feat(pulls): add label options and AI-written pull requests
Pulls now expose the label listing the UI needs for its pickers, and the platform can write the description for a pull request on request: it reads the branch diff and issue context and returns a draft the author can edit before saving.
ZhenYi committed
Added lib/pulls/src/handlers/labels/list_label_options.rs lib/pulls/src/handlers/labels/list_label_options.rs +34 −0 View lib/pulls/src/handlers/labels/list_label_options.rs
@@ -0,0 +1,34 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use git::handler::RepoPath;
4use session_layer::Session;
5
6use crate::errors::ApiError;
7use crate::services::service::PullService;
8use crate::share::lookup::repo_read;
9
10#[utoipa::path(
11 get,
12 path = "/repos/{space_name}/{repo_name}/pulls/label-options",
13 tag = "pulls",
14 description = "列出该仓库目录下可用于合并请求的标签(排除已归档)。",
15 params(RepoPath),
16 responses(
17 (status = 200, description = "label options", body = AppResponseData<Vec<issues::services::labels::LabelDto>>)
18 )
19)]
20pub async fn list_label_options(
21 State(service): State<PullService>,
22 session: Session,
23 Path(path): Path<RepoPath>,
24) -> Result<AppResponseData<Vec<issues::services::labels::LabelDto>>, ApiError> {
25 let (ctx, _) = repo_read(
26 &service,
27 &session,
28 path.space_name.clone(),
29 path.repo_name.clone(),
30 )
31 .await?;
32 let labels = service.list_label_options(ctx.model.dir_id).await?;
33 Ok(AppResponseData::ok(labels))
34}
Modified lib/pulls/src/handlers/labels/mod.rs lib/pulls/src/handlers/labels/mod.rs +1 −0 View lib/pulls/src/handlers/labels/mod.rs
@@ -1,3 +1,4 @@
11pub mod attach_pull_label;
22pub mod detach_pull_label;
3pub mod list_label_options;
34pub mod list_pull_labels;
Added lib/pulls/src/handlers/pulls/ai_fill_pull.rs lib/pulls/src/handlers/pulls/ai_fill_pull.rs +40 −0 View lib/pulls/src/handlers/pulls/ai_fill_pull.rs
@@ -0,0 +1,40 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::AppResponseData;
4use git::handler::RepoPath;
5use session_layer::Session;
6
7use crate::errors::ApiError;
8use crate::services::ai_fill::{AiFillPullCmd, AiFillPullResult};
9use crate::services::service::PullService;
10use crate::share::lookup::repo_write;
11
12#[utoipa::path(
13 post,
14 path = "/repos/{space_name}/{repo_name}/pulls/ai-fill",
15 tag = "pulls",
16 description = "依据 head…base 的提交与差异,让平台模型草拟合并请求标题与描述。",
17 params(RepoPath),
18 request_body = AiFillPullCmd,
19 responses(
20 (status = 200, description = "ai drafted pull request fields", body = AppResponseData<AiFillPullResult>)
21 )
22)]
23pub async fn ai_fill_pull(
24 State(service): State<PullService>,
25 session: Session,
26 Path(path): Path<RepoPath>,
27 Json(cmd): Json<AiFillPullCmd>,
28) -> Result<AppResponseData<AiFillPullResult>, ApiError> {
29 let (ctx, _) = repo_write(
30 &service,
31 &session,
32 path.space_name.clone(),
33 path.repo_name.clone(),
34 )
35 .await?;
36 let result = service
37 .ai_fill_pull(&path.space_name, &path.repo_name, &ctx, cmd)
38 .await?;
39 Ok(AppResponseData::ok(result))
40}
Modified lib/pulls/src/handlers/pulls/mod.rs lib/pulls/src/handlers/pulls/mod.rs +1 −0 View lib/pulls/src/handlers/pulls/mod.rs
@@ -1,3 +1,4 @@
1pub mod ai_fill_pull;
12pub mod close_pull;
23pub mod create_pull;
34pub mod delete_pull;
Modified lib/pulls/src/handlers/relations/unlink_issue_pull.rs lib/pulls/src/handlers/relations/unlink_issue_pull.rs +2 −2 View lib/pulls/src/handlers/relations/unlink_issue_pull.rs
@@ -58,7 +58,7 @@
5858 .await
5959 .map_err(PullError::from)?
6060 .ok_or(PullError::NotFound)?;
61 let (ctx, _caller) = repo_write(
61 let (ctx, caller) = repo_write(
6262 &service,
6363 &session,
6464 path.space_name.clone(),
@@ -66,7 +66,7 @@
6666 )
6767 .await?;
6868 service
69 .unlink_issue(&ctx, issue_id, path.relation_id)
69 .unlink_issue(&ctx, issue_id, path.relation_id, caller)
7070 .await?;
7171 Ok((StatusCode::OK, AppResponseData::ok(NoData)))
7272}
Added lib/pulls/src/services/ai_fill.rs lib/pulls/src/services/ai_fill.rs +201 −0 View lib/pulls/src/services/ai_fill.rs
@@ -0,0 +1,201 @@
1use git::context::facade::GitContext;
2use serde::{Deserialize, Serialize};
3use utoipa::ToSchema;
4
5use crate::errors::PullError;
6use crate::services::pulls::{MAX_BODY_LEN, MAX_TITLE_LEN};
7use crate::services::service::PullService;
8
9#[derive(Debug, Clone, Deserialize, ToSchema)]
10pub struct AiFillPullCmd {
11 pub head_branch: String,
12 #[serde(default)]
13 pub base_branch: Option<String>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
17pub struct AiFillPullResult {
18 pub title: String,
19 pub body: String,
20}
21
22const AI_FILL_SYSTEM_PROMPT: &str = r#"你是 GitData 平台的合并请求助手。用户会给出一次分支合并的提交列表与代码差异,你需要据此草拟合并请求的标题与描述。
23
24规则:
25- title:一句话概括这次合并的目的,动词开头,不超过 50 个字,不要带编号、引号或句号。
26- body:Markdown 格式,固定包含「## 做了什么」「## 为什么」「## 验证方式」三节,每节用要点列出;引用路径、命令、代码标识符时保持原文;无法从上下文判断的小节写「未提供」。
27- 只描述提交与差异中能够证实的内容,不要编造背景、影响面或测试结论。
28- 只输出一个 JSON 对象,不要输出 Markdown 代码块、思考过程或额外解释。
29
30JSON 结构:
31{"title":"...","body":"..."}"#;
32
33impl PullService {
34 /// 依据 head…base 的提交与差异,让平台模型草拟合并请求标题与描述。
35 pub async fn ai_fill_pull(
36 &self,
37 space_name: &str,
38 repo_name: &str,
39 ctx: &GitContext,
40 cmd: AiFillPullCmd,
41 ) -> Result<AiFillPullResult, PullError> {
42 let cfg = self.config().ai.clone();
43 if !cfg.enabled {
44 return Err(PullError::BadRequest("ai is not configured".to_string()));
45 }
46 let head_branch = cmd.head_branch.trim().to_string();
47 let base_branch = cmd
48 .base_branch
49 .as_deref()
50 .map(str::trim)
51 .filter(|value| !value.is_empty())
52 .unwrap_or(ctx.model.default_branch.as_str())
53 .to_string();
54 if head_branch.is_empty() {
55 return Err(PullError::BadRequest("head branch is required".to_string()));
56 }
57 if head_branch == base_branch {
58 return Err(PullError::BadRequest(
59 "head and base branch must differ".to_string(),
60 ));
61 }
62 let base_ref = format!("refs/heads/{base_branch}");
63 let head_ref = format!("refs/heads/{head_branch}");
64 let plan = self
65 .git
66 .merge_plan(ctx, &base_ref, &head_ref)
67 .await
68 .map_err(|error| match error {
69 git::share::errors::GitError::NotFound => PullError::BadRequest(
70 "head or base branch not found in this repository".to_string(),
71 ),
72 other => PullError::from(other),
73 })?;
74 if plan.head_only == 0 {
75 return Err(PullError::Conflict(
76 "the head branch has no commits ahead of the base branch".to_string(),
77 ));
78 }
79 let from = plan
80 .merge_base
81 .clone()
82 .unwrap_or_else(|| plan.base_sha.clone());
83 let commits = self
84 .git
85 .merge_commits(
86 space_name.to_string(),
87 repo_name.to_string(),
88 &base_ref,
89 &head_ref,
90 )
91 .await?;
92 let diff = self
93 .git
94 .diff_raw(
95 space_name.to_string(),
96 repo_name.to_string(),
97 from,
98 plan.head_sha.clone(),
99 )
100 .await?;
101 let stats = self
102 .diff_stats_for(space_name, repo_name, &plan, &plan.head_sha)
103 .await?;
104
105 let mut context = String::new();
106 context.push_str(&format!("仓库:{space_name}/{repo_name}\n"));
107 context.push_str(&format!(
108 "将 {head_branch} 合入 {base_branch}:{} 个提交、{} 个文件、+{} -{}。\n\n",
109 commits.len(),
110 stats.2,
111 stats.0,
112 stats.1
113 ));
114 context.push_str("提交列表:\n");
115 for commit in &commits {
116 let sha = &commit.sha[..commit.sha.len().min(10)];
117 context.push_str(&format!("- {sha} {}\n", commit.subject.trim()));
118 if let Some(body) = commit_body(&commit.message, &commit.subject) {
119 context.push_str(&body);
120 }
121 }
122 context.push_str("\n代码差异:\n");
123 context.push_str(&truncate_bytes(&diff, cfg.context_max_bytes));
124
125 let client = agent::client::AgentClient::new(&cfg)
126 .map_err(|error| PullError::BadRequest(error.to_string()))?;
127 let completion = client
128 .complete(AI_FILL_SYSTEM_PROMPT, &context, true)
129 .await
130 .map_err(|error| match error {
131 agent::client::AgentError::NotConfigured(message) => PullError::BadRequest(message),
132 agent::client::AgentError::Rejected { .. } => {
133 PullError::BadRequest(error.to_string())
134 }
135 other => PullError::Internal(other.to_string()),
136 })?;
137 let value = agent::client::extract_json(&completion.content)
138 .map_err(|error| PullError::Internal(error.to_string()))?;
139 let title = value
140 .get("title")
141 .and_then(|item| item.as_str())
142 .map(str::trim)
143 .filter(|text| !text.is_empty())
144 .map(str::to_string)
145 .unwrap_or_else(|| {
146 commits
147 .first()
148 .map(|commit| commit.subject.trim().to_string())
149 .unwrap_or_default()
150 });
151 if title.is_empty() {
152 return Err(PullError::Internal(
153 "ai returned an empty pull title".to_string(),
154 ));
155 }
156 let body = value
157 .get("body")
158 .and_then(|item| item.as_str())
159 .unwrap_or_default()
160 .trim()
161 .to_string();
162 Ok(AiFillPullResult {
163 title: clip_chars(&title, MAX_TITLE_LEN),
164 body: clip_chars(&body, MAX_BODY_LEN),
165 })
166 }
167}
168
169fn commit_body(message: &str, subject: &str) -> Option<String> {
170 let mut lines = message.lines();
171 lines.next();
172 let rest = lines.collect::<Vec<_>>().join("\n");
173 let trimmed = rest.trim();
174 if trimmed.is_empty() || trimmed == subject.trim() {
175 return None;
176 }
177 let mut body = String::from(" ");
178 body.push_str(&trimmed.chars().take(400).collect::<String>());
179 body.push('\n');
180 Some(body)
181}
182
183fn truncate_bytes(text: &str, limit: usize) -> String {
184 if text.len() <= limit {
185 return text.to_string();
186 }
187 let mut end = limit;
188 while end > 0 && !text.is_char_boundary(end) {
189 end -= 1;
190 }
191 let mut out = text[..end].to_string();
192 out.push_str("\n…(差异过大,已截断)");
193 out
194}
195
196fn clip_chars(text: &str, limit: usize) -> String {
197 if text.chars().count() <= limit {
198 return text.to_string();
199 }
200 text.chars().take(limit).collect()
201}
Modified lib/pulls/src/services/labels.rs lib/pulls/src/services/labels.rs +17 −0 View lib/pulls/src/services/labels.rs
@@ -40,6 +40,23 @@
4040 )))
4141 }
4242
43 /// 可附加到本仓库合并请求的标签候选(同目录、未归档),供侧栏搜索下拉使用。
44 pub async fn list_label_options(
45 &self,
46 dir_id: Uuid,
47 ) -> Result<Vec<issues::services::labels::LabelDto>, PullError> {
48 let rows = issue_label::Entity::find()
49 .filter(issue_label::Column::DirId.eq(dir_id))
50 .filter(issue_label::Column::IsArchived.eq(false))
51 .order_by_asc(issue_label::Column::Name)
52 .all(self.db_read())
53 .await?;
54 Ok(rows
55 .into_iter()
56 .map(issues::services::labels::LabelDto::from)
57 .collect())
58 }
59
4360 pub async fn list_labels(
4461 &self,
4562 pull_id: Uuid,
Modified lib/pulls/src/services/mod.rs lib/pulls/src/services/mod.rs +1 −0 View lib/pulls/src/services/mod.rs
@@ -1,4 +1,5 @@
11pub mod ai;
2pub mod ai_fill;
23pub mod approvals;
34pub mod assignees;
45pub mod changes;
Modified lib/pulls/src/services/pulls.rs lib/pulls/src/services/pulls.rs +5 −0 View lib/pulls/src/services/pulls.rs
@@ -287,6 +287,11 @@
287287 caller: Uuid,
288288 cmd: CreatePullCmd,
289289 ) -> Result<pull_requests::Model, PullError> {
290 if !ctx.model.allow_pull_requests {
291 return Err(PullError::BadRequest(
292 "pull requests are disabled for this repository".to_string(),
293 ));
294 }
290295 let title = cmd.title.trim();
291296 if title.is_empty() || title.chars().count() > MAX_TITLE_LEN {
292297 return Err(PullError::BadRequest(format!(
Modified lib/pulls/src/services/relations.rs lib/pulls/src/services/relations.rs +57 −2 View lib/pulls/src/services/relations.rs
@@ -1,4 +1,5 @@
11use git::context::facade::GitContext;
2use models::issue::issue_event_line::{self, IssueEventType};
23use models::issue::issue_relation_pull;
34use models::pull_request::pull_requests;
45use sea_orm::{
@@ -35,7 +36,7 @@
3536 &self,
3637 ctx: &GitContext,
3738 issue_id: Uuid,
38 _caller: Uuid,
39 caller: Uuid,
3940 pull_ref: &str,
4041 ) -> Result<pull_requests::Model, PullError> {
4142 let pull = crate::share::lookup::find_pull(self, ctx.model.id, pull_ref).await?;
@@ -45,14 +46,18 @@
4546 .one(self.db_read())
4647 .await?;
4748 if existing.is_none() {
49 let now = OffsetDateTime::now_utc();
4850 issue_relation_pull::ActiveModel {
4951 id: Set(Uuid::now_v7()),
5052 issue_id: Set(issue_id),
5153 pull_request_id: Set(pull.id),
52 created_at: Set(OffsetDateTime::now_utc()),
54 created_at: Set(now),
5355 }
5456 .insert(self.db_write())
5557 .await?;
58 // 关联也是一条活动记录:与「添加了标签」同级的 issue 事件。
59 self.write_pull_event(issue_id, caller, IssueEventType::PullLinked, &pull)
60 .await?;
5661 }
5762 Ok(pull)
5863 }
@@ -62,7 +67,12 @@
6267 ctx: &GitContext,
6368 issue_id: Uuid,
6469 relation_id: Uuid,
70 caller: Uuid,
6571 ) -> Result<(), PullError> {
72 let relation = issue_relation_pull::Entity::find_by_id(relation_id)
73 .filter(issue_relation_pull::Column::IssueId.eq(issue_id))
74 .one(self.db_read())
75 .await?;
6676 let deleted = issue_relation_pull::Entity::delete_many()
6777 .filter(issue_relation_pull::Column::Id.eq(relation_id))
6878 .filter(issue_relation_pull::Column::IssueId.eq(issue_id))
@@ -71,10 +81,55 @@
7181 if deleted.rows_affected == 0 {
7282 return Err(PullError::NotFound);
7383 }
84 if let Some(relation) = relation
85 && let Some(pull) = pull_requests::Entity::find_by_id(relation.pull_request_id)
86 .one(self.db_read())
87 .await?
88 {
89 self.write_pull_event(issue_id, caller, IssueEventType::PullUnlinked, &pull)
90 .await?;
91 }
7492 let _ = ctx;
7593 Ok(())
7694 }
7795
96 /// 写一条「关联/解除关联合并请求」的 issue 事件;事件文本为 `#<编号> <标题>`。
97 async fn write_pull_event(
98 &self,
99 issue_id: Uuid,
100 caller: Uuid,
101 event_type: IssueEventType,
102 pull: &pull_requests::Model,
103 ) -> Result<(), PullError> {
104 let title = pull.title.trim();
105 let clipped: String = title.chars().take(80).collect();
106 let label = format!(
107 "#{} {}{}",
108 pull.autoincrement_id,
109 clipped,
110 if title.chars().count() > 80 { "…" } else { "" }
111 );
112 let linked = matches!(event_type, IssueEventType::PullLinked);
113 issue_event_line::ActiveModel {
114 id: Set(Uuid::now_v7()),
115 issue_id: Set(Some(issue_id)),
116 project_id: Set(None),
117 user_id: Set(caller),
118 event_type: Set(event_type),
119 comment_id: Set(None),
120 commit_sha: Set(None),
121 old_title: Set(None),
122 new_title: Set(None),
123 old_ref: Set((!linked).then(|| label.clone())),
124 new_ref: Set(linked.then(|| label.clone())),
125 content: Set(Some(label)),
126 created_at: Set(OffsetDateTime::now_utc()),
127 }
128 .insert(self.db_write())
129 .await?;
130 Ok(())
131 }
132
78133 pub async fn list_linked_pulls(
79134 &self,
80135 issue_id: Uuid,
Modified lib/pulls/src/openapi.rs lib/pulls/src/openapi.rs +4 −0 View lib/pulls/src/openapi.rs
@@ -8,6 +8,7 @@
88 paths(
99 crate::handlers::pulls::list_pulls::list_pulls,
1010 crate::handlers::pulls::create_pull::create_pull,
11 crate::handlers::pulls::ai_fill_pull::ai_fill_pull,
1112 crate::handlers::pulls::get_pull::get_pull,
1213 crate::handlers::pulls::update_pull::update_pull,
1314 crate::handlers::pulls::delete_pull::delete_pull,
@@ -40,6 +41,7 @@
4041 crate::handlers::reviewers::list_requested_reviewers::list_requested_reviewers,
4142 crate::handlers::reviewers::request_reviewers::request_reviewers,
4243 crate::handlers::reviewers::delete_requested_reviewer::delete_requested_reviewer,
44 crate::handlers::labels::list_label_options::list_label_options,
4345 crate::handlers::labels::list_pull_labels::list_pull_labels,
4446 crate::handlers::labels::attach_pull_label::attach_pull_label,
4547 crate::handlers::labels::detach_pull_label::detach_pull_label,
@@ -56,6 +58,8 @@
5658 components(schemas(
5759 AppResponseData<NoData>,
5860 AppResponseData<crate::services::pulls::PullDto>,
61 AppResponseData<crate::services::ai_fill::AiFillPullResult>,
62 crate::services::ai_fill::AiFillPullCmd,
5963 AppResponseData<crate::services::pulls::PullPage>,
6064 AppResponseData<crate::services::comments::CommentPage>,
6165 AppResponseData<crate::services::events::EventPage>,
Modified lib/pulls/src/routes.rs lib/pulls/src/routes.rs +4 −0 View lib/pulls/src/routes.rs
@@ -20,7 +20,9 @@
2020use crate::handlers::inbox::list_my_pulls;
2121use crate::handlers::labels::attach_pull_label::attach_pull_label;
2222use crate::handlers::labels::detach_pull_label::detach_pull_label;
23use crate::handlers::labels::list_label_options::list_label_options;
2324use crate::handlers::labels::list_pull_labels::list_pull_labels;
25use crate::handlers::pulls::ai_fill_pull::ai_fill_pull;
2426use crate::handlers::pulls::close_pull::close_pull;
2527use crate::handlers::pulls::create_pull::create_pull;
2628use crate::handlers::pulls::delete_pull::delete_pull;
@@ -55,6 +57,8 @@
5557pub fn router() -> Router<crate::services::service::PullService> {
5658 Router::new()
5759 .route(PULLS, get(list_pulls).post(create_pull))
60 .route(&format!("{PULLS}/ai-fill"), post(ai_fill_pull))
61 .route(&format!("{PULLS}/label-options"), get(list_label_options))
5862 .route(ITEM, get(get_pull).patch(update_pull).delete(delete_pull))
5963 .route(&format!("{ITEM}/close"), post(close_pull))
6064 .route(&format!("{ITEM}/reopen"), post(reopen_pull))
Modified lib/pulls/Cargo.toml lib/pulls/Cargo.toml +1 −0 View lib/pulls/Cargo.toml
@@ -4,6 +4,7 @@
44edition.workspace = true
55
66[dependencies]
7agent = { workspace = true }
78config = { workspace = true }
89export = { workspace = true }
910git = { workspace = true }
Modified web/src/entities/pull-request/model/ai.ts web/src/entities/pull-request/model/ai.ts +16 −0 View web/src/entities/pull-request/model/ai.ts
@@ -8,6 +8,7 @@
88import { getLocale, translate } from '@/shared/i18n'
99import { unwrapApi } from '@/shared/lib'
1010import type {
11 AppResponseData_AiFillPullResult,
1112 AppResponseData_AiReviewDto,
1213 AppResponseData_AiReviewJobDto,
1314 AppResponseData_Vec_AiReviewDto,
@@ -22,6 +23,21 @@
2223>
2324export type AiFinding = PullAiReview['findings'][number]
2425
26/** `POST …/pulls/ai-fill` 的模型草稿:合并请求标题与描述。 */
27export type AiFillDraft = NonNullable<AppResponseData_AiFillPullResult['data']>
28
29/** 依据 head…base 的提交与差异,让平台模型草拟标题与描述。 */
30export const aiFillPull = (
31 space: string,
32 repo: string,
33 cmd: { head_branch: string; base_branch?: string | null },
34) =>
35 unwrapApi<AiFillDraft>(
36 Apis.pulls.ai_fill_pull({
37 pathParams: { space_name: space, repo_name: repo },
38 data: { head_branch: cmd.head_branch, base_branch: cmd.base_branch ?? null },
39 }),
40 )
2541/** 手动(重跑)PR 的 AI 评审。 */
2642export const triggerPullAiReview = (
2743 ref: { space: string; repo: string; pull: string },
Modified web/src/entities/pull-request/model/api.ts web/src/entities/pull-request/model/api.ts +6 −0 View web/src/entities/pull-request/model/api.ts
@@ -293,6 +293,12 @@
293293export const listPullLabels = (ref: PullRef) =>
294294 listOf<PullLabel>(Apis.pulls.list_pull_labels({ pathParams: pathOf(ref) }))
295295
296/** 可附加的标签候选(仓库目录标签库,未归档),供侧栏搜索下拉使用。 */
297export const listPullLabelOptions = (space: string, repo: string) =>
298 listOf<PullLabel>(
299 Apis.pulls.list_label_options({ pathParams: { space_name: space, repo_name: repo } }),
300 )
301
296302export const attachLabel = (ref: PullRef, label: string) =>
297303 ignore(Apis.pulls.attach_pull_label({ pathParams: pathOf(ref), data: { label } }))
298304
Modified web/src/features/pull-request/model/meta.ts web/src/features/pull-request/model/meta.ts +3 −0 View web/src/features/pull-request/model/meta.ts
@@ -63,6 +63,9 @@
6363 rebase: 'pull.mergeMethod.rebase',
6464}
6565
66/** 合并方式的可选顺序(供 <select> 迭代;Proxy 标签对象本身没有可枚举键)。 */
67export const MERGE_METHODS: MergeMethod[] = ['merge', 'squash', 'rebase']
68
6669export const MERGE_METHOD_LABEL = new Proxy({} as Record<MergeMethod, string>, {
6770 get: (_target, key: MergeMethod) => t(MERGE_METHOD_KEYS[key]),
6871})
Modified web/src/features/pull-request/ui/conversation.tsx web/src/features/pull-request/ui/conversation.tsx +14 −10 View web/src/features/pull-request/ui/conversation.tsx
@@ -1,4 +1,4 @@
1import { useMemo, useState } from 'react'
1import { useMemo, useState, type ReactNode } from 'react'
22import { Check, CheckCheck, Lock, MessageSquare, Pencil, Reply, RotateCcw, Send, Trash2 } from 'lucide-react'
33import { Alert, Button, Markdown } from '@/shared/ui'
44import { fmtRelative, fmtTime, useAsync } from '@/shared/lib'
@@ -32,10 +32,12 @@
3232 onChanged: () => void
3333 /** Jump to the files tab anchored at a path (from an inline comment). */
3434 onOpenFile: (path: string) => void
35 /** 时间线末尾、评论框之前的插槽(合并盒等页面级操作)。 */
36 beforeComposer?: ReactNode
3537}
3638
3739/** 会话页签:评论线程 + 评审卡片 + 事件时间线,按时间归并。 */
38export function Conversation({ refObj, pull, onChanged, onOpenFile }: ConversationProps) {
40export function Conversation({ refObj, pull, onChanged, onOpenFile, beforeComposer }: ConversationProps) {
3941 const { t } = useT()
4042 const [tick, setTick] = useState(0)
4143 const refresh = () => {
@@ -74,6 +76,8 @@
7476 ),
7577 )}
7678
79 {beforeComposer}
80
7781 <Composer refObj={refObj} pull={pull} onPosted={refresh} />
7882 </div>
7983 )
@@ -114,8 +118,8 @@
114118
115119 return (
116120 <div className="flex gap-2.5">
117 <Avatar user={comment.author} size={26} />
118 <div className="min-w-0 flex-1 rounded-lg border border-shell-hairline bg-shell-field px-3 py-2">
121 <Avatar user={comment.author} size={24} />
122 <div className="min-w-0 flex-1 px-0.5">
119123 <div className="flex flex-wrap items-center gap-2 text-[12px] text-shell-ink-muted">
120124 <span className="text-[12.5px] font-semibold text-shell-ink">{userName(comment.author)}</span>
121125 <span title={fmtTime(comment.created_at)}>{fmtRelative(comment.created_at)}</span>
@@ -144,7 +148,7 @@
144148 </div>
145149 </div>
146150 ) : (
147 <p className="mt-1 whitespace-pre-wrap break-words text-[13px] leading-6">{comment.body}</p>
151 <Markdown className="mt-1">{comment.body}</Markdown>
148152 )}
149153 </div>
150154 </div>
@@ -183,8 +187,8 @@
183187 }
184188
185189 return (
186 <div className="rounded-xl border border-shell-hairline bg-shell-surface p-4">
187 <div className="mb-2 flex flex-wrap items-center gap-2 text-[12px] text-shell-ink-muted">
190 <div className="rounded-lg border border-shell-hairline bg-shell-surface px-3 py-2.5">
191 <div className="mb-1.5 flex flex-wrap items-center gap-2 text-[12px] text-shell-ink-muted">
188192 <MessageSquare className="h-3.5 w-3.5" aria-hidden />
189193 <span className="font-medium text-shell-ink">{inline ? t('pull.conv.inline') : t('pull.conv.thread')}</span>
190194 {inline ? (
@@ -203,14 +207,14 @@
203207 </button>
204208 ) : null}
205209 </div>
206 <div className="space-y-2.5">
210 <div className="space-y-2">
207211 <CommentBubble refObj={refObj} comment={root} onChanged={onChanged} />
208212 {thread.replies.map(replyComment => (
209213 <CommentBubble key={replyComment.id} refObj={refObj} comment={replyComment} onChanged={onChanged} />
210214 ))}
211215 </div>
212216 {replying ? (
213 <div className="mt-2 flex gap-2">
217 <div className="mt-1.5 flex gap-2">
214218 <textarea
215219 autoFocus
216220 value={draft}
@@ -227,7 +231,7 @@
227231 </div>
228232 </div>
229233 ) : (
230 <button type="button" onClick={() => setReplying(true)} className="mt-2 inline-flex items-center gap-1 text-[12px] text-shell-ink-muted hover:text-shell-ink">
234 <button type="button" onClick={() => setReplying(true)} className="mt-1.5 inline-flex items-center gap-1 text-[12px] text-shell-ink-muted hover:text-shell-ink">
231235 <Reply className="h-3.5 w-3.5" aria-hidden />{t('pull.conv.reply')}
232236 </button>
233237 )}
Modified web/src/features/pull-request/ui/parts.tsx web/src/features/pull-request/ui/parts.tsx +111 −4 View web/src/features/pull-request/ui/parts.tsx
@@ -1,7 +1,7 @@
1import { Fragment, type ReactNode } from 'react'
1import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
22import type { MessageKey } from '@/shared/i18n'
3import { GitMerge, MessageSquareText, Tag as TagIcon } from 'lucide-react'
4import { InitialAvatar } from '@/shared/ui'
3import { ChevronDown, GitMerge, MessageSquareText, Search, Tag as TagIcon } from 'lucide-react'
4import { InitialAvatar, Markdown } from '@/shared/ui'
55import { shortSha } from '@/shared/lib'
66import { t as tr, useT } from '@/shared/i18n'
77import { parseHunks, type DiffFile, type DiffLine } from '@/entities/repo'
@@ -143,7 +143,7 @@
143143 {comments.map(comment => (
144144 <tr key={comment.id}>
145145 <td colSpan={4} className="border-b border-shell-hairline bg-shell-field/60 px-3 py-1.5">
146 <div className="whitespace-pre-wrap break-words font-sans text-[12.5px] text-shell-ink">{comment.body}</div>
146 <Markdown compact className="font-sans">{comment.body}</Markdown>
147147 </td>
148148 </tr>
149149 ))}
@@ -191,3 +191,110 @@
191191 </span>
192192 )
193193}
194
195export interface SearchSelectOption {
196 value: string
197 label: string
198 /** 右侧灰字(用户名等补充信息)。 */
199 hint?: string
200 /** 左侧色点(标签颜色)。 */
201 color?: string | null
202 /** 左侧自定义图形(头像等)。 */
203 icon?: ReactNode
204}
205
206/**
207 * 可搜索下拉:触发按钮 + 弹出搜索框 + 过滤后的选项列表。
208 * 侧栏的评审人/受理人/标签只能从这里选,避免手输拼错或写入无效值。
209 */
210export function SearchSelect({
211 placeholder,
212 options,
213 onSelect,
214 disabled = false,
215 emptyText,
216}: {
217 placeholder: string
218 options: SearchSelectOption[]
219 onSelect: (value: string) => void
220 disabled?: boolean
221 emptyText: string
222}) {
223 const box = useRef<HTMLDivElement | null>(null)
224 const [open, setOpen] = useState(false)
225 const [query, setQuery] = useState('')
226 const trimmed = query.trim().toLowerCase()
227 const filtered = trimmed
228 ? options.filter(option => `${option.label} ${option.hint ?? ''}`.toLowerCase().includes(trimmed))
229 : options
230
231 useEffect(() => {
232 if (!open) return
233 const onDown = (event: MouseEvent) => {
234 if (box.current && !box.current.contains(event.target as Node)) setOpen(false)
235 }
236 const onKey = (event: KeyboardEvent) => {
237 if (event.key === 'Escape') setOpen(false)
238 }
239 window.addEventListener('mousedown', onDown)
240 window.addEventListener('keydown', onKey)
241 return () => {
242 window.removeEventListener('mousedown', onDown)
243 window.removeEventListener('keydown', onKey)
244 }
245 }, [open])
246
247 return (
248 <div ref={box} className="relative mt-2">
249 <button
250 type="button"
251 aria-expanded={open}
252 aria-haspopup="listbox"
253 disabled={disabled}
254 onClick={() => {
255 setQuery('')
256 setOpen(value => !value)
257 }}
258 className="flex h-7 w-full items-center gap-1.5 rounded-md border border-shell-hairline bg-shell-field px-2 text-left text-[12px] text-shell-ink-muted outline-none transition-colors hover:text-shell-ink focus-visible:border-shell-tile disabled:pointer-events-none disabled:opacity-50"
259 >
260 <Search className="h-3 w-3 shrink-0 opacity-70" aria-hidden />
261 <span className="min-w-0 flex-1 truncate">{placeholder}</span>
262 <ChevronDown className="h-3 w-3 shrink-0 opacity-70" aria-hidden />
263 </button>
264 {open ? (
265 <div className="absolute left-0 right-0 top-full z-30 mt-1 rounded-lg border border-shell-hairline bg-shell-surface shadow-lg">
266 <input
267 autoFocus
268 value={query}
269 onChange={event => setQuery(event.target.value)}
270 placeholder={placeholder}
271 className="h-7 w-full rounded-t-lg border-b border-shell-hairline bg-shell-field px-2 text-[12px] outline-none"
272 />
273 <div role="listbox" className="max-h-44 overflow-y-auto py-1">
274 {filtered.length === 0 ? (
275 <p className="px-2 py-1.5 text-[12px] text-shell-ink-muted">{emptyText}</p>
276 ) : (
277 filtered.map(option => (
278 <button
279 key={option.value}
280 type="button"
281 role="option"
282 aria-selected={false}
283 onClick={() => {
284 setOpen(false)
285 onSelect(option.value)
286 }}
287 className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-[12.5px] text-shell-ink transition-colors hover:bg-shell-active"
288 >
289 {option.icon ?? (option.color ? <span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: option.color }} aria-hidden /> : null)}
290 <span className="min-w-0 flex-1 truncate">{option.label}</span>
291 {option.hint ? <span className="shrink-0 text-[11.5px] text-shell-ink-muted">{option.hint}</span> : null}
292 </button>
293 ))
294 )}
295 </div>
296 </div>
297 ) : null}
298 </div>
299 )
300}
No newline at end of file
Modified web/src/features/pull-request/ui/pull-detail.tsx web/src/features/pull-request/ui/pull-detail.tsx +118 −82 View web/src/features/pull-request/ui/pull-detail.tsx
@@ -4,10 +4,10 @@
44 FileCode2,
55 GitMerge,
66 ListChecks,
7 Loader2,
78 MessagesSquare,
89 Pencil,
910 RotateCcw,
10 Send,
1111 ShieldCheck,
1212 Sparkles,
1313 Unlock,
@@ -16,6 +16,7 @@
1616import { Alert, Button, ErrorPanel, Modal, PanelNote, modalInputClass, modalLabelClass, modalTextareaClass } from '@/shared/ui'
1717import { fmtRelative, fmtTime, repoKey, useAsync } from '@/shared/lib'
1818import { t as tr, useT } from '@/shared/i18n'
19import { listSpaceMembers } from '@/entities/issue'
1920import { fetchConflictDetail } from '@/entities/repo'
2021import {
2122 addAssignee,
@@ -26,6 +27,7 @@
2627 getPull,
2728 getPullGate,
2829 listAssignees,
30 listPullLabelOptions,
2931 listPullLabels,
3032 listRequestedReviewers,
3133 lockPull,
@@ -42,8 +44,8 @@
4244} from '@/entities/pull-request'
4345import type { MessageKey } from '@/shared/i18n'
4446import type { MergeMethod, Pull, PullGate, PullRef, ReviewerState } from '@/entities/pull-request'
45import { MERGE_METHOD_LABEL, REVIEWER_STATE_LABEL, TONE, userName } from '../model/meta'
46import { Avatar, MergeableChip, PullStateBadge, SideSection } from './parts'
47import { MERGE_METHOD_LABEL, MERGE_METHODS, REVIEWER_STATE_LABEL, TONE, userName } from '../model/meta'
48import { Avatar, MergeableChip, PullStateBadge, SearchSelect, SideSection } from './parts'
4749import { Conversation } from './conversation'
4850import { FilesTab } from './files-tab'
4951import { CommitsTab } from './commits-tab'
@@ -56,6 +58,8 @@
5658 space: string
5759 repo: string
5860 pullRef: string
61 /** Repo write access (space owner / repo collaborator server-side). */
62 canWrite: boolean
5963 ptab: PullSubTab
6064 onPtabChange: (next: PullSubTab) => void
6165 onBackToList: () => void
@@ -79,7 +83,7 @@
7983}
8084
8185/** PR 详情页:头部/操作 + 子页签 + 侧栏。 */
82export function PullDetail({ space, repo, pullRef, ptab, onPtabChange, onBackToList, onOpenCommit }: PullDetailProps) {
86export function PullDetail({ space, repo, pullRef, canWrite, ptab, onPtabChange, onBackToList, onOpenCommit }: PullDetailProps) {
8387 const { t } = useT()
8488 const [tick, setTick] = useState(0)
8589 const [focusFile, setFocusFile] = useState('')
@@ -92,7 +96,7 @@
9296 const pullState = useAsync(() => getPull(refObj), [space, repo, pullRef, tick], {
9397 cacheKey: repoKey(space, repo, `pull:${pullRef}:${tick}`),
9498 })
95 const gateState = useAsync(() => getPullGate(refObj).catch(() => null), [space, repo, pullRef, tick])
99 const gateState = useAsync(() => getPullGate(refObj), [space, repo, pullRef, tick])
96100 const pull = pullState.data
97101 const gate = gateState.data
98102 const failure = pullState.error as { code?: number; message?: string } | undefined
@@ -170,6 +174,16 @@
170174 refObj={refObj}
171175 pull={pull}
172176 onChanged={refresh}
177 beforeComposer={pull.state === 'open' && canWrite ? (
178 <MergeBox
179 refObj={refObj}
180 pull={pull}
181 gate={gate}
182 analyzing={gateState.loading && !gateState.data}
183 onChanged={refresh}
184 onError={setError}
185 />
186 ) : null}
173187 onOpenFile={path => {
174188 setFocusFile(path)
175189 onPtabChange('files')
@@ -195,31 +209,33 @@
195209 </div>
196210
197211 <div className="w-full shrink-0 space-y-3 lg:w-[300px]">
198 {pull.state === 'open' ? <MergeBox refObj={refObj} pull={pull} gate={gate} onChanged={refresh} onError={setError} /> : null}
199
200 <SideSection title={t('pull.detail.actions')}>
212 {canWrite ? <SideSection title={t('pull.detail.actions')}>
201213 <div className="flex flex-wrap gap-1.5">
202 {pull.state === 'open' ? (
214 {canWrite && pull.state === 'open' ? (
203215 <Button variant="secondary" className="h-7 gap-1 text-[12px]" onClick={() => void run(t('pull.detail.close'), () => closePull(refObj))}>
204216 <X className="h-3 w-3" aria-hidden />{t('pull.detail.close')}
205217 </Button>
206 ) : pull.state === 'closed' ? (
218 ) : canWrite && pull.state === 'closed' ? (
207219 <Button variant="secondary" className="h-7 gap-1 text-[12px]" onClick={() => void run(t('pull.detail.reopen'), () => reopenPull(refObj))}>
208220 <RotateCcw className="h-3 w-3" aria-hidden />{t('pull.detail.reopen')}
209221 </Button>
210222 ) : null}
211 <EditButton refObj={refObj} pull={pull} onChanged={refresh} onError={setError} />
212 <Button
213 variant="secondary"
214 className="h-7 gap-1 text-[12px]"
215 onClick={() => void run(pull.locked ? t('pull.detail.unlock') : t('pull.detail.lock'), () => (pull.locked ? unlockPull(refObj) : lockPull(refObj)))}
216 >
217 {pull.locked ? <Unlock className="h-3 w-3" aria-hidden /> : <ShieldCheck className="h-3 w-3" aria-hidden />}
218 {pull.locked ? t('pull.detail.unlockDiscussion') : t('pull.detail.lockDiscussion')}
219 </Button>
220 <Button variant="secondary" className="h-7 gap-1 text-[12px]" onClick={() => void run(t('pull.detail.refreshSummary'), () => refreshPullSummary(refObj))}>
221 <RotateCcw className="h-3 w-3" aria-hidden />{t('pull.detail.refreshSummary')}
222 </Button>
223 {canWrite ? <EditButton refObj={refObj} pull={pull} onChanged={refresh} onError={setError} /> : null}
224 {canWrite ? (
225 <Button
226 variant="secondary"
227 className="h-7 gap-1 text-[12px]"
228 onClick={() => void run(pull.locked ? t('pull.detail.unlock') : t('pull.detail.lock'), () => (pull.locked ? unlockPull(refObj) : lockPull(refObj)))}
229 >
230 {pull.locked ? <Unlock className="h-3 w-3" aria-hidden /> : <ShieldCheck className="h-3 w-3" aria-hidden />}
231 {pull.locked ? t('pull.detail.unlockDiscussion') : t('pull.detail.lockDiscussion')}
232 </Button>
233 ) : null}
234 {canWrite ? (
235 <Button variant="secondary" className="h-7 gap-1 text-[12px]" onClick={() => void run(t('pull.detail.refreshSummary'), () => refreshPullSummary(refObj))}>
236 <RotateCcw className="h-3 w-3" aria-hidden />{t('pull.detail.refreshSummary')}
237 </Button>
238 ) : null}
223239 <Button
224240 variant="secondary"
225241 className="h-7 gap-1 text-[12px]"
@@ -227,25 +243,27 @@
227243 >
228244 <Download className="h-3 w-3" aria-hidden />{t('pull.detail.patch')}
229245 </Button>
230 <Button
231 variant="ghost"
232 className="h-7 text-[12px] text-[#b42318]"
233 onClick={() => {
234 if (!window.confirm(t('pull.detail.deleteConfirm', { number: pull.number }))) return
235 void (async () => {
236 try {
237 await deletePull(refObj)
238 onBackToList()
239 } catch (f) {
240 setError(t('pull.detail.deleteFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
241 }
242 })()
243 }}
244 >
245 {t('common.action.delete')}
246 </Button>
246 {canWrite ? (
247 <Button
248 variant="ghost"
249 className="h-7 text-[12px] text-[#b42318]"
250 onClick={() => {
251 if (!window.confirm(t('pull.detail.deleteConfirm', { number: pull.number }))) return
252 void (async () => {
253 try {
254 await deletePull(refObj)
255 onBackToList()
256 } catch (f) {
257 setError(t('pull.detail.deleteFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
258 }
259 })()
260 }}
261 >
262 {t('common.action.delete')}
263 </Button>
264 ) : null}
247265 </div>
248 </SideSection>
266 </SideSection> : null}
249267
250268 <ReviewersPanel refObj={refObj} pull={pull} tick={tick} onChanged={refresh} onError={setError} />
251269 <AssigneesPanel refObj={refObj} pull={pull} tick={tick} onChanged={refresh} onError={setError} />
@@ -271,10 +289,12 @@
271289
272290/* ------------------------------------------------------------ merge box */
273291
274function MergeBox({ refObj, pull, gate, onChanged, onError }: {
292function MergeBox({ refObj, pull, gate, analyzing, onChanged, onError }: {
275293 refObj: PullRef
276294 pull: Pull
277295 gate: PullGate | null | undefined
296 /** 合并冲突分析尚未完成:先显示“分析中”,不提前给出可合并结论。 */
297 analyzing: boolean
278298 onChanged: () => void
279299 onError: (message: string) => void
280300}) {
@@ -310,8 +330,14 @@
310330 }
311331
312332 return (
313 <SideSection title={t('pull.detail.merge')}>
314 {pull.is_draft ? (
333 <section className="rounded-xl border border-shell-hairline bg-shell-surface p-4">
334 <h4 className="mb-2 text-[13px] font-semibold text-shell-ink">{t('pull.detail.merge')}</h4>
335 {analyzing ? (
336 <p className="flex items-center gap-1.5 text-[12.5px] text-shell-ink-muted">
337 <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
338 {t('pull.detail.analyzingMerge')}
339 </p>
340 ) : pull.is_draft ? (
315341 <p className="text-[12.5px] text-[#8a6100]">{t('pull.detail.draftMergeHint')}</p>
316342 ) : conflicts.length ? (
317343 <div className="text-[12.5px] text-[#b42318]">
@@ -359,7 +385,7 @@
359385 <label className="block">
360386 <span className={modalLabelClass}>{t('pull.mergeMethod')}</span>
361387 <select className={modalInputClass} value={method} onChange={event => setMethod(event.target.value as MergeMethod)}>
362 {(Object.keys(MERGE_METHOD_LABEL) as MergeMethod[]).map(value => (
388 {MERGE_METHODS.map(value => (
363389 <option key={value} value={value}>{MERGE_METHOD_LABEL[value]}</option>
364390 ))}
365391 </select>
@@ -372,7 +398,7 @@
372398 </label>
373399 <Button
374400 className="h-8 w-full gap-1 text-[13px]"
375 disabled={busy || pull.is_draft || conflicts.length > 0 || blocked || stale}
401 disabled={busy || analyzing || pull.is_draft || conflicts.length > 0 || blocked || stale}
376402 onClick={() => void merge()}
377403 >
378404 <GitMerge className="h-3.5 w-3.5" aria-hidden />{busy ? t('pull.detail.merging') : t('pull.detail.merge')}
@@ -395,7 +421,7 @@
395421 {t('pull.detail.autoMerge')}
396422 </label>
397423 </div>
398 </SideSection>
424 </section>
399425 )
400426}
401427
@@ -491,37 +517,23 @@
491517
492518/* ------------------------------------------------------------ side panels */
493519
494function AddLine({ placeholder, onAdd }: { placeholder: string; onAdd: (value: string) => Promise<void> }) {
495 const [value, setValue] = useState('')
496 const [busy, setBusy] = useState(false)
497 return (
498 <div className="mt-2 flex gap-1.5">
499 <input
500 value={value}
501 onChange={event => setValue(event.target.value)}
502 placeholder={placeholder}
503 className="h-7 min-w-0 flex-1 rounded-md border border-shell-hairline bg-shell-field px-2 text-[12px] outline-none focus:border-shell-tile"
504 />
505 <Button
506 variant="secondary"
507 className="h-7 px-2"
508 disabled={busy || !value.trim()}
509 onClick={() => {
510 setBusy(true)
511 void onAdd(value.trim()).then(() => setValue('')).finally(() => setBusy(false))
512 }}
513 >
514 <Send className="h-3 w-3" aria-hidden />
515 </Button>
516 </div>
517 )
518}
519520
520521function ReviewersPanel({ refObj, pull, tick, onChanged, onError }: { refObj: PullRef; pull: Pull; tick: number; onChanged: () => void; onError: (m: string) => void }) {
521522 const { t } = useT()
522523 const [required, setRequired] = useState(true)
523524 const reviewers = useAsync(() => listRequestedReviewers(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
524525 const rows = reviewers.data ?? []
526 const members = useAsync(() => listSpaceMembers(refObj.space), [refObj.space])
527 const memberOptions = (members.data?.items ?? [])
528 .map(row => row.user)
529 .filter((user): user is NonNullable<typeof user> => Boolean(user))
530 .filter(user => !rows.some(reviewer => reviewer.user?.username === user.username))
531 .map(user => ({
532 value: user.username,
533 label: user.display_name || user.username,
534 hint: `@${user.username}`,
535 icon: <Avatar user={user} size={16} />,
536 }))
525537
526538 async function add(name: string) {
527539 try {
@@ -567,7 +579,14 @@
567579 ) : (
568580 <p className="text-[12.5px] text-shell-ink-muted">{t('pull.detail.noReviewers')}</p>
569581 )}
570 {pull.state === 'open' ? <AddLine placeholder={t('pull.detail.reviewRequestPlaceholder')} onAdd={add} /> : null}
582 {pull.state === 'open' ? (
583 <SearchSelect
584 placeholder={t('pull.detail.reviewRequestPlaceholder')}
585 options={memberOptions}
586 emptyText={t('pull.detail.pickNoMatch')}
587 onSelect={value => void add(value).catch(() => undefined)}
588 />
589 ) : null}
571590 </SideSection>
572591 )
573592}
@@ -580,6 +599,17 @@
580599 const { t } = useT()
581600 const assignees = useAsync(() => listAssignees(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
582601 const rows = assignees.data ?? []
602 const members = useAsync(() => listSpaceMembers(refObj.space), [refObj.space])
603 const memberOptions = (members.data?.items ?? [])
604 .map(row => row.user)
605 .filter((user): user is NonNullable<typeof user> => Boolean(user))
606 .filter(user => !rows.some(assignee => assignee.user?.username === user.username))
607 .map(user => ({
608 value: user.username,
609 label: user.display_name || user.username,
610 hint: `@${user.username}`,
611 icon: <Avatar user={user} size={16} />,
612 }))
583613 return (
584614 <SideSection title={`${t('pull.detail.assignees')}${rows.length ? ` (${rows.length})` : ''}`}>
585615 {rows.length ? (
@@ -605,17 +635,18 @@
605635 <p className="text-[12.5px] text-shell-ink-muted">{t('pull.detail.noAssignees')}</p>
606636 )}
607637 {pull.state === 'open' ? (
608 <AddLine
638 <SearchSelect
609639 placeholder={t('pull.detail.assigneePlaceholder')}
610 onAdd={async name => {
640 options={memberOptions}
641 emptyText={t('pull.detail.pickNoMatch')}
642 onSelect={value => void (async () => {
611643 try {
612 await addAssignee(refObj, name)
644 await addAssignee(refObj, value)
613645 onChanged()
614646 } catch (f) {
615647 onError(t('pull.detail.addAssigneeFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
616 throw f
617648 }
618 }}
649 })()}
619650 />
620651 ) : null}
621652 </SideSection>
@@ -626,6 +657,10 @@
626657 const { t } = useT()
627658 const labels = useAsync(() => listPullLabels(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
628659 const rows = labels.data ?? []
660 const candidates = useAsync(() => listPullLabelOptions(refObj.space, refObj.repo), [refObj.space, refObj.repo, tick])
661 const labelOptions = (candidates.data ?? [])
662 .filter(label => !rows.some(bound => bound.id === label.id))
663 .map(label => ({ value: label.id, label: label.name, color: label.color }))
629664 return (
630665 <SideSection title={t('issue.board.labelsField')}>
631666 {rows.length ? (
@@ -637,17 +672,18 @@
637672 ) : (
638673 <p className="text-[12.5px] text-shell-ink-muted">{t('pull.detail.noLabels')}</p>
639674 )}
640 <AddLine
675 <SearchSelect
641676 placeholder={t('pull.detail.labelPlaceholder')}
642 onAdd={async name => {
677 options={labelOptions}
678 emptyText={t('pull.detail.pickNoMatch')}
679 onSelect={value => void (async () => {
643680 try {
644 await attachLabel(refObj, name)
681 await attachLabel(refObj, value)
645682 onChanged()
646683 } catch (f) {
647684 onError(t('pull.detail.attachLabelFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
648 throw f
649685 }
650 }}
686 })()}
651687 />
652688 </SideSection>
653689 )
Modified web/src/features/pull-request/ui/pull-form-modal.tsx web/src/features/pull-request/ui/pull-form-modal.tsx +28 −4 View web/src/features/pull-request/ui/pull-form-modal.tsx
@@ -1,8 +1,8 @@
11import { useEffect, useState } from 'react'
2import { AlertCircle, CheckCircle2 } from 'lucide-react'
2import { AlertCircle, CheckCircle2, Sparkles } from 'lucide-react'
33import { Button } from '@/shared/ui'
44import { listBranches } from '@/entities/repo'
5import { checkMergeStatus, createPull } from '@/entities/pull-request'
5import { aiFillPull, checkMergeStatus, createPull } from '@/entities/pull-request'
66import type { MergeStatus, Pull } from '@/entities/pull-request'
77import { Modal, modalInputClass, modalLabelClass, modalTextareaClass } from '@/shared/ui'
88import { useT } from '@/shared/i18n'
@@ -25,6 +25,7 @@
2525 const [body, setBody] = useState('')
2626 const [draft, setDraft] = useState(false)
2727 const [busy, setBusy] = useState(false)
28 const [aiBusy, setAiBusy] = useState(false)
2829 const [error, setError] = useState<string | null>(null)
2930 const [probe, setProbe] = useState<{ key: string; value: MergeStatus | 'failed' } | null>(null)
3031
@@ -64,6 +65,21 @@
6465 }
6566 }, [space, repo, base, head, pairKey])
6667
68 async function aiFill() {
69 if (!head || !base || head === base || aiBusy) return
70 setAiBusy(true)
71 setError(null)
72 try {
73 const draft = await aiFillPull(space, repo, { head_branch: head, base_branch: base })
74 setTitle(draft.title.slice(0, 200))
75 setBody(draft.body)
76 } catch (failure) {
77 setError(failure instanceof Error ? failure.message : t('pull.form.aiFillFailed'))
78 } finally {
79 setAiBusy(false)
80 }
81 }
82
6783 async function submit() {
6884 if (!title.trim() || !head || !base) {
6985 setError(t('pull.form.missingFields'))
@@ -98,8 +114,16 @@
98114 onClose={onClose}
99115 footer={
100116 <>
101 <Button variant="ghost" onClick={onClose}>{t('common.action.cancel')}</Button>
102 <Button disabled={busy} onClick={() => void submit()}>
117 <Button
118 variant="secondary"
119 disabled={busy || aiBusy || !head || !base || head === base}
120 onClick={() => void aiFill()}
121 >
122 <Sparkles className="h-3.5 w-3.5" aria-hidden />
123 {aiBusy ? t('pull.form.aiFilling') : t('pull.form.aiFill')}
124 </Button>
125 <Button variant="ghost" disabled={aiBusy} onClick={onClose}>{t('common.action.cancel')}</Button>
126 <Button disabled={busy || aiBusy} onClick={() => void submit()}>
103127 {busy ? t('pull.form.creating') : draft ? t('pull.form.createDraft') : t('pull.form.create')}
104128 </Button>
105129 </>
Modified web/src/features/pull-request/ui/pulls-tab.tsx web/src/features/pull-request/ui/pulls-tab.tsx +24 −8 View web/src/features/pull-request/ui/pulls-tab.tsx
@@ -19,7 +19,12 @@
1919 /** `RepoDetail.id` — the inbox can only deep-link PRs of this repo. */
2020 repoId: string
2121 defaultBranch: string
22 /** Repo write access (space owner / repo collaborator server-side). */
23 canWrite: boolean
24 /** 详情面板当前打开的 pull_ref;与其匹配的行再次点击即关闭详情。 */
25 activePull?: string | null
2226 onOpenPull: (pullRef: string) => void
27 onClosePull: () => void
2328}
2429
2530const SCOPE_KEYS: Record<MyPullScope, 'pull.list.scope.created' | 'pull.list.scope.reviewRequested' | 'pull.list.scope.reviewed'> = {
@@ -29,7 +34,7 @@
2934}
3035
3136/** 合并请求 页签:本仓库列表 + “我的 PR” 收件箱(限定本仓库,可深链)。 */
32export function PullsTab({ space, repo, repoId, defaultBranch, onOpenPull }: PullsTabProps) {
37export function PullsTab({ space, repo, repoId, defaultBranch, canWrite, activePull, onOpenPull, onClosePull }: PullsTabProps) {
3338 const { t, tc } = useT()
3439 const [source, setSource] = useState<'repo' | 'mine'>('repo')
3540 const [scope, setScope] = useState<MyPullScope>('review-requested')
@@ -120,9 +125,11 @@
120125 />
121126 </label>
122127 )}
123 <Button className="ml-auto h-8 gap-1 text-[12.5px]" onClick={() => setCreating(true)}>
124 <Plus className="h-3.5 w-3.5" aria-hidden /> {t('pull.list.new')}
125 </Button>
128 {canWrite ? (
129 <Button className="ml-auto h-8 gap-1 text-[12.5px]" onClick={() => setCreating(true)}>
130 <Plus className="h-3.5 w-3.5" aria-hidden /> {t('pull.list.new')}
131 </Button>
132 ) : null}
126133 </div>
127134
128135 <PullFilter value={filter} onChange={next => withReset(() => setFilter(next))()} />
@@ -137,9 +144,17 @@
137144
138145 {rows.length > 0 ? (
139146 <ul className="divide-y divide-shell-hairline rounded-xl border border-shell-hairline bg-shell-surface">
140 {rows.map(pull => (
141 <li key={pull.id} className="px-4 py-3">
142 <button type="button" onClick={() => onOpenPull(String(pull.number))} className="group flex w-full items-start gap-3 text-left">
147 {rows.map(pull => {
148 const ref = String(pull.number)
149 const active = activePull != null && (activePull === ref || activePull === pull.id)
150 return (
151 <li key={pull.id} className={`px-4 py-3 ${active ? 'bg-shell-active/50' : ''}`}>
152 <button
153 type="button"
154 aria-current={active ? 'true' : undefined}
155 onClick={() => (active ? onClosePull() : onOpenPull(ref))}
156 className="group flex w-full items-start gap-3 text-left"
157 >
143158 <span className="mt-0.5"><PullStateBadge pull={pull} /></span>
144159 <span className="min-w-0 flex-1">
145160 <span className="block truncate text-[14px] font-semibold leading-6 group-hover:underline">{pull.title}</span>
@@ -153,7 +168,8 @@
153168 <Avatar user={pull.author} size={22} />
154169 </button>
155170 </li>
156 ))}
171 )
172 })}
157173 </ul>
158174 ) : null}
159175