Skip to content
Clone

feat(pulls): add pull request api crate

New crate wired into /api/v1 with 31 paths / 43 operations:
- core: list/create/get/update/delete, close/reopen, merge, commits, files,
  patch, summary, events, lock/unlock, update-branch
- review: reviews with pending drafts and submit/dismiss, inline comment
  threads with resolve, requested reviewers feeding the approval gate
- taxonomy: labels, assignees, issue links via issue_relation_pull
- aggregate: /pulls/{n}/checks, /pulls/{n}/gate, /pulls/@me/{scope}
Merge decisions reuse git::service::merge and enforce the branch protection
rules; repo read/write authorization reuses git::share::access so pull requests
inherit the same space/member/collaboration model. Adds quota.pull_create_per_day.
ZhenYi committed
parent 2a93ffc commit 34249cb Browse files

81 files changed

+5773 −3

File tree 81
Modified config/default.toml View config/default.toml
@@ -124,6 +124,7 @@
124124space_create_per_day = 10
125125room_create_per_day = 50
126126issue_create_per_day = 300
127pull_create_per_day = 200
127128comment_create_per_day = 1000
128129dm_open_per_day = 100
129130webhook_create_per_day = 20
Modified lib/config/src/quota.rs View lib/config/src/quota.rs
@@ -7,6 +7,7 @@
77 pub space_create_per_day: i64,
88 pub room_create_per_day: i64,
99 pub issue_create_per_day: i64,
10 pub pull_create_per_day: i64,
1011 pub comment_create_per_day: i64,
1112 pub dm_open_per_day: i64,
1213 pub webhook_create_per_day: i64,
Added lib/pulls/src/handlers/assignees/add_pull_assignee.rs View lib/pulls/src/handlers/assignees/add_pull_assignee.rs
@@ -0,0 +1,48 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::{ApiError, PullError};
6use crate::services::assignees::PullAssigneeDto;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9
10#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
11pub struct AssigneePath {
12 pub space_name: String,
13 pub repo_name: String,
14 pub pull_ref: String,
15 /// 用户名或 UUID。
16 pub user_ref: String,
17}
18
19#[utoipa::path(
20 post,
21 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/assignees/{user_ref}",
22 tag = "pulls",
23 description = "把用户加入合并请求的指派人。",
24 params(AssigneePath),
25 responses(
26 (status = 200, description = "assignees", body = AppResponseData<Vec<PullAssigneeDto>>)
27 )
28)]
29pub async fn add_pull_assignee(
30 State(service): State<PullService>,
31 session: Session,
32 Path(extra): Path<AssigneePath>,
33) -> Result<AppResponseData<Vec<PullAssigneeDto>>, ApiError> {
34 let handle = pull_handle(
35 &service,
36 &session,
37 extra.space_name.clone(),
38 extra.repo_name.clone(),
39 extra.pull_ref,
40 true,
41 )
42 .await?;
43 let caller = handle.caller.ok_or(PullError::Unauthorized)?;
44 let assignees = service
45 .add_assignee(&handle.pull, caller, &extra.user_ref)
46 .await?;
47 Ok(AppResponseData::ok(assignees))
48}
Added lib/pulls/src/handlers/assignees/list_pull_assignees.rs View lib/pulls/src/handlers/assignees/list_pull_assignees.rs
@@ -0,0 +1,37 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::assignees::PullAssigneeDto;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9use crate::share::path::PullPath;
10
11#[utoipa::path(
12 get,
13 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/assignees",
14 tag = "pulls",
15 description = "列出合并请求的指派人。",
16 params(PullPath),
17 responses(
18 (status = 200, description = "assignees", body = AppResponseData<Vec<PullAssigneeDto>>)
19 )
20)]
21pub async fn list_pull_assignees(
22 State(service): State<PullService>,
23 session: Session,
24 path: PullPath,
25) -> Result<AppResponseData<Vec<PullAssigneeDto>>, ApiError> {
26 let handle = pull_handle(
27 &service,
28 &session,
29 path.space_name.clone(),
30 path.repo_name.clone(),
31 path.pull_ref,
32 false,
33 )
34 .await?;
35 let assignees = service.list_assignees(handle.pull.id).await?;
36 Ok(AppResponseData::ok(assignees))
37}
Added lib/pulls/src/handlers/assignees/mod.rs View lib/pulls/src/handlers/assignees/mod.rs
@@ -0,0 +1,3 @@
1pub mod add_pull_assignee;
2pub mod list_pull_assignees;
3pub mod remove_pull_assignee;
Added lib/pulls/src/handlers/assignees/remove_pull_assignee.rs View lib/pulls/src/handlers/assignees/remove_pull_assignee.rs
@@ -0,0 +1,48 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::{ApiError, PullError};
6use crate::services::assignees::PullAssigneeDto;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9
10#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
11pub struct AssigneePath {
12 pub space_name: String,
13 pub repo_name: String,
14 pub pull_ref: String,
15 /// 用户名或 UUID。
16 pub user_ref: String,
17}
18
19#[utoipa::path(
20 delete,
21 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/assignees/{user_ref}",
22 tag = "pulls",
23 description = "移除合并请求的指派人。",
24 params(AssigneePath),
25 responses(
26 (status = 200, description = "assignees", body = AppResponseData<Vec<PullAssigneeDto>>)
27 )
28)]
29pub async fn remove_pull_assignee(
30 State(service): State<PullService>,
31 session: Session,
32 Path(extra): Path<AssigneePath>,
33) -> Result<AppResponseData<Vec<PullAssigneeDto>>, ApiError> {
34 let handle = pull_handle(
35 &service,
36 &session,
37 extra.space_name.clone(),
38 extra.repo_name.clone(),
39 extra.pull_ref,
40 true,
41 )
42 .await?;
43 let caller = handle.caller.ok_or(PullError::Unauthorized)?;
44 let assignees = service
45 .remove_assignee(&handle.pull, caller, &extra.user_ref)
46 .await?;
47 Ok(AppResponseData::ok(assignees))
48}
Added lib/pulls/src/handlers/checks/get_pull_checks.rs View lib/pulls/src/handlers/checks/get_pull_checks.rs
@@ -0,0 +1,39 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::checks::PullChecksDto;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9use crate::share::path::PullPath;
10
11#[utoipa::path(
12 get,
13 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/checks",
14 tag = "pulls",
15 description = "返回 head 提交上的状态检查与流水线运行记录。",
16 params(PullPath),
17 responses(
18 (status = 200, description = "checks", body = AppResponseData<PullChecksDto>)
19 )
20)]
21pub async fn get_pull_checks(
22 State(service): State<PullService>,
23 session: Session,
24 path: PullPath,
25) -> Result<AppResponseData<PullChecksDto>, ApiError> {
26 let handle = pull_handle(
27 &service,
28 &session,
29 path.space_name.clone(),
30 path.repo_name.clone(),
31 path.pull_ref,
32 false,
33 )
34 .await?;
35 let checks = service
36 .pull_checks(path.space_name, path.repo_name, &handle.pull)
37 .await?;
38 Ok(AppResponseData::ok(checks))
39}
Added lib/pulls/src/handlers/checks/get_pull_gate.rs View lib/pulls/src/handlers/checks/get_pull_gate.rs
@@ -0,0 +1,46 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::checks::PullGateDto;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9use crate::share::path::PullPath;
10
11#[utoipa::path(
12 get,
13 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/gate",
14 tag = "pulls",
15 description = "合并门禁预览:冲突状态、审批进度、落后提交与阻塞原因。",
16 params(PullPath),
17 responses(
18 (status = 200, description = "merge gate", body = AppResponseData<PullGateDto>)
19 )
20)]
21pub async fn get_pull_gate(
22 State(service): State<PullService>,
23 session: Session,
24 path: PullPath,
25) -> Result<AppResponseData<PullGateDto>, ApiError> {
26 let handle = pull_handle(
27 &service,
28 &session,
29 path.space_name.clone(),
30 path.repo_name.clone(),
31 path.pull_ref,
32 false,
33 )
34 .await?;
35 let gate = service
36 .pull_gate(
37 path.space_name,
38 path.repo_name,
39 &handle.ctx,
40 &handle.pull,
41 handle.caller,
42 handle.admin,
43 )
44 .await?;
45 Ok(AppResponseData::ok(gate))
46}
Added lib/pulls/src/handlers/checks/mod.rs View lib/pulls/src/handlers/checks/mod.rs
@@ -0,0 +1,2 @@
1pub mod get_pull_checks;
2pub mod get_pull_gate;
Added lib/pulls/src/handlers/comments/create_pull_comment.rs View lib/pulls/src/handlers/comments/create_pull_comment.rs
@@ -0,0 +1,43 @@
1use axum::Json;
2use axum::extract::State;
3use export::rest::AppResponseData;
4use session::Session;
5
6use crate::errors::{ApiError, PullError};
7use crate::services::comments::{CreatePullCommentCmd, PullCommentDto};
8use crate::services::service::PullService;
9use crate::share::lookup::pull_handle;
10use crate::share::path::PullPath;
11
12#[utoipa::path(
13 post,
14 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/comments",
15 tag = "pulls",
16 description = "新增合并请求评论;带 path 时为行内评审评论。",
17 params(PullPath),
18 request_body = CreatePullCommentCmd,
19 responses(
20 (status = 200, description = "created comment", body = AppResponseData<PullCommentDto>)
21 )
22)]
23pub async fn create_pull_comment(
24 State(service): State<PullService>,
25 session: Session,
26 path: PullPath,
27 Json(cmd): Json<CreatePullCommentCmd>,
28) -> Result<AppResponseData<PullCommentDto>, ApiError> {
29 let handle = pull_handle(
30 &service,
31 &session,
32 path.space_name.clone(),
33 path.repo_name.clone(),
34 path.pull_ref,
35 true,
36 )
37 .await?;
38 let caller = handle.caller.ok_or(PullError::Unauthorized)?;
39 let row = service
40 .create_comment(&handle.ctx, &handle.pull, caller, cmd)
41 .await?;
42 Ok(AppResponseData::ok(service.comment_dto_for(&row).await?))
43}
Added lib/pulls/src/handlers/comments/delete_pull_comment.rs View lib/pulls/src/handlers/comments/delete_pull_comment.rs
@@ -0,0 +1,40 @@
1use axum::extract::{Path, State};
2use axum::http::StatusCode;
3use export::rest::{AppResponseData, NoData};
4use session::Session;
5
6use crate::errors::{ApiError, PullError};
7use crate::handlers::comments::update_pull_comment::CommentPath;
8use crate::services::service::PullService;
9use crate::share::lookup::pull_handle;
10
11#[utoipa::path(
12 delete,
13 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/comments/{comment_id}",
14 tag = "pulls",
15 description = "删除自己的评论及其直接回复(管理员可删任意评论)。",
16 params(CommentPath),
17 responses(
18 (status = 200, description = "comment deleted", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_pull_comment(
22 State(service): State<PullService>,
23 session: Session,
24 Path(extra): Path<CommentPath>,
25) -> Result<(StatusCode, AppResponseData<NoData>), ApiError> {
26 let handle = pull_handle(
27 &service,
28 &session,
29 extra.space_name.clone(),
30 extra.repo_name.clone(),
31 extra.pull_ref,
32 true,
33 )
34 .await?;
35 let caller = handle.caller.ok_or(PullError::Unauthorized)?;
36 service
37 .delete_comment(&handle.pull, extra.comment_id, caller, handle.admin)
38 .await?;
39 Ok((StatusCode::OK, AppResponseData::ok(NoData)))
40}
Added lib/pulls/src/handlers/comments/list_pull_comment_tree.rs View lib/pulls/src/handlers/comments/list_pull_comment_tree.rs
@@ -0,0 +1,37 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::comments::PullCommentThreadDto;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9use crate::share::path::PullPath;
10
11#[utoipa::path(
12 get,
13 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/comments/tree",
14 tag = "pulls",
15 description = "以顶层评论嵌套回复的线程树返回评论。",
16 params(PullPath),
17 responses(
18 (status = 200, description = "comment threads", body = AppResponseData<Vec<PullCommentThreadDto>>)
19 )
20)]
21pub async fn list_pull_comment_tree(
22 State(service): State<PullService>,
23 session: Session,
24 path: PullPath,
25) -> Result<AppResponseData<Vec<PullCommentThreadDto>>, ApiError> {
26 let handle = pull_handle(
27 &service,
28 &session,
29 path.space_name.clone(),
30 path.repo_name.clone(),
31 path.pull_ref,
32 false,
33 )
34 .await?;
35 let threads = service.comment_tree(handle.pull.id).await?;
36 Ok(AppResponseData::ok(threads))
37}
Added lib/pulls/src/handlers/comments/list_pull_comments.rs View lib/pulls/src/handlers/comments/list_pull_comments.rs
@@ -0,0 +1,39 @@
1use axum::extract::{Query, State};
2use export::pagination::PageQuery;
3use export::rest::AppResponseData;
4use session::Session;
5
6use crate::errors::ApiError;
7use crate::services::comments::CommentPage;
8use crate::services::service::PullService;
9use crate::share::lookup::pull_handle;
10use crate::share::path::PullPath;
11
12#[utoipa::path(
13 get,
14 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/comments",
15 tag = "pulls",
16 description = "分页读取合并请求的全部评论(含行内评论)。",
17 params(PullPath, PageQuery),
18 responses(
19 (status = 200, description = "comments", body = AppResponseData<CommentPage>)
20 )
21)]
22pub async fn list_pull_comments(
23 State(service): State<PullService>,
24 session: Session,
25 path: PullPath,
26 Query(page): Query<PageQuery>,
27) -> Result<AppResponseData<CommentPage>, ApiError> {
28 let handle = pull_handle(
29 &service,
30 &session,
31 path.space_name.clone(),
32 path.repo_name.clone(),
33 path.pull_ref,
34 false,
35 )
36 .await?;
37 let comments = service.list_comments(handle.pull.id, page).await?;
38 Ok(AppResponseData::ok(comments))
39}
Added lib/pulls/src/handlers/comments/mod.rs View lib/pulls/src/handlers/comments/mod.rs
@@ -0,0 +1,6 @@
1pub mod create_pull_comment;
2pub mod delete_pull_comment;
3pub mod list_pull_comment_tree;
4pub mod list_pull_comments;
5pub mod resolve_pull_comment;
6pub mod update_pull_comment;
Added lib/pulls/src/handlers/comments/resolve_pull_comment.rs View lib/pulls/src/handlers/comments/resolve_pull_comment.rs
@@ -0,0 +1,67 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::{ApiError, PullError};
6use crate::handlers::comments::update_pull_comment::CommentPath;
7use crate::services::comments::PullCommentDto;
8use crate::services::service::PullService;
9use crate::share::lookup::pull_handle;
10
11#[utoipa::path(
12 put,
13 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/comments/{comment_id}/resolve",
14 tag = "pulls",
15 description = "标记行内评审评论为已解决。",
16 params(CommentPath),
17 responses(
18 (status = 200, description = "resolved comment", body = AppResponseData<PullCommentDto>)
19 )
20)]
21pub async fn resolve_pull_comment(
22 State(service): State<PullService>,
23 session: Session,
24 Path(extra): Path<CommentPath>,
25) -> Result<AppResponseData<PullCommentDto>, ApiError> {
26 set_resolved(service, session, extra, true).await
27}
28
29#[utoipa::path(
30 delete,
31 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/comments/{comment_id}/resolve",
32 tag = "pulls",
33 description = "重新打开已解决的行内评审评论。",
34 params(CommentPath),
35 responses(
36 (status = 200, description = "unresolved comment", body = AppResponseData<PullCommentDto>)
37 )
38)]
39pub async fn unresolve_pull_comment(
40 State(service): State<PullService>,
41 session: Session,
42 Path(extra): Path<CommentPath>,
43) -> Result<AppResponseData<PullCommentDto>, ApiError> {
44 set_resolved(service, session, extra, false).await
45}
46
47async fn set_resolved(
48 service: PullService,
49 session: Session,
50 extra: CommentPath,
51 resolved: bool,
52) -> Result<AppResponseData<PullCommentDto>, ApiError> {
53 let handle = pull_handle(
54 &service,
55 &session,
56 extra.space_name.clone(),
57 extra.repo_name.clone(),
58 extra.pull_ref,
59 true,
60 )
61 .await?;
62 let caller = handle.caller.ok_or(PullError::Unauthorized)?;
63 let row = service
64 .set_comment_resolved(&handle.pull, extra.comment_id, caller, resolved)
65 .await?;
66 Ok(AppResponseData::ok(service.comment_dto_for(&row).await?))
67}
Added lib/pulls/src/handlers/comments/update_pull_comment.rs View lib/pulls/src/handlers/comments/update_pull_comment.rs
@@ -0,0 +1,57 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::AppResponseData;
4use session::Session;
5use uuid::Uuid;
6
7use crate::errors::{ApiError, PullError};
8use crate::services::comments::{PullCommentDto, UpdatePullCommentCmd};
9use crate::services::service::PullService;
10use crate::share::lookup::pull_handle;
11
12#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
13pub struct CommentPath {
14 pub space_name: String,
15 pub repo_name: String,
16 pub pull_ref: String,
17 pub comment_id: Uuid,
18}
19
20#[utoipa::path(
21 patch,
22 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/comments/{comment_id}",
23 tag = "pulls",
24 description = "修改自己发表的评论(管理员可改任意评论)。",
25 params(CommentPath),
26 request_body = UpdatePullCommentCmd,
27 responses(
28 (status = 200, description = "updated comment", body = AppResponseData<PullCommentDto>)
29 )
30)]
31pub async fn update_pull_comment(
32 State(service): State<PullService>,
33 session: Session,
34 Path(extra): Path<CommentPath>,
35 Json(cmd): Json<UpdatePullCommentCmd>,
36) -> Result<AppResponseData<PullCommentDto>, ApiError> {
37 let handle = pull_handle(
38 &service,
39 &session,
40 extra.space_name.clone(),
41 extra.repo_name.clone(),
42 extra.pull_ref,
43 true,
44 )
45 .await?;
46 let caller = handle.caller.ok_or(PullError::Unauthorized)?;
47 let row = service
48 .update_comment(
49 &handle.pull,
50 extra.comment_id,
51 caller,
52 handle.admin,
53 cmd.body,
54 )
55 .await?;
56 Ok(AppResponseData::ok(service.comment_dto_for(&row).await?))
57}
Added lib/pulls/src/handlers/labels/attach_pull_label.rs View lib/pulls/src/handlers/labels/attach_pull_label.rs
@@ -0,0 +1,43 @@
1use axum::Json;
2use axum::extract::State;
3use export::rest::AppResponseData;
4use session::Session;
5
6use crate::errors::{ApiError, PullError};
7use crate::services::labels::AttachLabelCmd;
8use crate::services::service::PullService;
9use crate::share::lookup::pull_handle;
10use crate::share::path::PullPath;
11
12#[utoipa::path(
13 post,
14 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/labels",
15 tag = "pulls",
16 description = "为合并请求绑定标签,标签取自仓库所在目录的标签库。",
17 params(PullPath),
18 request_body = AttachLabelCmd,
19 responses(
20 (status = 200, description = "labels", body = AppResponseData<Vec<issues::services::labels::LabelDto>>)
21 )
22)]
23pub async fn attach_pull_label(
24 State(service): State<PullService>,
25 session: Session,
26 path: PullPath,
27 Json(cmd): Json<AttachLabelCmd>,
28) -> Result<AppResponseData<Vec<issues::services::labels::LabelDto>>, ApiError> {
29 let handle = pull_handle(
30 &service,
31 &session,
32 path.space_name.clone(),
33 path.repo_name.clone(),
34 path.pull_ref,
35 true,
36 )
37 .await?;
38 let caller = handle.caller.ok_or(PullError::Unauthorized)?;
39 let labels = service
40 .attach_label(&handle.pull, handle.ctx.model.dir_id, caller, cmd)
41 .await?;
42 Ok(AppResponseData::ok(labels))
43}
Added lib/pulls/src/handlers/labels/detach_pull_label.rs View lib/pulls/src/handlers/labels/detach_pull_label.rs
@@ -0,0 +1,52 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::{ApiError, PullError};
6use crate::services::service::PullService;
7use crate::share::lookup::pull_handle;
8
9#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
10pub struct PullLabelPath {
11 pub space_name: String,
12 pub repo_name: String,
13 pub pull_ref: String,
14 /// 标签 UUID 或名称。
15 pub label_ref: String,
16}
17
18#[utoipa::path(
19 delete,
20 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/labels/{label_ref}",
21 tag = "pulls",
22 description = "解绑合并请求上的一个标签。",
23 params(PullLabelPath),
24 responses(
25 (status = 200, description = "labels", body = AppResponseData<Vec<issues::services::labels::LabelDto>>)
26 )
27)]
28pub async fn detach_pull_label(
29 State(service): State<PullService>,
30 session: Session,
31 Path(extra): Path<PullLabelPath>,
32) -> Result<AppResponseData<Vec<issues::services::labels::LabelDto>>, ApiError> {
33 let handle = pull_handle(
34 &service,
35 &session,
36 extra.space_name.clone(),
37 extra.repo_name.clone(),
38 extra.pull_ref,
39 true,
40 )
41 .await?;
42 let caller = handle.caller.ok_or(PullError::Unauthorized)?;
43 let labels = service
44 .detach_label(
45 &handle.pull,
46 handle.ctx.model.dir_id,
47 caller,
48 &extra.label_ref,
49 )
50 .await?;
51 Ok(AppResponseData::ok(labels))
52}
Added lib/pulls/src/handlers/labels/list_pull_labels.rs View lib/pulls/src/handlers/labels/list_pull_labels.rs
@@ -0,0 +1,36 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::service::PullService;
7use crate::share::lookup::pull_handle;
8use crate::share::path::PullPath;
9
10#[utoipa::path(
11 get,
12 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/labels",
13 tag = "pulls",
14 description = "列出合并请求已绑定的标签。",
15 params(PullPath),
16 responses(
17 (status = 200, description = "labels", body = AppResponseData<Vec<issues::services::labels::LabelDto>>)
18 )
19)]
20pub async fn list_pull_labels(
21 State(service): State<PullService>,
22 session: Session,
23 path: PullPath,
24) -> Result<AppResponseData<Vec<issues::services::labels::LabelDto>>, ApiError> {
25 let handle = pull_handle(
26 &service,
27 &session,
28 path.space_name.clone(),
29 path.repo_name.clone(),
30 path.pull_ref,
31 false,
32 )
33 .await?;
34 let labels = service.list_labels(handle.pull.id).await?;
35 Ok(AppResponseData::ok(labels))
36}
Added lib/pulls/src/handlers/labels/mod.rs View lib/pulls/src/handlers/labels/mod.rs
@@ -0,0 +1,3 @@
1pub mod attach_pull_label;
2pub mod detach_pull_label;
3pub mod list_pull_labels;
Added lib/pulls/src/handlers/pulls/close_pull.rs View lib/pulls/src/handlers/pulls/close_pull.rs
@@ -0,0 +1,40 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::pulls::PullDto;
7use crate::services::service::PullService;
8use crate::share::authz::require_can_manage;
9use crate::share::lookup::pull_handle;
10use crate::share::path::PullPath;
11
12#[utoipa::path(
13 post,
14 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/close",
15 tag = "pulls",
16 description = "关闭合并请求(不删除分支)。",
17 params(PullPath),
18 responses(
19 (status = 200, description = "close pull request", body = AppResponseData<PullDto>)
20 )
21)]
22pub async fn close_pull(
23 State(service): State<PullService>,
24 session: Session,
25 path: PullPath,
26) -> Result<AppResponseData<PullDto>, ApiError> {
27 let handle = pull_handle(
28 &service,
29 &session,
30 path.space_name.clone(),
31 path.repo_name.clone(),
32 path.pull_ref,
33 true,
34 )
35 .await?;
36 let caller = require_can_manage(&handle.pull, handle.caller, handle.admin)?;
37 let updated = service.set_state(&handle.pull, caller, true).await?;
38 let users = service.pull_users(std::slice::from_ref(&updated)).await?;
39 Ok(AppResponseData::ok(PullService::dto(&updated, &users)))
40}
Added lib/pulls/src/handlers/pulls/create_pull.rs View lib/pulls/src/handlers/pulls/create_pull.rs
@@ -0,0 +1,41 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::AppResponseData;
4use git::handler::RepoPath;
5use session::Session;
6
7use crate::errors::ApiError;
8use crate::services::pulls::{CreatePullCmd, PullDto};
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",
15 tag = "pulls",
16 description = "在同一仓库的 head 与 base 分支之间创建合并请求。",
17 params(RepoPath),
18 request_body = CreatePullCmd,
19 responses(
20 (status = 200, description = "created pull request", body = AppResponseData<PullDto>)
21 )
22)]
23pub async fn create_pull(
24 State(service): State<PullService>,
25 session: Session,
26 Path(path): Path<RepoPath>,
27 Json(cmd): Json<CreatePullCmd>,
28) -> Result<AppResponseData<PullDto>, ApiError> {
29 let (ctx, caller) = repo_write(
30 &service,
31 &session,
32 path.space_name.clone(),
33 path.repo_name.clone(),
34 )
35 .await?;
36 let pull = service
37 .create_pull(path.space_name, path.repo_name, &ctx, caller, cmd)
38 .await?;
39 let users = service.pull_users(std::slice::from_ref(&pull)).await?;
40 Ok(AppResponseData::ok(PullService::dto(&pull, &users)))
41}
Added lib/pulls/src/handlers/pulls/delete_pull.rs View lib/pulls/src/handlers/pulls/delete_pull.rs
@@ -0,0 +1,46 @@
1use axum::extract::State;
2use axum::http::StatusCode;
3use export::rest::{AppResponseData, NoData};
4use session::Session;
5
6use crate::errors::ApiError;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9use crate::share::path::PullPath;
10
11#[utoipa::path(
12 delete,
13 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}",
14 tag = "pulls",
15 description = "删除合并请求及其评审、评论、标签、指派人、事件与工单关联,仅仓库管理员可用。",
16 params(PullPath),
17 responses(
18 (status = 200, description = "pull request deleted", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_pull(
22 State(service): State<PullService>,
23 session: Session,
24 path: PullPath,
25) -> Result<(StatusCode, AppResponseData<NoData>), ApiError> {
26 let handle = pull_handle(
27 &service,
28 &session,
29 path.space_name.clone(),
30 path.repo_name.clone(),
31 path.pull_ref,
32 true,
33 )
34 .await?;
35 if !handle.admin {
36 return Err(ApiError::Forbidden(export::rest::AppResponseData::err(
37 403,
38 "repository administrator required",
39 )));
40 }
41 let caller = handle
42 .caller
43 .ok_or(crate::errors::PullError::Unauthorized)?;
44 service.delete_pull(&handle.pull, caller).await?;
45 Ok((StatusCode::OK, AppResponseData::ok(NoData)))
46}
Added lib/pulls/src/handlers/pulls/get_pull.rs View lib/pulls/src/handlers/pulls/get_pull.rs
@@ -0,0 +1,39 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::pulls::PullDto;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9use crate::share::path::PullPath;
10
11#[utoipa::path(
12 get,
13 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}",
14 tag = "pulls",
15 description = "读取单个合并请求,附带实时可合并状态与领先/落后提交数。",
16 params(PullPath),
17 responses(
18 (status = 200, description = "pull request detail", body = AppResponseData<PullDto>)
19 )
20)]
21pub async fn get_pull(
22 State(service): State<PullService>,
23 session: Session,
24 path: PullPath,
25) -> Result<AppResponseData<PullDto>, ApiError> {
26 let handle = pull_handle(
27 &service,
28 &session,
29 path.space_name.clone(),
30 path.repo_name.clone(),
31 path.pull_ref,
32 false,
33 )
34 .await?;
35 let dto = service
36 .pull_detail(path.space_name, path.repo_name, &handle.ctx, &handle.pull)
37 .await?;
38 Ok(AppResponseData::ok(dto))
39}
Added lib/pulls/src/handlers/pulls/get_pull_files.rs View lib/pulls/src/handlers/pulls/get_pull_files.rs
@@ -0,0 +1,39 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use git::service::diffs::ChangedPathDiffDto;
4use session::Session;
5
6use crate::errors::ApiError;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9use crate::share::path::PullPath;
10
11#[utoipa::path(
12 get,
13 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/files",
14 tag = "pulls",
15 description = "按 merge-base 到 head 的三点差异返回变更文件与逐文件补丁。",
16 params(PullPath),
17 responses(
18 (status = 200, description = "changed files", body = AppResponseData<Vec<ChangedPathDiffDto>>)
19 )
20)]
21pub async fn get_pull_files(
22 State(service): State<PullService>,
23 session: Session,
24 path: PullPath,
25) -> Result<AppResponseData<Vec<ChangedPathDiffDto>>, ApiError> {
26 let handle = pull_handle(
27 &service,
28 &session,
29 path.space_name.clone(),
30 path.repo_name.clone(),
31 path.pull_ref,
32 false,
33 )
34 .await?;
35 let files = service
36 .pull_files(&handle.ctx, path.space_name, path.repo_name, &handle.pull)
37 .await?;
38 Ok(AppResponseData::ok(files))
39}
Added lib/pulls/src/handlers/pulls/get_pull_patch.rs View lib/pulls/src/handlers/pulls/get_pull_patch.rs
@@ -0,0 +1,44 @@
1use axum::extract::State;
2use axum::http::StatusCode;
3use axum::http::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
4use session::Session;
5
6use crate::errors::ApiError;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9use crate::share::path::PullPath;
10
11#[utoipa::path(
12 get,
13 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/patch",
14 tag = "pulls",
15 description = "返回合并请求的原始 unified diff(text/plain)。",
16 params(PullPath),
17 responses(
18 (status = 200, description = "raw patch", body = String, content_type = "text/plain")
19 )
20)]
21pub async fn get_pull_patch(
22 State(service): State<PullService>,
23 session: Session,
24 path: PullPath,
25) -> Result<(StatusCode, HeaderMap, String), ApiError> {
26 let handle = pull_handle(
27 &service,
28 &session,
29 path.space_name.clone(),
30 path.repo_name.clone(),
31 path.pull_ref,
32 false,
33 )
34 .await?;
35 let patch = service
36 .pull_patch(&handle.ctx, path.space_name, path.repo_name, &handle.pull)
37 .await?;
38 let mut headers = HeaderMap::new();
39 headers.insert(
40 CONTENT_TYPE,
41 HeaderValue::from_static("text/plain; charset=utf-8"),
42 );
43 Ok((StatusCode::OK, headers, patch))
44}
Added lib/pulls/src/handlers/pulls/list_pull_commits.rs View lib/pulls/src/handlers/pulls/list_pull_commits.rs
@@ -0,0 +1,39 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use git::service::merge::MergeCommitDto;
4use session::Session;
5
6use crate::errors::ApiError;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9use crate::share::path::PullPath;
10
11#[utoipa::path(
12 get,
13 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/commits",
14 tag = "pulls",
15 description = "列出合并请求 base..head 之间的提交(正序,最多 200 条)。",
16 params(PullPath),
17 responses(
18 (status = 200, description = "pull request commits", body = AppResponseData<Vec<MergeCommitDto>>)
19 )
20)]
21pub async fn list_pull_commits(
22 State(service): State<PullService>,
23 session: Session,
24 path: PullPath,
25) -> Result<AppResponseData<Vec<MergeCommitDto>>, ApiError> {
26 let handle = pull_handle(
27 &service,
28 &session,
29 path.space_name.clone(),
30 path.repo_name.clone(),
31 path.pull_ref,
32 false,
33 )
34 .await?;
35 let commits = service
36 .pull_commits(path.space_name, path.repo_name, &handle.pull)
37 .await?;
38 Ok(AppResponseData::ok(commits))
39}
Added lib/pulls/src/handlers/pulls/list_pull_events.rs View lib/pulls/src/handlers/pulls/list_pull_events.rs
@@ -0,0 +1,39 @@
1use axum::extract::{Query, State};
2use export::pagination::PageQuery;
3use export::rest::AppResponseData;
4use session::Session;
5
6use crate::errors::ApiError;
7use crate::services::events::EventPage;
8use crate::services::service::PullService;
9use crate::share::lookup::pull_handle;
10use crate::share::path::PullPath;
11
12#[utoipa::path(
13 get,
14 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/events",
15 tag = "pulls",
16 description = "分页读取合并请求时间线(创建、评审、合并、推送更新等)。",
17 params(PullPath, PageQuery),
18 responses(
19 (status = 200, description = "pull request timeline", body = AppResponseData<EventPage>)
20 )
21)]
22pub async fn list_pull_events(
23 State(service): State<PullService>,
24 session: Session,
25 path: PullPath,
26 Query(page): Query<PageQuery>,
27) -> Result<AppResponseData<EventPage>, ApiError> {
28 let handle = pull_handle(
29 &service,
30 &session,
31 path.space_name.clone(),
32 path.repo_name.clone(),
33 path.pull_ref,
34 false,
35 )
36 .await?;
37 let events = service.list_events(handle.pull.id, page).await?;
38 Ok(AppResponseData::ok(events))
39}
Added lib/pulls/src/handlers/pulls/list_pulls.rs View lib/pulls/src/handlers/pulls/list_pulls.rs
@@ -0,0 +1,37 @@
1use axum::extract::{Path, Query, State};
2use export::rest::AppResponseData;
3use git::handler::RepoPath;
4use session::Session;
5
6use crate::errors::ApiError;
7use crate::services::pulls::ListPullQuery;
8use crate::services::pulls::PullPage;
9use crate::services::service::PullService;
10use crate::share::lookup::repo_read;
11
12#[utoipa::path(
13 get,
14 path = "/repos/{space_name}/{repo_name}/pulls",
15 tag = "pulls",
16 description = "按状态、作者、分支、草稿标记或关键字分页查询合并请求。",
17 params(RepoPath, ListPullQuery),
18 responses(
19 (status = 200, description = "list pull requests", body = AppResponseData<PullPage>)
20 )
21)]
22pub async fn list_pulls(
23 State(service): State<PullService>,
24 session: Session,
25 Path(path): Path<RepoPath>,
26 Query(query): Query<ListPullQuery>,
27) -> Result<AppResponseData<PullPage>, ApiError> {
28 let (ctx, _) = repo_read(
29 &service,
30 &session,
31 path.space_name.clone(),
32 path.repo_name.clone(),
33 )
34 .await?;
35 let page = service.list_pulls(ctx.model.id, query).await?;
36 Ok(AppResponseData::ok(page))
37}
Added lib/pulls/src/handlers/pulls/lock_pull.rs View lib/pulls/src/handlers/pulls/lock_pull.rs
@@ -0,0 +1,70 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::pulls::PullDto;
7use crate::services::service::PullService;
8use crate::share::authz::require_can_manage;
9use crate::share::lookup::pull_handle;
10use crate::share::path::PullPath;
11
12#[utoipa::path(
13 put,
14 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/lock",
15 tag = "pulls",
16 description = "锁定合并请求,锁定后仅作者与管理员可再改动。",
17 params(PullPath),
18 responses(
19 (status = 200, description = "locked pull request", body = AppResponseData<PullDto>)
20 )
21)]
22pub async fn lock_pull(
23 State(service): State<PullService>,
24 session: Session,
25 path: PullPath,
26) -> Result<AppResponseData<PullDto>, ApiError> {
27 let handle = pull_handle(
28 &service,
29 &session,
30 path.space_name.clone(),
31 path.repo_name.clone(),
32 path.pull_ref,
33 true,
34 )
35 .await?;
36 let caller = require_can_manage(&handle.pull, handle.caller, handle.admin)?;
37 let updated = service.set_lock(&handle.pull, caller, true).await?;
38 let users = service.pull_users(std::slice::from_ref(&updated)).await?;
39 Ok(AppResponseData::ok(PullService::dto(&updated, &users)))
40}
41
42#[utoipa::path(
43 delete,
44 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/lock",
45 tag = "pulls",
46 description = "解除合并请求锁定。",
47 params(PullPath),
48 responses(
49 (status = 200, description = "unlocked pull request", body = AppResponseData<PullDto>)
50 )
51)]
52pub async fn unlock_pull(
53 State(service): State<PullService>,
54 session: Session,
55 path: PullPath,
56) -> Result<AppResponseData<PullDto>, ApiError> {
57 let handle = pull_handle(
58 &service,
59 &session,
60 path.space_name.clone(),
61 path.repo_name.clone(),
62 path.pull_ref,
63 true,
64 )
65 .await?;
66 let caller = require_can_manage(&handle.pull, handle.caller, handle.admin)?;
67 let updated = service.set_lock(&handle.pull, caller, false).await?;
68 let users = service.pull_users(std::slice::from_ref(&updated)).await?;
69 Ok(AppResponseData::ok(PullService::dto(&updated, &users)))
70}
Added lib/pulls/src/handlers/pulls/merge_pull.rs View lib/pulls/src/handlers/pulls/merge_pull.rs
@@ -0,0 +1,58 @@
1use axum::Json;
2use axum::extract::State;
3use export::rest::AppResponseData;
4use session::Session;
5
6use crate::errors::ApiError;
7use crate::services::merge::{MergePullCmd, MergePullDto};
8use crate::services::service::PullService;
9use crate::share::lookup::pull_handle;
10use crate::share::path::PullPath;
11
12#[utoipa::path(
13 post,
14 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/merge",
15 tag = "pulls",
16 description = "按合并策略与分支保护规则合并请求;被门禁拦截时返回 409 与原因。",
17 params(PullPath),
18 request_body = MergePullCmd,
19 responses(
20 (status = 200, description = "merge result", body = AppResponseData<MergePullDto>)
21 )
22)]
23pub async fn merge_pull(
24 State(service): State<PullService>,
25 session: Session,
26 path: PullPath,
27 Json(cmd): Json<MergePullCmd>,
28) -> Result<AppResponseData<MergePullDto>, ApiError> {
29 let handle = pull_handle(
30 &service,
31 &session,
32 path.space_name.clone(),
33 path.repo_name.clone(),
34 path.pull_ref,
35 true,
36 )
37 .await?;
38 let caller = handle
39 .caller
40 .ok_or(crate::errors::PullError::Unauthorized)?;
41 let (merged, result, summary) = service
42 .merge_pull(
43 path.space_name.clone(),
44 path.repo_name.clone(),
45 &handle.ctx,
46 &handle.pull,
47 caller,
48 handle.admin,
49 cmd,
50 )
51 .await?;
52 let users = service.pull_users(std::slice::from_ref(&merged)).await?;
53 Ok(AppResponseData::ok(MergePullDto {
54 pull: PullService::dto(&merged, &users),
55 result,
56 summary,
57 }))
58}
Added lib/pulls/src/handlers/pulls/mod.rs View lib/pulls/src/handlers/pulls/mod.rs
@@ -0,0 +1,15 @@
1pub mod close_pull;
2pub mod create_pull;
3pub mod delete_pull;
4pub mod get_pull;
5pub mod get_pull_files;
6pub mod get_pull_patch;
7pub mod list_pull_commits;
8pub mod list_pull_events;
9pub mod list_pulls;
10pub mod lock_pull;
11pub mod merge_pull;
12pub mod refresh_pull_summary;
13pub mod reopen_pull;
14pub mod update_branch_pull;
15pub mod update_pull;
Added lib/pulls/src/handlers/pulls/refresh_pull_summary.rs View lib/pulls/src/handlers/pulls/refresh_pull_summary.rs
@@ -0,0 +1,39 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::changes::PullSummaryDto;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9use crate::share::path::PullPath;
10
11#[utoipa::path(
12 post,
13 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/summary",
14 tag = "pulls",
15 description = "重新对齐 head/base 快照并重算增删行数、文件数与冲突状态。",
16 params(PullPath),
17 responses(
18 (status = 200, description = "recomputed summary", body = AppResponseData<PullSummaryDto>)
19 )
20)]
21pub async fn refresh_pull_summary(
22 State(service): State<PullService>,
23 session: Session,
24 path: PullPath,
25) -> Result<AppResponseData<PullSummaryDto>, ApiError> {
26 let handle = pull_handle(
27 &service,
28 &session,
29 path.space_name.clone(),
30 path.repo_name.clone(),
31 path.pull_ref,
32 true,
33 )
34 .await?;
35 let (_, summary) = service
36 .refresh_pull(path.space_name, path.repo_name, &handle.pull, handle.caller)
37 .await?;
38 Ok(AppResponseData::ok(summary))
39}
Added lib/pulls/src/handlers/pulls/reopen_pull.rs View lib/pulls/src/handlers/pulls/reopen_pull.rs
@@ -0,0 +1,40 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::pulls::PullDto;
7use crate::services::service::PullService;
8use crate::share::authz::require_can_manage;
9use crate::share::lookup::pull_handle;
10use crate::share::path::PullPath;
11
12#[utoipa::path(
13 post,
14 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/reopen",
15 tag = "pulls",
16 description = "重新打开已关闭的合并请求。",
17 params(PullPath),
18 responses(
19 (status = 200, description = "reopen pull request", body = AppResponseData<PullDto>)
20 )
21)]
22pub async fn reopen_pull(
23 State(service): State<PullService>,
24 session: Session,
25 path: PullPath,
26) -> Result<AppResponseData<PullDto>, ApiError> {
27 let handle = pull_handle(
28 &service,
29 &session,
30 path.space_name.clone(),
31 path.repo_name.clone(),
32 path.pull_ref,
33 true,
34 )
35 .await?;
36 let caller = require_can_manage(&handle.pull, handle.caller, handle.admin)?;
37 let updated = service.set_state(&handle.pull, caller, false).await?;
38 let users = service.pull_users(std::slice::from_ref(&updated)).await?;
39 Ok(AppResponseData::ok(PullService::dto(&updated, &users)))
40}
Added lib/pulls/src/handlers/pulls/update_branch_pull.rs View lib/pulls/src/handlers/pulls/update_branch_pull.rs
@@ -0,0 +1,60 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use git::service::merge::MergeResultDto;
4use session::Session;
5
6use crate::errors::ApiError;
7use crate::services::pulls::PullDto;
8use crate::services::service::PullService;
9use crate::share::lookup::pull_handle;
10use crate::share::path::PullPath;
11
12#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
13pub struct UpdateBranchDto {
14 pub pull: PullDto,
15 pub result: MergeResultDto,
16}
17
18#[utoipa::path(
19 post,
20 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/update-branch",
21 tag = "pulls",
22 description = "把 base 分支最新提交并入 head 分支,用于解除落后阻塞。",
23 params(PullPath),
24 responses(
25 (status = 200, description = "branch updated", body = AppResponseData<UpdateBranchDto>)
26 )
27)]
28pub async fn update_branch_pull(
29 State(service): State<PullService>,
30 session: Session,
31 path: PullPath,
32) -> Result<AppResponseData<UpdateBranchDto>, ApiError> {
33 let handle = pull_handle(
34 &service,
35 &session,
36 path.space_name.clone(),
37 path.repo_name.clone(),
38 path.pull_ref,
39 true,
40 )
41 .await?;
42 let caller = handle
43 .caller
44 .ok_or(crate::errors::PullError::Unauthorized)?;
45 let (updated, result) = service
46 .update_pull_branch(
47 path.space_name,
48 path.repo_name,
49 &handle.ctx,
50 &handle.pull,
51 caller,
52 handle.admin,
53 )
54 .await?;
55 let users = service.pull_users(std::slice::from_ref(&updated)).await?;
56 Ok(AppResponseData::ok(UpdateBranchDto {
57 pull: PullService::dto(&updated, &users),
58 result,
59 }))
60}
Added lib/pulls/src/handlers/pulls/update_pull.rs View lib/pulls/src/handlers/pulls/update_pull.rs
@@ -0,0 +1,53 @@
1use axum::Json;
2use axum::extract::State;
3use export::rest::AppResponseData;
4use session::Session;
5
6use crate::errors::ApiError;
7use crate::services::pulls::{PullDto, UpdatePullCmd};
8use crate::services::service::PullService;
9use crate::share::authz::require_can_manage;
10use crate::share::lookup::pull_handle;
11use crate::share::path::PullPath;
12
13#[utoipa::path(
14 patch,
15 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}",
16 tag = "pulls",
17 description = "修改标题、正文、base 分支、草稿标记、自动合并或关联工单。",
18 params(PullPath),
19 request_body = UpdatePullCmd,
20 responses(
21 (status = 200, description = "updated pull request", body = AppResponseData<PullDto>)
22 )
23)]
24pub async fn update_pull(
25 State(service): State<PullService>,
26 session: Session,
27 path: PullPath,
28 Json(cmd): Json<UpdatePullCmd>,
29) -> Result<AppResponseData<PullDto>, ApiError> {
30 let handle = pull_handle(
31 &service,
32 &session,
33 path.space_name.clone(),
34 path.repo_name.clone(),
35 path.pull_ref,
36 true,
37 )
38 .await?;
39 let caller = require_can_manage(&handle.pull, handle.caller, handle.admin)?;
40 let updated = service
41 .update_pull(
42 path.space_name,
43 path.repo_name,
44 &handle.ctx,
45 &handle.pull,
46 caller,
47 handle.admin,
48 cmd,
49 )
50 .await?;
51 let users = service.pull_users(std::slice::from_ref(&updated)).await?;
52 Ok(AppResponseData::ok(PullService::dto(&updated, &users)))
53}
Added lib/pulls/src/handlers/relations/link_issue_pull.rs View lib/pulls/src/handlers/relations/link_issue_pull.rs
@@ -0,0 +1,43 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::AppResponseData;
4use issues::services::issues::IssuePath;
5use session::Session;
6
7use crate::errors::ApiError;
8use crate::handlers::relations::resolve_issue;
9use crate::services::pulls::{LinkIssueCmd, PullDto};
10use crate::services::service::PullService;
11use crate::share::lookup::repo_write;
12
13#[utoipa::path(
14 post,
15 path = "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/pulls",
16 tag = "pulls",
17 description = "把一个合并请求关联到工单,需要对目标仓库有写权限。",
18 params(IssuePath),
19 request_body = LinkIssueCmd,
20 responses(
21 (status = 200, description = "linked pull request", body = AppResponseData<PullDto>)
22 )
23)]
24pub async fn link_issue_pull(
25 State(service): State<PullService>,
26 session: Session,
27 Path(path): Path<IssuePath>,
28 Json(cmd): Json<LinkIssueCmd>,
29) -> Result<AppResponseData<PullDto>, ApiError> {
30 let (_, _, issue_id) = resolve_issue(&service, &session, &path).await?;
31 let (ctx, caller) = repo_write(
32 &service,
33 &session,
34 cmd.space_name.clone(),
35 cmd.repo_name.clone(),
36 )
37 .await?;
38 let pull = service
39 .link_issue(&ctx, issue_id, caller, &cmd.pull_ref)
40 .await?;
41 let users = service.pull_users(std::slice::from_ref(&pull)).await?;
42 Ok(AppResponseData::ok(PullService::dto(&pull, &users)))
43}
Added lib/pulls/src/handlers/relations/list_issue_pulls.rs View lib/pulls/src/handlers/relations/list_issue_pulls.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use issues::services::issues::IssuePath;
4use session::Session;
5
6use crate::errors::ApiError;
7use crate::handlers::relations::resolve_issue;
8use crate::services::pulls::PullDto;
9use crate::services::service::PullService;
10
11#[utoipa::path(
12 get,
13 path = "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/pulls",
14 tag = "pulls",
15 description = "列出与该工单关联的合并请求,私有仓库仅在调用者可见时返回。",
16 params(IssuePath),
17 responses(
18 (status = 200, description = "linked pull requests", body = AppResponseData<Vec<PullDto>>)
19 )
20)]
21pub async fn list_issue_pulls(
22 State(service): State<PullService>,
23 session: Session,
24 Path(path): Path<IssuePath>,
25) -> Result<AppResponseData<Vec<PullDto>>, ApiError> {
26 let (space_id, _, issue_id) = resolve_issue(&service, &session, &path).await?;
27 let pulls = service.list_linked_pulls(issue_id, space_id).await?;
28 Ok(AppResponseData::ok(pulls))
29}
Added lib/pulls/src/handlers/relations/mod.rs View lib/pulls/src/handlers/relations/mod.rs
@@ -0,0 +1,31 @@
1pub mod link_issue_pull;
2pub mod list_issue_pulls;
3pub mod unlink_issue_pull;
4
5use issues::services::issues::IssuePath;
6
7use crate::errors::{ApiError, PullError};
8use crate::services::service::PullService;
9
10/// 校验空间成员与看板/工单可读性,返回工单与其所属空间。
11pub(crate) async fn resolve_issue(
12 service: &PullService,
13 session: &session::Session,
14 path: &IssuePath,
15) -> Result<(uuid::Uuid, uuid::Uuid, uuid::Uuid), ApiError> {
16 let caller = PullService::uid(session).map_err(ApiError::from)?;
17 let issues = issues::services::service::IssueService::new(service.state.clone());
18 let space = issues::share::lookup::lookup_space_by_name(&issues, &path.space_name)
19 .await
20 .map_err(PullError::from)?;
21 issues::share::lookup::require_member(&issues, &space, caller)
22 .await
23 .map_err(PullError::from)?;
24 let board = issues::share::lookup::lookup_board(&issues, &space, path.board_id, caller)
25 .await
26 .map_err(PullError::from)?;
27 let issue = issues::share::lookup::lookup_issue_by_ref(&issues, &board, &path.issue_id)
28 .await
29 .map_err(PullError::from)?;
30 Ok((space.id, board.id, issue.id))
31}
Added lib/pulls/src/handlers/relations/unlink_issue_pull.rs View lib/pulls/src/handlers/relations/unlink_issue_pull.rs
@@ -0,0 +1,72 @@
1use axum::extract::{Path, State};
2use axum::http::StatusCode;
3use export::rest::{AppResponseData, NoData};
4use issues::services::issues::IssuePath;
5use sea_orm::EntityTrait;
6use session::Session;
7use uuid::Uuid;
8
9use crate::errors::{ApiError, PullError};
10use crate::handlers::relations::resolve_issue;
11use crate::services::service::PullService;
12use crate::share::lookup::repo_write;
13
14#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
15pub struct IssuePullPath {
16 pub space_name: String,
17 pub board_id: Uuid,
18 pub issue_id: String,
19 pub relation_id: Uuid,
20}
21
22#[utoipa::path(
23 delete,
24 path = "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/pulls/{relation_id}",
25 tag = "pulls",
26 description = "解除工单与合并请求的关联。",
27 params(IssuePullPath),
28 responses(
29 (status = 200, description = "relation removed", body = AppResponseData<NoData>)
30 )
31)]
32pub async fn unlink_issue_pull(
33 State(service): State<PullService>,
34 session: Session,
35 Path(path): Path<IssuePullPath>,
36) -> Result<(StatusCode, AppResponseData<NoData>), ApiError> {
37 let issue_path = IssuePath {
38 space_name: path.space_name.clone(),
39 board_id: path.board_id,
40 issue_id: path.issue_id.clone(),
41 };
42 let (_, _, issue_id) = resolve_issue(&service, &session, &issue_path).await?;
43 let relation = models::issue::issue_relation_pull::Entity::find_by_id(path.relation_id)
44 .one(service.db_read())
45 .await
46 .map_err(PullError::from)?
47 .ok_or_else(|| PullError::NotFound)?;
48 if relation.issue_id != issue_id {
49 return Err(ApiError::from(PullError::NotFound));
50 }
51 let pull = models::pull_request::pull_requests::Entity::find_by_id(relation.pull_request_id)
52 .one(service.db_read())
53 .await
54 .map_err(PullError::from)?
55 .ok_or_else(|| PullError::NotFound)?;
56 let repo = models::repo::repos::Entity::find_by_id(pull.repo_id)
57 .one(service.db_read())
58 .await
59 .map_err(PullError::from)?
60 .ok_or_else(|| PullError::NotFound)?;
61 let (ctx, _caller) = repo_write(
62 &service,
63 &session,
64 path.space_name.clone(),
65 repo.lower_name.clone(),
66 )
67 .await?;
68 service
69 .unlink_issue(&ctx, issue_id, path.relation_id)
70 .await?;
71 Ok((StatusCode::OK, AppResponseData::ok(NoData)))
72}
Added lib/pulls/src/handlers/reviewers/delete_requested_reviewer.rs View lib/pulls/src/handlers/reviewers/delete_requested_reviewer.rs
@@ -0,0 +1,48 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::{ApiError, PullError};
7use crate::services::reviewers::PullReviewerDto;
8use crate::services::service::PullService;
9use crate::share::lookup::pull_handle;
10
11#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
12pub struct ReviewerPath {
13 pub space_name: String,
14 pub repo_name: String,
15 pub pull_ref: String,
16 pub user_id: Uuid,
17}
18
19#[utoipa::path(
20 delete,
21 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/requested-reviewers/{user_id}",
22 tag = "reviews",
23 description = "取消某个用户的审阅请求。",
24 params(ReviewerPath),
25 responses(
26 (status = 200, description = "reviewers", body = AppResponseData<Vec<PullReviewerDto>>)
27 )
28)]
29pub async fn delete_requested_reviewer(
30 State(service): State<PullService>,
31 session: Session,
32 Path(extra): Path<ReviewerPath>,
33) -> Result<AppResponseData<Vec<PullReviewerDto>>, ApiError> {
34 let handle = pull_handle(
35 &service,
36 &session,
37 extra.space_name.clone(),
38 extra.repo_name.clone(),
39 extra.pull_ref,
40 true,
41 )
42 .await?;
43 let caller = handle.caller.ok_or(PullError::Unauthorized)?;
44 let reviewers = service
45 .cancel_reviewer_request(&handle.pull, caller, extra.user_id)
46 .await?;
47 Ok(AppResponseData::ok(reviewers))
48}
Added lib/pulls/src/handlers/reviewers/list_requested_reviewers.rs View lib/pulls/src/handlers/reviewers/list_requested_reviewers.rs
@@ -0,0 +1,37 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::reviewers::PullReviewerDto;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9use crate::share::path::PullPath;
10
11#[utoipa::path(
12 get,
13 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/requested-reviewers",
14 tag = "reviews",
15 description = "列出合并请求的审阅请求与各自状态。",
16 params(PullPath),
17 responses(
18 (status = 200, description = "reviewers", body = AppResponseData<Vec<PullReviewerDto>>)
19 )
20)]
21pub async fn list_requested_reviewers(
22 State(service): State<PullService>,
23 session: Session,
24 path: PullPath,
25) -> Result<AppResponseData<Vec<PullReviewerDto>>, ApiError> {
26 let handle = pull_handle(
27 &service,
28 &session,
29 path.space_name.clone(),
30 path.repo_name.clone(),
31 path.pull_ref,
32 false,
33 )
34 .await?;
35 let reviewers = service.list_reviewers(handle.pull.id).await?;
36 Ok(AppResponseData::ok(reviewers))
37}
Added lib/pulls/src/handlers/reviewers/mod.rs View lib/pulls/src/handlers/reviewers/mod.rs
@@ -0,0 +1,3 @@
1pub mod delete_requested_reviewer;
2pub mod list_requested_reviewers;
3pub mod request_reviewers;
Added lib/pulls/src/handlers/reviewers/request_reviewers.rs View lib/pulls/src/handlers/reviewers/request_reviewers.rs
@@ -0,0 +1,41 @@
1use axum::Json;
2use axum::extract::State;
3use export::rest::AppResponseData;
4use session::Session;
5
6use crate::errors::{ApiError, PullError};
7use crate::services::reviewers::{PullReviewerDto, RequestReviewersCmd};
8use crate::services::service::PullService;
9use crate::share::lookup::pull_handle;
10use crate::share::path::PullPath;
11
12#[utoipa::path(
13 post,
14 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/requested-reviewers",
15 tag = "reviews",
16 description = "请求指定用户评审;required=true 时计入分支保护的必需审批人数。",
17 params(PullPath),
18 request_body = RequestReviewersCmd,
19 responses(
20 (status = 200, description = "reviewers", body = AppResponseData<Vec<PullReviewerDto>>)
21 )
22)]
23pub async fn request_reviewers(
24 State(service): State<PullService>,
25 session: Session,
26 path: PullPath,
27 Json(cmd): Json<RequestReviewersCmd>,
28) -> Result<AppResponseData<Vec<PullReviewerDto>>, ApiError> {
29 let handle = pull_handle(
30 &service,
31 &session,
32 path.space_name.clone(),
33 path.repo_name.clone(),
34 path.pull_ref,
35 true,
36 )
37 .await?;
38 let caller = handle.caller.ok_or(PullError::Unauthorized)?;
39 let reviewers = service.request_reviewers(&handle.pull, caller, cmd).await?;
40 Ok(AppResponseData::ok(reviewers))
41}
Added lib/pulls/src/handlers/reviews/dismiss_review.rs View lib/pulls/src/handlers/reviews/dismiss_review.rs
@@ -0,0 +1,48 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::{ApiError, PullError};
6use crate::services::reviews::PullReviewDto;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9
10#[utoipa::path(
11 put,
12 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/reviews/{review_id}/dismiss",
13 tag = "reviews",
14 description = "撤销一条评审结论,仅仓库管理员可用。",
15 params(crate::handlers::reviews::publish_review::ReviewPath),
16 responses(
17 (status = 200, description = "dismissed review", body = AppResponseData<PullReviewDto>)
18 )
19)]
20pub async fn dismiss_review(
21 State(service): State<PullService>,
22 session: Session,
23 Path(extra): Path<crate::handlers::reviews::publish_review::ReviewPath>,
24) -> Result<AppResponseData<PullReviewDto>, ApiError> {
25 let handle = pull_handle(
26 &service,
27 &session,
28 extra.space_name.clone(),
29 extra.repo_name.clone(),
30 extra.pull_ref,
31 true,
32 )
33 .await?;
34 if !handle.admin {
35 return Err(ApiError::Forbidden(export::rest::AppResponseData::err(
36 403,
37 "repository administrator required",
38 )));
39 }
40 let caller = handle.caller.ok_or(PullError::Unauthorized)?;
41 let review = service
42 .dismiss_review(&handle.pull, extra.review_id, caller)
43 .await?;
44 let dto = service
45 .get_review(handle.pull.id, review.id, handle.caller)
46 .await?;
47 Ok(AppResponseData::ok(dto))
48}
Added lib/pulls/src/handlers/reviews/get_review.rs View lib/pulls/src/handlers/reviews/get_review.rs
@@ -0,0 +1,38 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::reviews::PullReviewDto;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9
10#[utoipa::path(
11 get,
12 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/reviews/{review_id}",
13 tag = "reviews",
14 description = "读取单条评审记录。",
15 params(crate::handlers::reviews::publish_review::ReviewPath),
16 responses(
17 (status = 200, description = "review", body = AppResponseData<PullReviewDto>)
18 )
19)]
20pub async fn get_review(
21 State(service): State<PullService>,
22 session: Session,
23 Path(extra): Path<crate::handlers::reviews::publish_review::ReviewPath>,
24) -> Result<AppResponseData<PullReviewDto>, ApiError> {
25 let handle = pull_handle(
26 &service,
27 &session,
28 extra.space_name.clone(),
29 extra.repo_name.clone(),
30 extra.pull_ref,
31 false,
32 )
33 .await?;
34 let dto = service
35 .get_review(handle.pull.id, extra.review_id, handle.caller)
36 .await?;
37 Ok(AppResponseData::ok(dto))
38}
Added lib/pulls/src/handlers/reviews/list_reviews.rs View lib/pulls/src/handlers/reviews/list_reviews.rs
@@ -0,0 +1,37 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::reviews::PullReviewDto;
7use crate::services::service::PullService;
8use crate::share::lookup::pull_handle;
9use crate::share::path::PullPath;
10
11#[utoipa::path(
12 get,
13 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/reviews",
14 tag = "reviews",
15 description = "列出合并请求的评审记录(他人的草稿评审不可见)。",
16 params(PullPath),
17 responses(
18 (status = 200, description = "reviews", body = AppResponseData<Vec<PullReviewDto>>)
19 )
20)]
21pub async fn list_reviews(
22 State(service): State<PullService>,
23 session: Session,
24 path: PullPath,
25) -> Result<AppResponseData<Vec<PullReviewDto>>, ApiError> {
26 let handle = pull_handle(
27 &service,
28 &session,
29 path.space_name.clone(),
30 path.repo_name.clone(),
31 path.pull_ref,
32 false,
33 )
34 .await?;
35 let reviews = service.list_reviews(handle.pull.id, handle.caller).await?;
36 Ok(AppResponseData::ok(reviews))
37}
Added lib/pulls/src/handlers/reviews/mod.rs View lib/pulls/src/handlers/reviews/mod.rs
@@ -0,0 +1,5 @@
1pub mod dismiss_review;
2pub mod get_review;
3pub mod list_reviews;
4pub mod publish_review;
5pub mod submit_review;
Added lib/pulls/src/handlers/reviews/publish_review.rs View lib/pulls/src/handlers/reviews/publish_review.rs
@@ -0,0 +1,54 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::AppResponseData;
4use session::Session;
5use uuid::Uuid;
6
7use crate::errors::{ApiError, PullError};
8use crate::services::reviews::{PullReviewDto, SubmitReviewCmd};
9use crate::services::service::PullService;
10use crate::share::lookup::pull_handle;
11
12#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
13pub struct ReviewPath {
14 pub space_name: String,
15 pub repo_name: String,
16 pub pull_ref: String,
17 pub review_id: Uuid,
18}
19
20#[utoipa::path(
21 post,
22 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/reviews/{review_id}/submit",
23 tag = "reviews",
24 description = "发布自己的待提交评审草稿,草稿行内评论同时公开并计入审批门禁。",
25 params(ReviewPath),
26 request_body = SubmitReviewCmd,
27 responses(
28 (status = 200, description = "published review", body = AppResponseData<PullReviewDto>)
29 )
30)]
31pub async fn publish_review(
32 State(service): State<PullService>,
33 session: Session,
34 Path(extra): Path<ReviewPath>,
35 Json(cmd): Json<SubmitReviewCmd>,
36) -> Result<AppResponseData<PullReviewDto>, ApiError> {
37 let handle = pull_handle(
38 &service,
39 &session,
40 extra.space_name.clone(),
41 extra.repo_name.clone(),
42 extra.pull_ref,
43 true,
44 )
45 .await?;
46 let caller = handle.caller.ok_or(PullError::Unauthorized)?;
47 let review = service
48 .publish_pending_review(&handle.pull, extra.review_id, caller, handle.admin, cmd)
49 .await?;
50 let dto = service
51 .get_review(handle.pull.id, review.id, handle.caller)
52 .await?;
53 Ok(AppResponseData::ok(dto))
54}
Added lib/pulls/src/handlers/reviews/submit_review.rs View lib/pulls/src/handlers/reviews/submit_review.rs
@@ -0,0 +1,46 @@
1use axum::Json;
2use axum::extract::State;
3use export::rest::AppResponseData;
4use session::Session;
5
6use crate::errors::{ApiError, PullError};
7use crate::services::reviews::{PullReviewDto, SubmitReviewCmd};
8use crate::services::service::PullService;
9use crate::share::lookup::pull_handle;
10use crate::share::path::PullPath;
11
12#[utoipa::path(
13 post,
14 path = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}/reviews",
15 tag = "reviews",
16 description = "提交评审:approve / request_changes / comment,或以 pending 存为草稿并附带行内评论。",
17 params(PullPath),
18 request_body = SubmitReviewCmd,
19 responses(
20 (status = 200, description = "submitted review", body = AppResponseData<PullReviewDto>)
21 )
22)]
23pub async fn submit_review(
24 State(service): State<PullService>,
25 session: Session,
26 path: PullPath,
27 Json(cmd): Json<SubmitReviewCmd>,
28) -> Result<AppResponseData<PullReviewDto>, ApiError> {
29 let handle = pull_handle(
30 &service,
31 &session,
32 path.space_name.clone(),
33 path.repo_name.clone(),
34 path.pull_ref,
35 true,
36 )
37 .await?;
38 let caller = handle.caller.ok_or(PullError::Unauthorized)?;
39 let review = service
40 .submit_review(&handle.ctx, &handle.pull, caller, handle.admin, cmd)
41 .await?;
42 let dto = service
43 .get_review(handle.pull.id, review.id, handle.caller)
44 .await?;
45 Ok(AppResponseData::ok(dto))
46}
Added lib/pulls/src/handlers/inbox.rs View lib/pulls/src/handlers/inbox.rs
@@ -0,0 +1,44 @@
1use axum::extract::{Path, Query, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::inbox::{InboxPage, InboxQuery};
7use crate::services::service::PullService;
8
9#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
10pub struct InboxPath {
11 /// created | review-requested | reviewed
12 pub scope: String,
13}
14
15#[utoipa::path(
16 get,
17 path = "/pulls/@me/{scope}",
18 tag = "pulls",
19 description = "跨仓库的个人合并请求收件箱:我创建的、待我评审的、我评审过的。",
20 params(InboxPath, InboxQuery),
21 responses(
22 (status = 200, description = "inbox", body = AppResponseData<InboxPage>)
23 )
24)]
25pub async fn list_my_pulls(
26 State(service): State<PullService>,
27 session: Session,
28 Path(path): Path<InboxPath>,
29 Query(query): Query<InboxQuery>,
30) -> Result<AppResponseData<InboxPage>, ApiError> {
31 let caller = PullService::uid(&session).map_err(ApiError::from)?;
32 let scope = match path.scope.as_str() {
33 "created" => crate::services::inbox::InboxScope::Created,
34 "review-requested" => crate::services::inbox::InboxScope::ReviewRequested,
35 "reviewed" => crate::services::inbox::InboxScope::Reviewed,
36 other => {
37 return Err(ApiError::from(crate::errors::PullError::BadRequest(
38 format!("unknown inbox scope `{other}`"),
39 )));
40 }
41 };
42 let page = service.list_inbox(caller, scope, query).await?;
43 Ok(AppResponseData::ok(page))
44}
Added lib/pulls/src/handlers/mod.rs View lib/pulls/src/handlers/mod.rs
@@ -0,0 +1,9 @@
1pub mod assignees;
2pub mod checks;
3pub mod comments;
4pub mod inbox;
5pub mod labels;
6pub mod pulls;
7pub mod relations;
8pub mod reviewers;
9pub mod reviews;
Added lib/pulls/src/services/approvals.rs View lib/pulls/src/services/approvals.rs
@@ -0,0 +1,289 @@
1use std::collections::HashMap;
2
3use git::context::facade::GitContext;
4use git::share::policy::{branch_policy_whitelists, match_branch_policies};
5use globset::{Glob, GlobSet, GlobSetBuilder};
6use models::pull_request::{pull_request_review, pull_request_reviewer, pull_requests};
7use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11use crate::errors::PullError;
12use crate::services::changes::PullSummaryDto;
13use crate::services::service::PullService;
14
15const SIGNED_CHECK_CAP: usize = 50;
16
17#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
18pub struct MergeGateDto {
19 pub allowed: bool,
20 pub reasons: Vec<String>,
21 pub protected: bool,
22 pub required_approvals: i32,
23 pub approvals: i32,
24 pub change_requests: i32,
25 pub outdated: bool,
26 pub bypassed: bool,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ReviewVerdict {
31 Approved,
32 ChangesRequested,
33 Commented,
34}
35
36fn build_glob_set(raw: &str) -> Result<Option<GlobSet>, PullError> {
37 let mut builder = GlobSetBuilder::new();
38 let mut count = 0usize;
39 for line in raw.split([',', '\n']) {
40 let trimmed = line.trim();
41 if trimmed.is_empty() {
42 continue;
43 }
44 let pattern = if trimmed.contains('/') {
45 trimmed.to_string()
46 } else {
47 format!("**/{trimmed}")
48 };
49 builder.add(
50 Glob::new(&pattern)
51 .map_err(|e| PullError::Internal(format!("invalid glob `{pattern}`: {e}")))?,
52 );
53 count += 1;
54 }
55 if count == 0 {
56 return Ok(None);
57 }
58 builder
59 .build()
60 .map(Some)
61 .map_err(|e| PullError::Internal(format!("invalid glob set: {e}")))
62}
63
64impl PullService {
65 /// 每位审阅者在 PR 上的最新一次非草稿评审结论。
66 pub async fn latest_verdicts(
67 &self,
68 pull_id: Uuid,
69 head_sha: &str,
70 dismiss_stale: bool,
71 ) -> Result<HashMap<Uuid, ReviewVerdict>, PullError> {
72 let mut select = pull_request_review::Entity::find()
73 .filter(pull_request_review::Column::PullRequestId.eq(pull_id))
74 .filter(
75 pull_request_review::Column::State
76 .ne(models::pull_request::pull_request_review::ReviewState::Pending),
77 )
78 .order_by_asc(pull_request_review::Column::CreatedAt);
79 if dismiss_stale {
80 select = select.filter(pull_request_review::Column::CommitSha.eq(head_sha));
81 }
82 let rows = select.all(self.db_read()).await?;
83 let mut out: HashMap<Uuid, ReviewVerdict> = HashMap::new();
84 for row in rows {
85 let verdict = match row.state {
86 models::pull_request::pull_request_review::ReviewState::Approved => {
87 ReviewVerdict::Approved
88 }
89 models::pull_request::pull_request_review::ReviewState::ChangesRequested => {
90 ReviewVerdict::ChangesRequested
91 }
92 _ => ReviewVerdict::Commented,
93 };
94 out.insert(row.user_id, verdict);
95 }
96 Ok(out)
97 }
98
99 /// 待处理(未满足)的审阅请求人数。
100 pub async fn pending_requests(&self, pull_id: Uuid) -> Result<i32, PullError> {
101 Ok(pull_request_reviewer::Entity::find()
102 .filter(pull_request_reviewer::Column::PullRequestId.eq(pull_id))
103 .filter(pull_request_reviewer::Column::Required.eq(true))
104 .filter(
105 pull_request_reviewer::Column::State
106 .eq(models::pull_request::pull_request_reviewer::ReviewerState::Requested),
107 )
108 .count(self.db_read())
109 .await? as i32)
110 }
111
112 #[allow(clippy::too_many_arguments)]
113 pub async fn merge_gate(
114 &self,
115 space_name: String,
116 repo_name: String,
117 ctx: &GitContext,
118 pull: &pull_requests::Model,
119 caller: Uuid,
120 admin: bool,
121 summary: &PullSummaryDto,
122 ) -> Result<MergeGateDto, PullError> {
123 let policies =
124 match_branch_policies(self.db_read(), ctx.model.id, &pull.base_branch).await?;
125 let outdated = summary.commits_behind > 0;
126 if policies.is_empty() {
127 return Ok(MergeGateDto {
128 allowed: true,
129 reasons: Vec::new(),
130 protected: false,
131 required_approvals: 0,
132 approvals: 0,
133 change_requests: 0,
134 outdated,
135 bypassed: false,
136 });
137 }
138 let whitelists = branch_policy_whitelists(self.db_read(), &policies).await?;
139 let required = policies
140 .iter()
141 .map(|policy| policy.required_approvals)
142 .max()
143 .unwrap_or(0);
144 let dismiss_stale = policies.iter().any(|policy| policy.dismiss_stale_approvals);
145 let merge_whitelist_on = policies.iter().any(|policy| policy.enable_merge_whitelist);
146 let bypassed = admin
147 || (merge_whitelist_on
148 && whitelists
149 .merge_allowed
150 .iter()
151 .any(|entry| *entry == caller));
152 let mut reasons: Vec<String> = Vec::new();
153
154 let verdicts = if required > 0
155 || policies
156 .iter()
157 .any(|policy| policy.block_on_change_requested_reviews)
158 {
159 self.latest_verdicts(pull.id, &summary.head_sha, dismiss_stale)
160 .await?
161 } else {
162 HashMap::new()
163 };
164 let approvals = verdicts
165 .values()
166 .filter(|verdict| **verdict == ReviewVerdict::Approved)
167 .count() as i32;
168 let change_requests = verdicts
169 .values()
170 .filter(|verdict| **verdict == ReviewVerdict::ChangesRequested)
171 .count() as i32;
172
173 if !bypassed {
174 if required > 0 && approvals < required {
175 reasons.push(format!(
176 "{required} approval(s) required, {approvals} granted on the current head commit"
177 ));
178 }
179 if change_requests > 0
180 && policies
181 .iter()
182 .any(|policy| policy.block_on_rejected_reviews)
183 {
184 reasons.push(format!("change requested by {change_requests} reviewer(s)"));
185 }
186 if change_requests > 0
187 && policies
188 .iter()
189 .any(|policy| policy.block_on_change_requested_reviews)
190 {
191 reasons.push(format!(
192 "unaddressed change request from {change_requests} reviewer(s)"
193 ));
194 }
195 if outdated
196 && policies
197 .iter()
198 .any(|policy| policy.block_on_outdated_branch)
199 {
200 reasons.push(
201 "base branch has new commits; sync the branch before merging".to_string(),
202 );
203 }
204 if policies.iter().any(|policy| policy.require_signed_commits) {
205 let commits = self
206 .git
207 .merge_commits(
208 space_name.clone(),
209 repo_name.clone(),
210 &format!("refs/heads/{}", pull.base_branch),
211 &summary.head_sha,
212 )
213 .await?;
214 if commits.len() > SIGNED_CHECK_CAP {
215 return Err(PullError::Conflict(format!(
216 "pull request carries more than {SIGNED_CHECK_CAP} commits; the commit signature policy cannot be verified"
217 )));
218 }
219 for commit in &commits {
220 let verify = self
221 .git
222 .signature_verify_commit(
223 space_name.clone(),
224 repo_name.clone(),
225 commit.sha.clone(),
226 )
227 .await?;
228 if !verify.signed || !verify.valid {
229 reasons.push(format!("commit {} is not validly signed", commit.sha));
230 break;
231 }
232 }
233 }
234
235 let protected_raw: String = policies
236 .iter()
237 .map(|policy| policy.protected_file_patterns.as_str())
238 .collect::<Vec<_>>()
239 .join(",");
240 let unprotected_raw: String = policies
241 .iter()
242 .map(|policy| policy.unprotected_file_patterns.as_str())
243 .collect::<Vec<_>>()
244 .join(",");
245 if !protected_raw.trim().is_empty() || !unprotected_raw.trim().is_empty() {
246 let from = summary
247 .merge_base
248 .clone()
249 .unwrap_or_else(|| summary.base_sha.clone());
250 let changed = self
251 .git
252 .diff_files_changed_between(
253 space_name.clone(),
254 repo_name.clone(),
255 from,
256 summary.head_sha.clone(),
257 )
258 .await?;
259 let protected = build_glob_set(&protected_raw)?;
260 let unprotected = build_glob_set(&unprotected_raw)?;
261 let blocked: Vec<String> = changed
262 .into_iter()
263 .filter(|path| {
264 let hit = protected.as_ref().is_some_and(|set| set.is_match(path));
265 let exempt = unprotected.as_ref().is_some_and(|set| set.is_match(path));
266 hit && !exempt
267 })
268 .collect();
269 if !blocked.is_empty() {
270 reasons.push(format!(
271 "protected file(s) changed without whitelist permission: {}",
272 blocked.join(", ")
273 ));
274 }
275 }
276 }
277
278 Ok(MergeGateDto {
279 allowed: reasons.is_empty(),
280 reasons,
281 protected: true,
282 required_approvals: required,
283 approvals,
284 change_requests,
285 outdated,
286 bypassed,
287 })
288 }
289}
Added lib/pulls/src/services/assignees.rs View lib/pulls/src/services/assignees.rs
@@ -0,0 +1,110 @@
1use models::pull_request::{pull_request_assignee, pull_requests};
2use sea_orm::{
3 ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter, QueryOrder,
4};
5use serde::{Deserialize, Serialize};
6use space::services::spaces::UserDto;
7use time::OffsetDateTime;
8use utoipa::ToSchema;
9use uuid::Uuid;
10
11use crate::errors::PullError;
12use crate::services::events::{EVENT_ASSIGNED, EVENT_UNASSIGNED};
13use crate::services::reviews::unique_ids;
14use crate::services::service::PullService;
15use crate::share::authz::require_unlocked;
16
17#[derive(Debug, Clone, Deserialize, ToSchema)]
18pub struct AssignCmd {
19 /// 用户名或 UUID。
20 pub user: String,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
24pub struct PullAssigneeDto {
25 pub id: Uuid,
26 pub user: Option<UserDto>,
27 pub created_at: OffsetDateTime,
28}
29
30impl PullService {
31 pub async fn list_assignees(&self, pull_id: Uuid) -> Result<Vec<PullAssigneeDto>, PullError> {
32 let rows = pull_request_assignee::Entity::find()
33 .filter(pull_request_assignee::Column::PullRequestId.eq(pull_id))
34 .order_by_asc(pull_request_assignee::Column::CreatedAt)
35 .all(self.db_read())
36 .await?;
37 let users = self
38 .user_briefs(&unique_ids(rows.iter().map(|row| row.user_id)))
39 .await?;
40 Ok(rows
41 .iter()
42 .map(|row| PullAssigneeDto {
43 id: row.id,
44 user: users.get(&row.user_id).cloned(),
45 created_at: row.created_at,
46 })
47 .collect())
48 }
49
50 pub async fn add_assignee(
51 &self,
52 pull: &pull_requests::Model,
53 caller: Uuid,
54 reference: &str,
55 ) -> Result<Vec<PullAssigneeDto>, PullError> {
56 require_unlocked(pull)?;
57 let uid = self.resolve_user_id(reference).await?;
58 let existing = pull_request_assignee::Entity::find()
59 .filter(pull_request_assignee::Column::PullRequestId.eq(pull.id))
60 .filter(pull_request_assignee::Column::UserId.eq(uid))
61 .one(self.db_read())
62 .await?;
63 if existing.is_none() {
64 pull_request_assignee::ActiveModel {
65 id: Set(Uuid::now_v7()),
66 pull_request_id: Set(pull.id),
67 user_id: Set(uid),
68 created_at: Set(OffsetDateTime::now_utc()),
69 ..Default::default()
70 }
71 .insert(self.db_write())
72 .await?;
73 self.record_event(
74 pull,
75 Some(caller),
76 EVENT_ASSIGNED,
77 None,
78 Some(uid.to_string()),
79 )
80 .await?;
81 }
82 self.list_assignees(pull.id).await
83 }
84
85 pub async fn remove_assignee(
86 &self,
87 pull: &pull_requests::Model,
88 caller: Uuid,
89 reference: &str,
90 ) -> Result<Vec<PullAssigneeDto>, PullError> {
91 require_unlocked(pull)?;
92 let uid = self.resolve_user_id(reference).await?;
93 let deleted = pull_request_assignee::Entity::delete_many()
94 .filter(pull_request_assignee::Column::PullRequestId.eq(pull.id))
95 .filter(pull_request_assignee::Column::UserId.eq(uid))
96 .exec(self.db_write())
97 .await?;
98 if deleted.rows_affected > 0 {
99 self.record_event(
100 pull,
101 Some(caller),
102 EVENT_UNASSIGNED,
103 None,
104 Some(uid.to_string()),
105 )
106 .await?;
107 }
108 self.list_assignees(pull.id).await
109 }
110}
Added lib/pulls/src/services/changes.rs View lib/pulls/src/services/changes.rs
@@ -0,0 +1,216 @@
1use git::context::facade::GitContext;
2use git::service::diffs::{ChangedPathDiffDto, DiffOptionsDto};
3use git::service::merge::{MergeCommitDto, MergePlanDto};
4use models::pull_request::pull_requests;
5use sea_orm::{ActiveModelTrait, ActiveValue::Set, IntoActiveModel};
6use serde::{Deserialize, Serialize};
7use time::OffsetDateTime;
8use uuid::Uuid;
9
10use crate::errors::PullError;
11use crate::services::events::EVENT_HEAD_UPDATED;
12use crate::services::events::write_event;
13use crate::services::service::PullService;
14
15#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
16pub struct PullSummaryDto {
17 pub head_sha: String,
18 pub base_sha: String,
19 pub merge_base: Option<String>,
20 pub mergeable: bool,
21 pub mergeable_state: String,
22 pub conflicts: Vec<String>,
23 pub commits_ahead: i64,
24 pub commits_behind: i64,
25 pub additions: i32,
26 pub deletions: i32,
27 pub files_changed: i32,
28}
29
30impl PullService {
31 pub(crate) fn revisions(pull: &pull_requests::Model) -> (String, String) {
32 (
33 format!("refs/heads/{}", pull.base_branch),
34 pull.head_sha.clone(),
35 )
36 }
37
38 pub(crate) async fn plan_for(
39 &self,
40 ctx: &GitContext,
41 pull: &pull_requests::Model,
42 ) -> Result<MergePlanDto, PullError> {
43 let (base_ref, head) = Self::revisions(pull);
44 Ok(self.git.merge_plan(ctx, &base_ref, &head).await?)
45 }
46
47 pub async fn pull_commits(
48 &self,
49 space_name: String,
50 repo_name: String,
51 pull: &pull_requests::Model,
52 ) -> Result<Vec<MergeCommitDto>, PullError> {
53 let (base_ref, head) = Self::revisions(pull);
54 Ok(self
55 .git
56 .merge_commits(space_name, repo_name, &base_ref, &head)
57 .await?)
58 }
59
60 pub async fn pull_files(
61 &self,
62 ctx: &GitContext,
63 space_name: String,
64 repo_name: String,
65 pull: &pull_requests::Model,
66 ) -> Result<Vec<ChangedPathDiffDto>, PullError> {
67 let plan = self.plan_for(ctx, pull).await?;
68 let from = plan
69 .merge_base
70 .clone()
71 .unwrap_or_else(|| plan.base_sha.clone());
72 Ok(self
73 .git
74 .diff_files(
75 space_name,
76 repo_name,
77 from,
78 plan.head_sha.clone(),
79 DiffOptionsDto::default(),
80 )
81 .await?)
82 }
83
84 pub async fn pull_patch(
85 &self,
86 ctx: &GitContext,
87 space_name: String,
88 repo_name: String,
89 pull: &pull_requests::Model,
90 ) -> Result<String, PullError> {
91 let plan = self.plan_for(ctx, pull).await?;
92 let from = plan
93 .merge_base
94 .clone()
95 .unwrap_or_else(|| plan.base_sha.clone());
96 Ok(self
97 .git
98 .diff_raw(space_name, repo_name, from, plan.head_sha.clone())
99 .await?)
100 }
101
102 /// 重新对齐 head/base 快照并重算行数与冲突状态。
103 pub async fn refresh_pull(
104 &self,
105 space_name: String,
106 repo_name: String,
107 pull: &pull_requests::Model,
108 actor: Option<Uuid>,
109 ) -> Result<(pull_requests::Model, PullSummaryDto), PullError> {
110 let head_ref = format!("refs/heads/{}", pull.head_branch);
111 let head_now = match self
112 .git
113 .rev_parse(space_name.clone(), repo_name.clone(), head_ref)
114 .await
115 {
116 Ok(sha) => Some(sha),
117 Err(git::share::errors::GitError::NotFound) => None,
118 Err(other) => return Err(PullError::from(other)),
119 };
120 let moved = head_now.as_deref().is_some_and(|sha| sha != pull.head_sha);
121 let head = head_now.clone().unwrap_or_else(|| pull.head_sha.clone());
122 let plan = self
123 .git
124 .merge_plan(
125 &self
126 .git
127 .context(space_name.clone(), repo_name.clone())
128 .await?,
129 &format!("refs/heads/{}", pull.base_branch),
130 &head,
131 )
132 .await?;
133 let stats = self
134 .diff_stats_for(&space_name, &repo_name, &plan, &plan.head_sha)
135 .await?;
136
137 let mut model = pull.clone().into_active_model();
138 model.head_sha = Set(head.clone());
139 model.base_sha = Set(plan.base_sha.clone());
140 model.additions = Set(stats.0);
141 model.deletions = Set(stats.1);
142 model.files_changed = Set(stats.2);
143 if moved {
144 model.updated_at = Set(Some(OffsetDateTime::now_utc()));
145 }
146 let updated = model.update(self.db_write()).await?;
147 if moved {
148 write_event(
149 self.db_write(),
150 &updated,
151 actor,
152 EVENT_HEAD_UPDATED,
153 Some(head.clone()),
154 None,
155 )
156 .await?;
157 }
158 Ok((
159 updated,
160 PullSummaryDto {
161 head_sha: plan.head_sha.clone(),
162 base_sha: plan.base_sha.clone(),
163 merge_base: plan.merge_base.clone(),
164 mergeable: plan.clean,
165 mergeable_state: if plan.clean {
166 "clean".to_string()
167 } else {
168 "dirty".to_string()
169 },
170 conflicts: plan.conflicts.clone(),
171 commits_ahead: plan.head_only,
172 commits_behind: plan.base_only,
173 additions: stats.0,
174 deletions: stats.1,
175 files_changed: stats.2,
176 },
177 ))
178 }
179}
180
181impl PullService {
182 /// 详情视图:附带实时可合并状态与领先/落后提交数。
183 pub async fn pull_detail(
184 &self,
185 space_name: String,
186 repo_name: String,
187 ctx: &GitContext,
188 pull: &pull_requests::Model,
189 ) -> Result<crate::services::pulls::PullDto, PullError> {
190 let users = self.pull_users(std::slice::from_ref(pull)).await?;
191 let mut dto = PullService::dto(pull, &users);
192 if pull.has_merged || pull.closed_at.is_some() {
193 return Ok(dto);
194 }
195 match self.plan_for(ctx, pull).await {
196 Ok(plan) => {
197 dto.mergeable = Some(plan.clean);
198 dto.mergeable_state = Some(if plan.clean {
199 "clean".to_string()
200 } else {
201 "dirty".to_string()
202 });
203 dto.conflicts = Some(plan.conflicts.clone());
204 dto.commits_ahead = Some(plan.head_only);
205 dto.commits_behind = Some(plan.base_only);
206 }
207 Err(error) => {
208 tracing::debug!(error = %error, "pull merge plan unavailable");
209 dto.mergeable = None;
210 dto.mergeable_state = Some("unstable".to_string());
211 }
212 }
213 let _ = (space_name, repo_name);
214 Ok(dto)
215 }
216}
Added lib/pulls/src/services/checks.rs View lib/pulls/src/services/checks.rs
@@ -0,0 +1,96 @@
1use git::context::facade::GitContext;
2use git::service::commits::CommitStatusDto;
3use git::service::pipelines::PipelineRunDto;
4use models::pull_request::pull_requests;
5use models::repo::pipeline_run;
6use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
7use serde::Serialize;
8use utoipa::ToSchema;
9use uuid::Uuid;
10
11use crate::errors::PullError;
12use crate::services::approvals::MergeGateDto;
13use crate::services::service::PullService;
14
15#[derive(Debug, Clone, Serialize, ToSchema)]
16pub struct PullChecksDto {
17 pub head_sha: String,
18 pub statuses: Vec<CommitStatusDto>,
19 pub runs: Vec<PipelineRunDto>,
20}
21
22#[derive(Debug, Clone, Serialize, ToSchema)]
23pub struct PullGateDto {
24 pub head_sha: String,
25 pub mergeable: bool,
26 pub conflicts: Vec<String>,
27 pub gate: MergeGateDto,
28 pub pending_review_requests: i32,
29}
30
31impl PullService {
32 pub async fn pull_checks(
33 &self,
34 space_name: String,
35 repo_name: String,
36 pull: &pull_requests::Model,
37 ) -> Result<PullChecksDto, PullError> {
38 let statuses = self
39 .git
40 .list_commit_statuses(space_name.clone(), repo_name.clone(), pull.head_sha.clone())
41 .await?;
42 let rows = pipeline_run::Entity::find()
43 .filter(pipeline_run::Column::RepoId.eq(pull.repo_id))
44 .filter(pipeline_run::Column::CommitSha.eq(pull.head_sha.clone()))
45 .order_by_desc(pipeline_run::Column::CreatedAt)
46 .all(self.db_read())
47 .await?;
48 Ok(PullChecksDto {
49 head_sha: pull.head_sha.clone(),
50 statuses,
51 runs: rows.into_iter().map(PipelineRunDto::from).collect(),
52 })
53 }
54
55 /// 合并门禁预览:不改动任何数据。
56 pub async fn pull_gate(
57 &self,
58 space_name: String,
59 repo_name: String,
60 ctx: &GitContext,
61 pull: &pull_requests::Model,
62 caller: Option<Uuid>,
63 admin: bool,
64 ) -> Result<PullGateDto, PullError> {
65 let plan = self.plan_for(ctx, pull).await?;
66 let summary = crate::services::changes::PullSummaryDto {
67 head_sha: plan.head_sha.clone(),
68 base_sha: plan.base_sha.clone(),
69 merge_base: plan.merge_base.clone(),
70 mergeable: plan.clean,
71 mergeable_state: if plan.clean {
72 "clean".into()
73 } else {
74 "dirty".into()
75 },
76 conflicts: plan.conflicts.clone(),
77 commits_ahead: plan.head_only,
78 commits_behind: plan.base_only,
79 additions: pull.additions,
80 deletions: pull.deletions,
81 files_changed: pull.files_changed,
82 };
83 let caller = caller.ok_or(PullError::Unauthorized)?;
84 let gate = self
85 .merge_gate(space_name, repo_name, ctx, pull, caller, admin, &summary)
86 .await?;
87 let pending = self.pending_requests(pull.id).await?;
88 Ok(PullGateDto {
89 head_sha: plan.head_sha,
90 mergeable: plan.clean,
91 conflicts: plan.conflicts,
92 gate,
93 pending_review_requests: pending,
94 })
95 }
96}
Added lib/pulls/src/services/comments.rs View lib/pulls/src/services/comments.rs
@@ -0,0 +1,364 @@
1use export::pagination::{Page, PageQuery};
2use git::context::facade::GitContext;
3use models::pull_request::{pull_request_comment, pull_requests};
4use sea_orm::{
5 ActiveModelTrait, ActiveValue::Set, ColumnTrait, Condition, EntityTrait, PaginatorTrait,
6 QueryFilter, QueryOrder, QuerySelect,
7};
8use serde::{Deserialize, Serialize};
9use space::services::spaces::UserDto;
10use std::collections::HashMap;
11use time::OffsetDateTime;
12use utoipa::ToSchema;
13use uuid::Uuid;
14
15use crate::errors::PullError;
16use crate::services::events::EVENT_COMMENTED;
17use crate::services::reviews::unique_ids;
18use crate::services::service::PullService;
19use crate::share::authz::{require_open, require_unlocked};
20
21pub const MAX_COMMENT_LEN: usize = 65_536;
22
23#[derive(Debug, Clone, Deserialize, ToSchema)]
24pub struct CreatePullCommentCmd {
25 pub body: String,
26 /// 行内评论需要 path;缺省则为普通讨论评论。
27 #[serde(default)]
28 pub path: Option<String>,
29 #[serde(default)]
30 pub line: Option<i32>,
31 /// left | right
32 #[serde(default)]
33 pub side: Option<String>,
34 #[serde(default)]
35 pub commit_sha: Option<String>,
36 #[serde(default)]
37 pub review_id: Option<Uuid>,
38 #[serde(default)]
39 pub parent_comment_id: Option<Uuid>,
40}
41
42#[derive(Debug, Clone, Deserialize, ToSchema)]
43pub struct UpdatePullCommentCmd {
44 pub body: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
48pub struct PullCommentDto {
49 pub id: Uuid,
50 pub pull_request_id: Uuid,
51 pub author: Option<UserDto>,
52 pub body: String,
53 pub path: Option<String>,
54 pub line: Option<i32>,
55 pub side: Option<String>,
56 pub commit_sha: Option<String>,
57 pub review_id: Option<Uuid>,
58 pub parent_comment_id: Option<Uuid>,
59 pub resolved: bool,
60 pub resolved_by: Option<Uuid>,
61 pub created_at: OffsetDateTime,
62 pub updated_at: Option<OffsetDateTime>,
63}
64
65/// 线程视图:顶层评论 + 其下全部回复(无递归,便于文档生成)。
66#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
67pub struct PullCommentThreadDto {
68 pub root: PullCommentDto,
69 pub replies: Vec<PullCommentDto>,
70}
71
72pub type CommentPage = Page<PullCommentDto>;
73
74impl PullService {
75 pub async fn create_comment(
76 &self,
77 ctx: &GitContext,
78 pull: &pull_requests::Model,
79 caller: Uuid,
80 cmd: CreatePullCommentCmd,
81 ) -> Result<pull_request_comment::Model, PullError> {
82 require_open(pull)?;
83 require_unlocked(pull)?;
84 if cmd.body.trim().is_empty() || cmd.body.chars().count() > MAX_COMMENT_LEN {
85 return Err(PullError::BadRequest("invalid comment body".to_string()));
86 }
87 if !self
88 .state
89 .redis
90 .quota_allow(
91 &format!("quota:comment_create:{caller}"),
92 self.config().quota.comment_create_per_day,
93 86_400,
94 )
95 .await
96 {
97 return Err(PullError::RateLimited(
98 "comment creation quota exceeded, retry tomorrow".to_string(),
99 ));
100 }
101 let inline = cmd.path.as_deref().is_some_and(|p| !p.trim().is_empty());
102 let commit_sha = match (inline, cmd.commit_sha.clone()) {
103 (false, _) => None,
104 (true, Some(sha)) => Some(sha),
105 (true, None) => Some(pull.head_sha.clone()),
106 };
107 if !inline && cmd.line.is_some() {
108 return Err(PullError::BadRequest("line requires a path".to_string()));
109 }
110 if let Some(line) = cmd.line {
111 if line <= 0 {
112 return Err(PullError::BadRequest("line must be positive".to_string()));
113 }
114 }
115 if let Some(side) = cmd.side.as_deref() {
116 if side != "left" && side != "right" {
117 return Err(PullError::BadRequest(
118 "side must be left or right".to_string(),
119 ));
120 }
121 }
122 if let Some(parent) = cmd.parent_comment_id {
123 if !pull_request_comment::Entity::find_by_id(parent)
124 .filter(pull_request_comment::Column::PullRequestId.eq(pull.id))
125 .one(self.db_read())
126 .await?
127 .is_some()
128 {
129 return Err(PullError::BadRequest(
130 "parent comment not found".to_string(),
131 ));
132 }
133 }
134 if let Some(review_id) = cmd.review_id {
135 if !models::pull_request::pull_request_review::Entity::find_by_id(review_id)
136 .filter(
137 models::pull_request::pull_request_review::Column::PullRequestId.eq(pull.id),
138 )
139 .one(self.db_read())
140 .await?
141 .is_some()
142 {
143 return Err(PullError::BadRequest("review not found".to_string()));
144 }
145 }
146 let row = pull_request_comment::ActiveModel {
147 id: Set(Uuid::now_v7()),
148 pull_request_id: Set(pull.id),
149 user_id: Set(caller),
150 review_id: Set(cmd.review_id),
151 parent_comment_id: Set(cmd.parent_comment_id),
152 commit_sha: Set(commit_sha.clone()),
153 original_commit_sha: Set(commit_sha.clone()),
154 path: Set(if inline { cmd.path.clone() } else { None }),
155 side: Set(if inline {
156 Some(cmd.side.clone().unwrap_or_else(|| "right".to_string()))
157 } else {
158 None
159 }),
160 line: Set(if inline { cmd.line } else { None }),
161 original_line: Set(if inline { cmd.line } else { None }),
162 body: Set(cmd.body.clone()),
163 resolved_at: Set(None),
164 resolved_by: Set(None),
165 created_at: Set(OffsetDateTime::now_utc()),
166 updated_at: Set(None),
167 ..Default::default()
168 }
169 .insert(self.db_write())
170 .await?;
171 self.record_event(pull, Some(caller), EVENT_COMMENTED, commit_sha, None)
172 .await?;
173 let _ = ctx;
174 Ok(row)
175 }
176
177 pub async fn list_comments(
178 &self,
179 pull_id: Uuid,
180 page: PageQuery,
181 ) -> Result<CommentPage, PullError> {
182 let (limit, offset) = page.page();
183 let total = pull_request_comment::Entity::find()
184 .filter(pull_request_comment::Column::PullRequestId.eq(pull_id))
185 .count(self.db_read())
186 .await? as i64;
187 let rows = pull_request_comment::Entity::find()
188 .filter(pull_request_comment::Column::PullRequestId.eq(pull_id))
189 .order_by_asc(pull_request_comment::Column::CreatedAt)
190 .limit(limit)
191 .offset(offset)
192 .all(self.db_read())
193 .await?;
194 let users = self.comment_users(&rows).await?;
195 Ok(Page {
196 items: rows
197 .iter()
198 .map(|row| Self::comment_dto(row, &users))
199 .collect(),
200 total,
201 limit,
202 offset,
203 })
204 }
205
206 /// 顶层评论 + 嵌套回复的线程树。
207 pub async fn comment_tree(
208 &self,
209 pull_id: Uuid,
210 ) -> Result<Vec<PullCommentThreadDto>, PullError> {
211 let rows = pull_request_comment::Entity::find()
212 .filter(pull_request_comment::Column::PullRequestId.eq(pull_id))
213 .order_by_asc(pull_request_comment::Column::CreatedAt)
214 .all(self.db_read())
215 .await?;
216 let users = self.comment_users(&rows).await?;
217 let mut by_parent: HashMap<Uuid, Vec<&pull_request_comment::Model>> = HashMap::new();
218 let mut roots: Vec<&pull_request_comment::Model> = Vec::new();
219 for row in &rows {
220 match row.parent_comment_id {
221 Some(parent) => by_parent.entry(parent).or_default().push(row),
222 None => roots.push(row),
223 }
224 }
225 Ok(roots
226 .into_iter()
227 .map(|row| PullCommentThreadDto {
228 root: Self::comment_dto(row, &users),
229 replies: by_parent
230 .get(&row.id)
231 .map(|children| {
232 children
233 .iter()
234 .map(|child| Self::comment_dto(child, &users))
235 .collect()
236 })
237 .unwrap_or_default(),
238 })
239 .collect())
240 }
241
242 pub async fn update_comment(
243 &self,
244 pull: &pull_requests::Model,
245 comment_id: Uuid,
246 caller: Uuid,
247 admin: bool,
248 body: String,
249 ) -> Result<pull_request_comment::Model, PullError> {
250 let row = self.find_comment(pull.id, comment_id).await?;
251 if row.user_id != caller && !admin {
252 return Err(PullError::Forbidden);
253 }
254 if body.trim().is_empty() || body.chars().count() > MAX_COMMENT_LEN {
255 return Err(PullError::BadRequest("invalid comment body".to_string()));
256 }
257 let mut model: pull_request_comment::ActiveModel = row.into();
258 model.body = Set(body);
259 model.updated_at = Set(Some(OffsetDateTime::now_utc()));
260 let updated = model.update(self.db_write()).await?;
261 let _ = pull;
262 Ok(updated)
263 }
264
265 pub async fn delete_comment(
266 &self,
267 pull: &pull_requests::Model,
268 comment_id: Uuid,
269 caller: Uuid,
270 admin: bool,
271 ) -> Result<(), PullError> {
272 let row = self.find_comment(pull.id, comment_id).await?;
273 if row.user_id != caller && !admin {
274 return Err(PullError::Forbidden);
275 }
276 pull_request_comment::Entity::delete_many()
277 .filter(
278 Condition::any()
279 .add(pull_request_comment::Column::Id.eq(comment_id))
280 .add(pull_request_comment::Column::ParentCommentId.eq(comment_id)),
281 )
282 .exec(self.db_write())
283 .await?;
284 Ok(())
285 }
286
287 pub async fn set_comment_resolved(
288 &self,
289 pull: &pull_requests::Model,
290 comment_id: Uuid,
291 caller: Uuid,
292 resolved: bool,
293 ) -> Result<pull_request_comment::Model, PullError> {
294 let row = self.find_comment(pull.id, comment_id).await?;
295 if row.path.is_none() {
296 return Err(PullError::BadRequest(
297 "only inline review comments can be resolved".to_string(),
298 ));
299 }
300 let mut model: pull_request_comment::ActiveModel = row.into();
301 model.resolved_at = Set(if resolved {
302 Some(OffsetDateTime::now_utc())
303 } else {
304 None
305 });
306 model.resolved_by = Set(if resolved { Some(caller) } else { None });
307 let updated = model.update(self.db_write()).await?;
308 let _ = pull;
309 Ok(updated)
310 }
311
312 pub(crate) async fn find_comment(
313 &self,
314 pull_id: Uuid,
315 comment_id: Uuid,
316 ) -> Result<pull_request_comment::Model, PullError> {
317 pull_request_comment::Entity::find_by_id(comment_id)
318 .filter(pull_request_comment::Column::PullRequestId.eq(pull_id))
319 .one(self.db_read())
320 .await?
321 .ok_or(PullError::NotFound)
322 }
323
324 async fn comment_users(
325 &self,
326 rows: &[pull_request_comment::Model],
327 ) -> Result<HashMap<Uuid, UserDto>, PullError> {
328 self.user_briefs(&unique_ids(rows.iter().map(|row| row.user_id)))
329 .await
330 }
331
332 fn comment_dto(
333 row: &pull_request_comment::Model,
334 users: &HashMap<Uuid, UserDto>,
335 ) -> PullCommentDto {
336 PullCommentDto {
337 id: row.id,
338 pull_request_id: row.pull_request_id,
339 author: users.get(&row.user_id).cloned(),
340 body: row.body.clone(),
341 path: row.path.clone(),
342 line: row.line,
343 side: row.side.clone(),
344 commit_sha: row.commit_sha.clone(),
345 review_id: row.review_id,
346 parent_comment_id: row.parent_comment_id,
347 resolved: row.resolved_at.is_some(),
348 resolved_by: row.resolved_by,
349 created_at: row.created_at,
350 updated_at: row.updated_at,
351 }
352 }
353}
354
355impl PullService {
356 /// 单条评论视图(含作者信息)。
357 pub async fn comment_dto_for(
358 &self,
359 row: &pull_request_comment::Model,
360 ) -> Result<PullCommentDto, PullError> {
361 let users = self.comment_users(std::slice::from_ref(row)).await?;
362 Ok(Self::comment_dto(row, &users))
363 }
364}
Added lib/pulls/src/services/events.rs View lib/pulls/src/services/events.rs
@@ -0,0 +1,158 @@
1use export::pagination::{Page, PageQuery};
2use models::pull_request::{pull_request_event_line, pull_requests};
3use models::users::user;
4use sea_orm::{
5 ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait,
6 QueryFilter, QueryOrder, QuerySelect,
7};
8use serde::{Deserialize, Serialize};
9use space::services::spaces::UserDto;
10use std::collections::HashMap;
11use time::OffsetDateTime;
12use uuid::Uuid;
13
14use crate::errors::PullError;
15use crate::services::service::PullService;
16
17pub const EVENT_CREATED: &str = "created";
18pub const EVENT_UPDATED: &str = "updated";
19pub const EVENT_CLOSED: &str = "closed";
20pub const EVENT_REOPENED: &str = "reopened";
21pub const EVENT_MERGED: &str = "merged";
22pub const EVENT_COMMENTED: &str = "commented";
23pub const EVENT_REVIEW_SUBMITTED: &str = "review_submitted";
24pub const EVENT_REVIEW_DISMISSED: &str = "review_dismissed";
25pub const EVENT_REVIEW_REQUESTED: &str = "review_requested";
26pub const EVENT_REVIEW_REQUEST_CANCELLED: &str = "review_request_cancelled";
27pub const EVENT_HEAD_UPDATED: &str = "head_updated";
28pub const EVENT_LABELED: &str = "labeled";
29pub const EVENT_UNLABELED: &str = "unlabeled";
30pub const EVENT_ASSIGNED: &str = "assigned";
31pub const EVENT_UNASSIGNED: &str = "unassigned";
32pub const EVENT_LOCKED: &str = "locked";
33pub const EVENT_UNLOCKED: &str = "unlocked";
34pub const EVENT_AUTO_MERGE_ENABLED: &str = "auto_merge_enabled";
35pub const EVENT_AUTO_MERGE_DISABLED: &str = "auto_merge_disabled";
36
37#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
38pub struct PullEventDto {
39 pub id: Uuid,
40 pub pull_request_id: Uuid,
41 pub event_type: String,
42 pub actor: Option<UserDto>,
43 pub commit_sha: Option<String>,
44 pub content: Option<String>,
45 pub created_at: OffsetDateTime,
46}
47
48pub type EventPage = Page<PullEventDto>;
49
50pub async fn write_event(
51 db: &impl ConnectionTrait,
52 pull: &pull_requests::Model,
53 actor: Option<Uuid>,
54 event_type: &str,
55 commit_sha: Option<String>,
56 content: Option<String>,
57) -> Result<(), sea_orm::DbErr> {
58 let actor = match actor {
59 Some(actor) => actor,
60 None => pull.author_id,
61 };
62 let row = pull_request_event_line::ActiveModel {
63 id: Set(Uuid::now_v7()),
64 pull_request_id: Set(pull.id),
65 user_id: Set(actor),
66 event_type: Set(event_type.to_string()),
67 commit_sha: Set(commit_sha),
68 content: Set(content),
69 created_at: Set(OffsetDateTime::now_utc()),
70 ..Default::default()
71 };
72 row.insert(db).await?;
73 Ok(())
74}
75
76impl PullService {
77 pub async fn record_event(
78 &self,
79 pull: &pull_requests::Model,
80 actor: Option<Uuid>,
81 event_type: &str,
82 commit_sha: Option<String>,
83 content: Option<String>,
84 ) -> Result<(), PullError> {
85 write_event(
86 self.db_write(),
87 pull,
88 actor,
89 event_type,
90 commit_sha,
91 content,
92 )
93 .await?;
94 Ok(())
95 }
96
97 pub async fn list_events(
98 &self,
99 pull_id: Uuid,
100 page: PageQuery,
101 ) -> Result<EventPage, PullError> {
102 let (limit, offset) = page.page();
103 let total = pull_request_event_line::Entity::find()
104 .filter(pull_request_event_line::Column::PullRequestId.eq(pull_id))
105 .count(self.db_read())
106 .await? as i64;
107 let rows = pull_request_event_line::Entity::find()
108 .filter(pull_request_event_line::Column::PullRequestId.eq(pull_id))
109 .order_by_asc(pull_request_event_line::Column::CreatedAt)
110 .limit(limit)
111 .offset(offset)
112 .all(self.db_read())
113 .await?;
114 let actors = self.user_briefs(&unique_users(&rows)).await?;
115 let items = rows
116 .into_iter()
117 .map(|row| PullEventDto {
118 id: row.id,
119 pull_request_id: row.pull_request_id,
120 event_type: row.event_type,
121 commit_sha: row.commit_sha,
122 content: row.content,
123 created_at: row.created_at,
124 actor: actors.get(&row.user_id).cloned(),
125 })
126 .collect();
127 Ok(Page {
128 items,
129 total,
130 limit,
131 offset,
132 })
133 }
134
135 pub(crate) async fn user_briefs(
136 &self,
137 ids: &[Uuid],
138 ) -> Result<HashMap<Uuid, UserDto>, PullError> {
139 if ids.is_empty() {
140 return Ok(HashMap::new());
141 }
142 let rows = user::Entity::find()
143 .filter(user::Column::Uid.is_in(ids.iter().copied()))
144 .all(self.db_read())
145 .await?;
146 Ok(rows
147 .into_iter()
148 .map(|row| (row.uid, UserDto::from(row)))
149 .collect())
150 }
151}
152
153fn unique_users(rows: &[pull_request_event_line::Model]) -> Vec<Uuid> {
154 let mut ids: Vec<Uuid> = rows.iter().map(|row| row.user_id).collect();
155 ids.sort();
156 ids.dedup();
157 ids
158}
Added lib/pulls/src/services/inbox.rs View lib/pulls/src/services/inbox.rs
@@ -0,0 +1,100 @@
1use export::pagination::Page;
2use models::pull_request::{pull_request_review, pull_request_reviewer, pull_requests};
3use sea_orm::{
4 ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Select,
5};
6use serde::{Deserialize, Serialize};
7use utoipa::{IntoParams, ToSchema};
8use uuid::Uuid;
9
10use crate::errors::PullError;
11use crate::services::pulls::PullDto;
12use crate::services::service::PullService;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
15#[serde(rename_all = "kebab-case")]
16pub enum InboxScope {
17 Created,
18 ReviewRequested,
19 Reviewed,
20}
21
22#[derive(Debug, Clone, Default, Deserialize, IntoParams)]
23pub struct InboxQuery {
24 /// open | closed | merged | all
25 #[serde(default)]
26 pub state: Option<String>,
27 #[serde(default)]
28 pub limit: Option<u64>,
29 #[serde(default)]
30 pub offset: Option<u64>,
31}
32
33pub type InboxPage = Page<PullDto>;
34
35impl PullService {
36 pub async fn list_inbox(
37 &self,
38 caller: Uuid,
39 scope: InboxScope,
40 query: InboxQuery,
41 ) -> Result<InboxPage, PullError> {
42 let limit = query.limit.unwrap_or(50).clamp(1, 100);
43 let offset = query.offset.unwrap_or(0);
44 let base: Select<pull_requests::Entity> = match scope {
45 InboxScope::Created => {
46 pull_requests::Entity::find().filter(pull_requests::Column::AuthorId.eq(caller))
47 }
48 InboxScope::ReviewRequested => {
49 let ids: Vec<Uuid> = pull_request_reviewer::Entity::find()
50 .filter(pull_request_reviewer::Column::UserId.eq(caller))
51 .all(self.db_read())
52 .await?
53 .into_iter()
54 .map(|row| row.pull_request_id)
55 .collect();
56 pull_requests::Entity::find().filter(pull_requests::Column::Id.is_in(ids))
57 }
58 InboxScope::Reviewed => {
59 let ids: Vec<Uuid> = pull_request_review::Entity::find()
60 .filter(pull_request_review::Column::UserId.eq(caller))
61 .all(self.db_read())
62 .await?
63 .into_iter()
64 .map(|row| row.pull_request_id)
65 .collect();
66 pull_requests::Entity::find().filter(pull_requests::Column::Id.is_in(ids))
67 }
68 };
69 let mut select = match query.state.as_deref().unwrap_or("open") {
70 "all" => base,
71 "closed" => base
72 .filter(pull_requests::Column::HasMerged.eq(false))
73 .filter(pull_requests::Column::ClosedAt.is_not_null()),
74 "merged" => base.filter(pull_requests::Column::HasMerged.eq(true)),
75 "open" => base
76 .filter(pull_requests::Column::HasMerged.eq(false))
77 .filter(pull_requests::Column::ClosedAt.is_null()),
78 other => {
79 return Err(PullError::BadRequest(format!("unknown state `{other}`")));
80 }
81 };
82 let total = select.clone().count(self.db_read()).await? as i64;
83 select = select.order_by_desc(pull_requests::Column::UpdatedAt);
84 let rows = select
85 .limit(limit)
86 .offset(offset)
87 .all(self.db_read())
88 .await?;
89 let users = self.pull_users(&rows).await?;
90 Ok(Page {
91 items: rows
92 .iter()
93 .map(|row| PullService::dto(row, &users))
94 .collect(),
95 total,
96 limit,
97 offset,
98 })
99 }
100}
Added lib/pulls/src/services/labels.rs View lib/pulls/src/services/labels.rs
@@ -0,0 +1,130 @@
1use models::issue::issue_label;
2use models::pull_request::{pull_request_label, pull_requests};
3use sea_orm::{
4 ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter, QueryOrder,
5};
6use serde::Deserialize;
7use utoipa::ToSchema;
8use uuid::Uuid;
9
10use crate::errors::PullError;
11use crate::services::events::{EVENT_LABELED, EVENT_UNLABELED};
12use crate::services::service::PullService;
13use crate::share::authz::require_unlocked;
14
15#[derive(Debug, Clone, Deserialize, ToSchema)]
16pub struct AttachLabelCmd {
17 /// 标签 UUID 或名称(同一仓库目录内的标签)。
18 pub label: String,
19}
20
21impl PullService {
22 async fn resolve_label(
23 &self,
24 dir_id: Uuid,
25 reference: &str,
26 ) -> Result<issue_label::Model, PullError> {
27 let reference = reference.trim();
28 let select = issue_label::Entity::find()
29 .filter(issue_label::Column::DirId.eq(dir_id))
30 .filter(issue_label::Column::IsArchived.eq(false));
31 let row = if let Ok(id) = Uuid::parse_str(reference) {
32 select.filter(issue_label::Column::Id.eq(id))
33 } else {
34 select.filter(issue_label::Column::Name.eq(reference))
35 }
36 .one(self.db_read())
37 .await?;
38 row.ok_or(PullError::BadRequest(format!(
39 "label `{reference}` not found in this repository directory"
40 )))
41 }
42
43 pub async fn list_labels(
44 &self,
45 pull_id: Uuid,
46 ) -> Result<Vec<issues::services::labels::LabelDto>, PullError> {
47 let links = pull_request_label::Entity::find()
48 .filter(pull_request_label::Column::PullRequestId.eq(pull_id))
49 .order_by_asc(pull_request_label::Column::CreatedAt)
50 .all(self.db_read())
51 .await?;
52 if links.is_empty() {
53 return Ok(Vec::new());
54 }
55 let ids: Vec<Uuid> = links.iter().map(|link| link.label_id).collect();
56 let rows = issue_label::Entity::find()
57 .filter(issue_label::Column::Id.is_in(ids))
58 .all(self.db_read())
59 .await?;
60 Ok(rows
61 .into_iter()
62 .map(issues::services::labels::LabelDto::from)
63 .collect())
64 }
65
66 pub async fn attach_label(
67 &self,
68 pull: &pull_requests::Model,
69 dir_id: Uuid,
70 caller: Uuid,
71 cmd: AttachLabelCmd,
72 ) -> Result<Vec<issues::services::labels::LabelDto>, PullError> {
73 require_unlocked(pull)?;
74 let label = self.resolve_label(dir_id, &cmd.label).await?;
75 let existing = pull_request_label::Entity::find()
76 .filter(pull_request_label::Column::PullRequestId.eq(pull.id))
77 .filter(pull_request_label::Column::LabelId.eq(label.id))
78 .one(self.db_read())
79 .await?;
80 if existing.is_none() {
81 pull_request_label::ActiveModel {
82 id: Set(Uuid::now_v7()),
83 pull_request_id: Set(pull.id),
84 label_id: Set(label.id),
85 created_at: Set(OffsetDateTime::now_utc()),
86 ..Default::default()
87 }
88 .insert(self.db_write())
89 .await?;
90 self.record_event(
91 pull,
92 Some(caller),
93 EVENT_LABELED,
94 None,
95 Some(label.name.clone()),
96 )
97 .await?;
98 }
99 self.list_labels(pull.id).await
100 }
101
102 pub async fn detach_label(
103 &self,
104 pull: &pull_requests::Model,
105 dir_id: Uuid,
106 caller: Uuid,
107 label_ref: &str,
108 ) -> Result<Vec<issues::services::labels::LabelDto>, PullError> {
109 require_unlocked(pull)?;
110 let label = self.resolve_label(dir_id, label_ref).await?;
111 let deleted = pull_request_label::Entity::delete_many()
112 .filter(pull_request_label::Column::PullRequestId.eq(pull.id))
113 .filter(pull_request_label::Column::LabelId.eq(label.id))
114 .exec(self.db_write())
115 .await?;
116 if deleted.rows_affected > 0 {
117 self.record_event(
118 pull,
119 Some(caller),
120 EVENT_UNLABELED,
121 None,
122 Some(label.name.clone()),
123 )
124 .await?;
125 }
126 self.list_labels(pull.id).await
127 }
128}
129
130use time::OffsetDateTime;
Added lib/pulls/src/services/merge.rs View lib/pulls/src/services/merge.rs
@@ -0,0 +1,224 @@
1use git::context::facade::GitContext;
2use git::service::merge::{ExecuteMergeCmd, MergeResultDto};
3use models::pull_request::pull_requests;
4use models::pull_request::pull_requests::MergeMethod;
5use sea_orm::{ActiveModelTrait, ActiveValue::Set, IntoActiveModel};
6use serde::{Deserialize, Serialize};
7use utoipa::ToSchema;
8use uuid::Uuid;
9
10use crate::errors::PullError;
11use crate::services::changes::PullSummaryDto;
12use crate::services::service::PullService;
13use crate::share::authz::require_open;
14
15#[derive(Debug, Clone, Deserialize, ToSchema)]
16pub struct MergePullCmd {
17 /// merge | squash | rebase
18 #[serde(default)]
19 pub method: Option<MergeMethod>,
20 #[serde(default)]
21 pub title: Option<String>,
22 #[serde(default)]
23 pub message: Option<String>,
24 /// 允许在无冲突时直接快进(method=merge 时生效)。
25 #[serde(default)]
26 pub fast_forward_only: Option<bool>,
27 #[serde(default)]
28 pub expect_head_sha: Option<String>,
29 #[serde(default)]
30 pub delete_branch_after_merge: Option<bool>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
34pub struct MergePullDto {
35 pub pull: super::pulls::PullDto,
36 pub result: MergeResultDto,
37 pub summary: PullSummaryDto,
38}
39
40impl PullService {
41 pub async fn merge_pull(
42 &self,
43 space_name: String,
44 repo_name: String,
45 ctx: &GitContext,
46 pull: &pull_requests::Model,
47 caller: Uuid,
48 admin: bool,
49 cmd: MergePullCmd,
50 ) -> Result<(pull_requests::Model, MergeResultDto, PullSummaryDto), PullError> {
51 require_open(pull)?;
52 if pull.is_draft {
53 return Err(PullError::Conflict(
54 "pull request is still a draft".to_string(),
55 ));
56 }
57 if let Some(expected) = cmd.expect_head_sha.as_deref() {
58 if expected != pull.head_sha {
59 return Err(PullError::Conflict(format!(
60 "head moved: expected {expected}, found {}",
61 pull.head_sha
62 )));
63 }
64 }
65
66 let (current, summary) = self
67 .refresh_pull(space_name.clone(), repo_name.clone(), pull, Some(caller))
68 .await?;
69 if summary.commits_ahead == 0 {
70 return Err(PullError::Conflict(
71 "nothing to merge; head is already contained in base".to_string(),
72 ));
73 }
74 if !summary.mergeable {
75 return Err(PullError::Conflict(format!(
76 "merge conflict in {} file(s): {}",
77 summary.conflicts.len(),
78 summary.conflicts.join(", ")
79 )));
80 }
81 let gate = self
82 .merge_gate(
83 space_name.clone(),
84 repo_name.clone(),
85 ctx,
86 &current,
87 caller,
88 admin,
89 &summary,
90 )
91 .await?;
92 if !gate.allowed {
93 return Err(PullError::Conflict(gate.reasons.join("; ")));
94 }
95
96 let method = cmd.method.unwrap_or(current.merge_method);
97 match method {
98 MergeMethod::Merge => {}
99 MergeMethod::Squash => {
100 if !ctx.model.allow_squash_merge {
101 return Err(PullError::Forbidden);
102 }
103 }
104 MergeMethod::Rebase => {
105 if !ctx.model.allow_rebase && !ctx.model.allow_rebase_explicit {
106 return Err(PullError::Forbidden);
107 }
108 }
109 }
110
111 let subject = cmd.title.clone().unwrap_or_else(|| current.title.clone());
112 let message = cmd.message.clone().or_else(|| {
113 cmd.title
114 .as_ref()
115 .map(|_| format!("{} (#{})", subject, current.autoincrement_id))
116 });
117 let summary_label = format!("{} (#{})", subject, current.autoincrement_id);
118 let result = self
119 .git
120 .merge_execute(
121 space_name.clone(),
122 repo_name.clone(),
123 ExecuteMergeCmd {
124 base_branch: current.base_branch.clone(),
125 head_revision: summary.head_sha.clone(),
126 method,
127 message,
128 caller,
129 allow_ff_only: cmd.fast_forward_only.unwrap_or(true),
130 summary: summary_label,
131 },
132 )
133 .await?;
134
135 let squash_sha = if result.method == "squash" {
136 Some(result.new_sha.clone())
137 } else {
138 None
139 };
140 let merged = self
141 .apply_merge_outcome(&current, caller, result.new_sha.clone(), squash_sha, method)
142 .await?;
143
144 if cmd.delete_branch_after_merge.unwrap_or(false)
145 && merged.head_branch != ctx.model.default_branch
146 {
147 if let Err(error) = self
148 .git
149 .delete_branch(
150 space_name.clone(),
151 repo_name.clone(),
152 merged.head_branch.clone(),
153 false,
154 )
155 .await
156 {
157 tracing::warn!(
158 error = %error,
159 branch = %merged.head_branch,
160 "merged pull request head branch cleanup failed"
161 );
162 }
163 }
164 Ok((merged, result, summary))
165 }
166
167 /// 把 base 分支的最新提交并入 head 分支(更新 PR 分支)。
168 pub async fn update_pull_branch(
169 &self,
170 space_name: String,
171 repo_name: String,
172 ctx: &GitContext,
173 pull: &pull_requests::Model,
174 caller: Uuid,
175 admin: bool,
176 ) -> Result<(pull_requests::Model, MergeResultDto), PullError> {
177 require_open(pull)?;
178 let _ = ctx;
179 if pull.is_locked && !admin {
180 return Err(PullError::Forbidden);
181 }
182 let (current, summary) = self
183 .refresh_pull(space_name.clone(), repo_name.clone(), pull, Some(caller))
184 .await?;
185 if summary.commits_behind == 0 {
186 return Err(PullError::Conflict(
187 "branch is already up to date".to_string(),
188 ));
189 }
190 let result = self
191 .git
192 .merge_execute(
193 space_name.clone(),
194 repo_name.clone(),
195 ExecuteMergeCmd {
196 base_branch: current.head_branch.clone(),
197 head_revision: format!("refs/heads/{}", current.base_branch),
198 method: MergeMethod::Merge,
199 message: Some(format!(
200 "Merge branch '{}' into '{}'",
201 current.base_branch, current.head_branch
202 )),
203 caller,
204 allow_ff_only: false,
205 summary: format!("update #{}", current.autoincrement_id),
206 },
207 )
208 .await?;
209 let head_sha = result.new_sha.clone();
210 let mut model = current.into_active_model();
211 model.head_sha = Set(head_sha.clone());
212 model.updated_at = Set(Some(time::OffsetDateTime::now_utc()));
213 let updated = model.update(self.db_write()).await?;
214 self.record_event(
215 &updated,
216 Some(caller),
217 crate::services::events::EVENT_HEAD_UPDATED,
218 Some(head_sha),
219 Some("base branch merged into head".to_string()),
220 )
221 .await?;
222 Ok((updated, result))
223 }
224}
Added lib/pulls/src/services/mod.rs View lib/pulls/src/services/mod.rs
@@ -0,0 +1,14 @@
1pub mod approvals;
2pub mod assignees;
3pub mod changes;
4pub mod checks;
5pub mod comments;
6pub mod events;
7pub mod inbox;
8pub mod labels;
9pub mod merge;
10pub mod pulls;
11pub mod relations;
12pub mod reviewers;
13pub mod reviews;
14pub mod service;
Added lib/pulls/src/services/pulls.rs View lib/pulls/src/services/pulls.rs
@@ -0,0 +1,709 @@
1use export::pagination::Page;
2use git::context::facade::GitContext;
3use models::issue::issue_relation_pull;
4use models::pull_request::{
5 pull_request_assignee, pull_request_comment, pull_request_event_line, pull_request_label,
6 pull_request_review, pull_request_reviewer, pull_requests,
7};
8use models::repo::repos;
9use models::users::user;
10use sea_orm::{
11 ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter,
12 QueryOrder, QuerySelect, TransactionTrait,
13};
14use serde::{Deserialize, Serialize};
15use space::services::spaces::UserDto;
16use std::collections::HashMap;
17use time::OffsetDateTime;
18use utoipa::{IntoParams, ToSchema};
19use uuid::Uuid;
20
21use crate::errors::PullError;
22use crate::services::events::{
23 EVENT_CLOSED, EVENT_CREATED, EVENT_MERGED, EVENT_REOPENED, EVENT_UPDATED, write_event,
24};
25use crate::services::service::PullService;
26
27pub const MAX_TITLE_LEN: usize = 512;
28pub const MAX_BODY_LEN: usize = 65_536;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
31#[serde(rename_all = "snake_case")]
32pub enum PullState {
33 Open,
34 Closed,
35 Merged,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
39pub struct PullRefDto {
40 pub label: String,
41 pub branch: String,
42 pub sha: String,
43 pub repo_id: Option<Uuid>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
47pub struct PullDto {
48 pub id: Uuid,
49 pub repo_id: Uuid,
50 pub number: i32,
51 pub title: String,
52 pub body: String,
53 pub state: PullState,
54 pub is_draft: bool,
55 pub auto_merge: bool,
56 pub locked: bool,
57 pub head: PullRefDto,
58 pub base: PullRefDto,
59 pub author: Option<UserDto>,
60 pub merger: Option<UserDto>,
61 pub merge_method: models::pull_request::pull_requests::MergeMethod,
62 pub additions: i32,
63 pub deletions: i32,
64 pub files_changed: i32,
65 pub merged_commit_sha: Option<String>,
66 pub squash_commit_sha: Option<String>,
67 pub issue_id: Option<Uuid>,
68 pub created_at: OffsetDateTime,
69 pub updated_at: Option<OffsetDateTime>,
70 pub closed_at: Option<OffsetDateTime>,
71 pub merged_at: Option<OffsetDateTime>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub mergeable: Option<bool>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub mergeable_state: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub conflicts: Option<Vec<String>>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub commits_ahead: Option<i64>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub commits_behind: Option<i64>,
82}
83
84pub type PullPage = Page<PullDto>;
85
86#[derive(Debug, Clone, Default, Deserialize, IntoParams)]
87pub struct ListPullQuery {
88 /// open | closed | merged | all
89 #[serde(default)]
90 pub state: Option<String>,
91 #[serde(default)]
92 pub author: Option<String>,
93 #[serde(default)]
94 pub base: Option<String>,
95 #[serde(default)]
96 pub head: Option<String>,
97 #[serde(default)]
98 pub draft: Option<bool>,
99 #[serde(default)]
100 pub search: Option<String>,
101 /// number | created | updated
102 #[serde(default)]
103 pub sort: Option<String>,
104 /// asc | desc
105 #[serde(default)]
106 pub order: Option<String>,
107 #[serde(default)]
108 pub limit: Option<u64>,
109 #[serde(default)]
110 pub offset: Option<u64>,
111}
112
113#[derive(Debug, Clone, Deserialize, ToSchema)]
114pub struct CreatePullCmd {
115 pub title: String,
116 #[serde(default)]
117 pub body: Option<String>,
118 pub head_branch: String,
119 #[serde(default)]
120 pub base_branch: Option<String>,
121 #[serde(default)]
122 pub draft: Option<bool>,
123 #[serde(default)]
124 pub issue_id: Option<Uuid>,
125}
126
127#[derive(Debug, Clone, Deserialize, ToSchema)]
128pub struct UpdatePullCmd {
129 #[serde(default)]
130 pub title: Option<String>,
131 #[serde(default)]
132 pub body: Option<Option<String>>,
133 #[serde(default)]
134 pub base_branch: Option<String>,
135 #[serde(default)]
136 pub draft: Option<bool>,
137 #[serde(default)]
138 pub auto_merge: Option<Option<bool>>,
139 #[serde(default)]
140 pub issue_id: Option<Option<Uuid>>,
141}
142
143fn state_of(pull: &pull_requests::Model) -> PullState {
144 if pull.has_merged {
145 PullState::Merged
146 } else if pull.closed_at.is_some() {
147 PullState::Closed
148 } else {
149 PullState::Open
150 }
151}
152
153impl PullService {
154 pub(crate) fn dto(pull: &pull_requests::Model, users: &HashMap<Uuid, UserDto>) -> PullDto {
155 PullDto {
156 id: pull.id,
157 repo_id: pull.repo_id,
158 number: pull.autoincrement_id,
159 title: pull.title.clone(),
160 body: pull.body.clone(),
161 state: state_of(pull),
162 is_draft: pull.is_draft,
163 auto_merge: pull.auto_merge,
164 locked: pull.is_locked,
165 head: PullRefDto {
166 label: pull
167 .head_label
168 .clone()
169 .unwrap_or_else(|| pull.head_branch.clone()),
170 branch: pull.head_branch.clone(),
171 sha: pull.head_sha.clone(),
172 repo_id: pull.head_repo_id,
173 },
174 base: PullRefDto {
175 label: pull.base_branch.clone(),
176 branch: pull.base_branch.clone(),
177 sha: pull.base_sha.clone(),
178 repo_id: Some(pull.repo_id),
179 },
180 author: users.get(&pull.author_id).cloned(),
181 merger: pull.merger_id.and_then(|id| users.get(&id).cloned()),
182 merge_method: pull.merge_method,
183 additions: pull.additions,
184 deletions: pull.deletions,
185 files_changed: pull.files_changed,
186 merged_commit_sha: pull.merged_commit_sha.clone(),
187 squash_commit_sha: pull.squash_commit_sha.clone(),
188 issue_id: pull.issue_id,
189 created_at: pull.created_at,
190 updated_at: pull.updated_at,
191 closed_at: pull.closed_at,
192 merged_at: pull.merged_at,
193 mergeable: None,
194 mergeable_state: None,
195 conflicts: None,
196 commits_ahead: None,
197 commits_behind: None,
198 }
199 }
200
201 pub async fn list_pulls(&self, repo_id: Uuid, q: ListPullQuery) -> Result<PullPage, PullError> {
202 let limit = q.limit.unwrap_or(50).clamp(1, 100);
203 let offset = q.offset.unwrap_or(0);
204 let state = q.state.clone().unwrap_or_else(|| "open".to_string());
205 let mut select =
206 pull_requests::Entity::find().filter(pull_requests::Column::RepoId.eq(repo_id));
207
208 match state.as_str() {
209 "all" => {}
210 "open" => {
211 select = select
212 .filter(pull_requests::Column::HasMerged.eq(false))
213 .filter(pull_requests::Column::ClosedAt.is_null());
214 }
215 "closed" => {
216 select = select
217 .filter(pull_requests::Column::HasMerged.eq(false))
218 .filter(pull_requests::Column::ClosedAt.is_not_null());
219 }
220 "merged" => select = select.filter(pull_requests::Column::HasMerged.eq(true)),
221 other => {
222 return Err(PullError::BadRequest(format!("unknown state `{other}`")));
223 }
224 }
225 if let Some(author) = q.author.as_deref().filter(|a| !a.trim().is_empty()) {
226 let uid = self.resolve_user_id(author).await?;
227 select = select.filter(pull_requests::Column::AuthorId.eq(uid));
228 }
229 if let Some(base) = q.base.as_deref().filter(|b| !b.trim().is_empty()) {
230 select = select.filter(pull_requests::Column::BaseBranch.eq(base));
231 }
232 if let Some(head) = q.head.as_deref().filter(|h| !h.trim().is_empty()) {
233 select = select.filter(pull_requests::Column::HeadBranch.eq(head));
234 }
235 if let Some(draft) = q.draft {
236 select = select.filter(pull_requests::Column::IsDraft.eq(draft));
237 }
238 if let Some(search) = q.search.as_deref().filter(|s| !s.trim().is_empty()) {
239 select = select.filter(pull_requests::Column::Title.contains(search));
240 }
241
242 let total = select.clone().count(self.db_read()).await? as i64;
243 let descending = q.order.as_deref().unwrap_or("desc") != "asc";
244 select = match q.sort.as_deref().unwrap_or("created") {
245 "number" => {
246 if descending {
247 select.order_by_desc(pull_requests::Column::AutoincrementId)
248 } else {
249 select.order_by_asc(pull_requests::Column::AutoincrementId)
250 }
251 }
252 "updated" => {
253 if descending {
254 select.order_by_desc(pull_requests::Column::UpdatedAt)
255 } else {
256 select.order_by_asc(pull_requests::Column::UpdatedAt)
257 }
258 }
259 _ => {
260 if descending {
261 select.order_by_desc(pull_requests::Column::CreatedAt)
262 } else {
263 select.order_by_asc(pull_requests::Column::CreatedAt)
264 }
265 }
266 };
267 let rows = select
268 .limit(limit)
269 .offset(offset)
270 .all(self.db_read())
271 .await?;
272 let users = self.pull_users(&rows).await?;
273 let items = rows.iter().map(|row| Self::dto(row, &users)).collect();
274 Ok(Page {
275 items,
276 total,
277 limit,
278 offset,
279 })
280 }
281
282 pub async fn create_pull(
283 &self,
284 space_name: String,
285 repo_name: String,
286 ctx: &GitContext,
287 caller: Uuid,
288 cmd: CreatePullCmd,
289 ) -> Result<pull_requests::Model, PullError> {
290 let title = cmd.title.trim();
291 if title.is_empty() || title.chars().count() > MAX_TITLE_LEN {
292 return Err(PullError::BadRequest(format!(
293 "title must be 1-{MAX_TITLE_LEN} characters"
294 )));
295 }
296 let body = cmd.body.clone().unwrap_or_default();
297 if body.chars().count() > MAX_BODY_LEN {
298 return Err(PullError::BadRequest("body too large".to_string()));
299 }
300 if !self
301 .state
302 .redis
303 .quota_allow(
304 &format!("quota:pull_create:{caller}"),
305 self.config().quota.pull_create_per_day,
306 86_400,
307 )
308 .await
309 {
310 return Err(PullError::RateLimited(
311 "pull request creation quota exceeded, retry tomorrow".to_string(),
312 ));
313 }
314
315 let base_branch = cmd
316 .base_branch
317 .clone()
318 .unwrap_or_else(|| ctx.model.default_branch.clone());
319 if base_branch == cmd.head_branch {
320 return Err(PullError::BadRequest(
321 "head and base branch must differ".to_string(),
322 ));
323 }
324 if let Some(issue_id) = cmd.issue_id {
325 if !models::issue::issue_item::Entity::find_by_id(issue_id)
326 .one(self.db_read())
327 .await?
328 .is_some()
329 {
330 return Err(PullError::BadRequest("issue not found".to_string()));
331 }
332 }
333
334 let plan = self
335 .git
336 .merge_plan(
337 ctx,
338 &format!("refs/heads/{base_branch}"),
339 &format!("refs/heads/{}", cmd.head_branch),
340 )
341 .await
342 .map_err(|e| match e {
343 git::share::errors::GitError::NotFound => PullError::BadRequest(
344 "head or base branch not found in this repository".to_string(),
345 ),
346 other => PullError::from(other),
347 })?;
348 if plan.head_only == 0 {
349 return Err(PullError::Conflict(
350 "the head branch has no commits ahead of the base branch".to_string(),
351 ));
352 }
353 let stats = self
354 .diff_stats_for(&space_name, &repo_name, &plan, &plan.head_sha)
355 .await?;
356
357 let txn = self.state.database.write().begin().await?;
358 let _ = repos::Entity::find_by_id(ctx.model.id)
359 .lock_exclusive()
360 .one(&txn)
361 .await?;
362 let last = pull_requests::Entity::find()
363 .filter(pull_requests::Column::RepoId.eq(ctx.model.id))
364 .order_by_desc(pull_requests::Column::AutoincrementId)
365 .one(&txn)
366 .await?;
367 let number = last.map(|row| row.autoincrement_id + 1).unwrap_or(1);
368 let now = OffsetDateTime::now_utc();
369 let created = pull_requests::ActiveModel {
370 id: Set(Uuid::now_v7()),
371 repo_id: Set(ctx.model.id),
372 issue_id: Set(cmd.issue_id),
373 autoincrement_id: Set(number),
374 author_id: Set(caller),
375 title: Set(title.to_string()),
376 body: Set(body),
377 head_branch: Set(cmd.head_branch.clone()),
378 head_sha: Set(plan.head_sha.clone()),
379 head_repo_id: Set(None),
380 head_label: Set(Some(format!("{}", cmd.head_branch))),
381 base_branch: Set(base_branch.clone()),
382 base_sha: Set(plan.base_sha.clone()),
383 is_draft: Set(cmd.draft.unwrap_or(false)),
384 merge_method: Set(models::pull_request::pull_requests::MergeMethod::Merge),
385 has_merged: Set(false),
386 merged_commit_sha: Set(None),
387 squash_commit_sha: Set(None),
388 auto_merge: Set(false),
389 additions: Set(stats.0),
390 deletions: Set(stats.1),
391 files_changed: Set(stats.2),
392 merger_id: Set(None),
393 merged_at: Set(None),
394 is_locked: Set(false),
395 created_at: Set(now),
396 updated_at: Set(None),
397 closed_at: Set(None),
398 ..Default::default()
399 };
400 let pull = created.insert(&txn).await?;
401 write_event(
402 &txn,
403 &pull,
404 Some(caller),
405 EVENT_CREATED,
406 Some(plan.head_sha.clone()),
407 None,
408 )
409 .await?;
410 txn.commit().await?;
411 self.recount_open(ctx.model.id).await?;
412 Ok(pull)
413 }
414
415 pub async fn update_pull(
416 &self,
417 space_name: String,
418 repo_name: String,
419 ctx: &GitContext,
420 pull: &pull_requests::Model,
421 caller: Uuid,
422 admin: bool,
423 cmd: UpdatePullCmd,
424 ) -> Result<pull_requests::Model, PullError> {
425 if pull.is_locked && !admin {
426 return Err(PullError::Forbidden);
427 }
428 let mut model: pull_requests::ActiveModel = pull.clone().into();
429 let mut changed = false;
430 if let Some(title) = cmd.title.as_deref() {
431 let title = title.trim();
432 if title.is_empty() || title.chars().count() > MAX_TITLE_LEN {
433 return Err(PullError::BadRequest("invalid title".to_string()));
434 }
435 model.title = Set(title.to_string());
436 changed = true;
437 }
438 if let Some(body) = cmd.body {
439 if body
440 .as_deref()
441 .is_some_and(|b| b.chars().count() > MAX_BODY_LEN)
442 {
443 return Err(PullError::BadRequest("body too large".to_string()));
444 }
445 model.body = Set(body.unwrap_or_default());
446 changed = true;
447 }
448 if let Some(draft) = cmd.draft {
449 model.is_draft = Set(draft);
450 changed = true;
451 }
452 if let Some(auto) = cmd.auto_merge {
453 if auto.is_some_and(|a| a) {
454 crate::share::authz::require_open(pull)?;
455 }
456 model.auto_merge = Set(auto.unwrap_or(false));
457 changed = true;
458 }
459 if let Some(issue_id) = cmd.issue_id {
460 if let Some(id) = issue_id {
461 if !models::issue::issue_item::Entity::find_by_id(id)
462 .one(self.db_read())
463 .await?
464 .is_some()
465 {
466 return Err(PullError::BadRequest("issue not found".to_string()));
467 }
468 }
469 model.issue_id = Set(issue_id);
470 changed = true;
471 }
472 if let Some(base_branch) = cmd.base_branch.as_deref() {
473 if base_branch == pull.head_branch {
474 return Err(PullError::BadRequest(
475 "base branch must differ from head branch".to_string(),
476 ));
477 }
478 let plan = self
479 .git
480 .merge_plan(ctx, &format!("refs/heads/{base_branch}"), &pull.head_sha)
481 .await
482 .map_err(|_| {
483 PullError::BadRequest(format!("base branch `{base_branch}` not found"))
484 })?;
485 model.base_branch = Set(base_branch.to_string());
486 model.base_sha = Set(plan.base_sha);
487 changed = true;
488 }
489 if !changed {
490 return Ok(pull.clone());
491 }
492 model.updated_at = Set(Some(OffsetDateTime::now_utc()));
493 let updated = model.update(self.db_write()).await?;
494 self.record_event(&updated, Some(caller), EVENT_UPDATED, None, None)
495 .await?;
496 let _ = (space_name, repo_name);
497 Ok(updated)
498 }
499
500 pub async fn set_state(
501 &self,
502 pull: &pull_requests::Model,
503 caller: Uuid,
504 closed: bool,
505 ) -> Result<pull_requests::Model, PullError> {
506 if pull.has_merged {
507 return Err(PullError::Conflict(
508 "a merged pull request cannot change state".to_string(),
509 ));
510 }
511 if closed && pull.closed_at.is_some() {
512 return Ok(pull.clone());
513 }
514 if !closed && pull.closed_at.is_none() {
515 return Ok(pull.clone());
516 }
517 let mut model: pull_requests::ActiveModel = pull.clone().into();
518 model.closed_at = Set(if closed {
519 Some(OffsetDateTime::now_utc())
520 } else {
521 None
522 });
523 model.updated_at = Set(Some(OffsetDateTime::now_utc()));
524 let updated = model.update(self.db_write()).await?;
525 self.record_event(
526 &updated,
527 Some(caller),
528 if closed { EVENT_CLOSED } else { EVENT_REOPENED },
529 None,
530 None,
531 )
532 .await?;
533 self.recount_open(updated.repo_id).await?;
534 Ok(updated)
535 }
536
537 pub async fn delete_pull(
538 &self,
539 pull: &pull_requests::Model,
540 caller: Uuid,
541 ) -> Result<(), PullError> {
542 let _ = caller;
543 let id = pull.id;
544 let txn = self.state.database.write().begin().await?;
545 pull_request_comment::Entity::delete_many()
546 .filter(pull_request_comment::Column::PullRequestId.eq(id))
547 .exec(&txn)
548 .await?;
549 pull_request_review::Entity::delete_many()
550 .filter(pull_request_review::Column::PullRequestId.eq(id))
551 .exec(&txn)
552 .await?;
553 pull_request_reviewer::Entity::delete_many()
554 .filter(pull_request_reviewer::Column::PullRequestId.eq(id))
555 .exec(&txn)
556 .await?;
557 pull_request_label::Entity::delete_many()
558 .filter(pull_request_label::Column::PullRequestId.eq(id))
559 .exec(&txn)
560 .await?;
561 pull_request_assignee::Entity::delete_many()
562 .filter(pull_request_assignee::Column::PullRequestId.eq(id))
563 .exec(&txn)
564 .await?;
565 pull_request_event_line::Entity::delete_many()
566 .filter(pull_request_event_line::Column::PullRequestId.eq(id))
567 .exec(&txn)
568 .await?;
569 issue_relation_pull::Entity::delete_many()
570 .filter(issue_relation_pull::Column::PullRequestId.eq(id))
571 .exec(&txn)
572 .await?;
573 pull_requests::Entity::delete_many()
574 .filter(pull_requests::Column::Id.eq(id))
575 .exec(&txn)
576 .await?;
577 txn.commit().await?;
578 self.recount_open(pull.repo_id).await?;
579 Ok(())
580 }
581
582 pub(crate) async fn pull_users(
583 &self,
584 rows: &[pull_requests::Model],
585 ) -> Result<HashMap<Uuid, UserDto>, PullError> {
586 let mut ids: Vec<Uuid> = rows
587 .iter()
588 .flat_map(|row| [Some(row.author_id), row.merger_id])
589 .flatten()
590 .collect();
591 ids.sort();
592 ids.dedup();
593 if ids.is_empty() {
594 return Ok(HashMap::new());
595 }
596 let users = user::Entity::find()
597 .filter(user::Column::Uid.is_in(ids.iter().copied()))
598 .all(self.db_read())
599 .await?;
600 Ok(users
601 .into_iter()
602 .map(|row| (row.uid, UserDto::from(row)))
603 .collect())
604 }
605
606 pub(crate) async fn resolve_user_id(&self, reference: &str) -> Result<Uuid, PullError> {
607 let reference = reference.trim();
608 if let Ok(uid) = Uuid::parse_str(reference) {
609 return Ok(uid);
610 }
611 let row = user::Entity::find()
612 .filter(user::Column::Username.eq(reference))
613 .one(self.db_read())
614 .await?
615 .ok_or_else(|| PullError::BadRequest(format!("user `{reference}` not found")))?;
616 Ok(row.uid)
617 }
618
619 /// (additions, deletions, files_changed)
620 pub(crate) async fn diff_stats_for(
621 &self,
622 space_name: &str,
623 repo_name: &str,
624 plan: &git::service::merge::MergePlanDto,
625 head: &str,
626 ) -> Result<(i32, i32, i32), PullError> {
627 let from = plan
628 .merge_base
629 .clone()
630 .unwrap_or_else(|| plan.base_sha.clone());
631 let stat = self
632 .git
633 .diff_short_stat(
634 space_name.to_string(),
635 repo_name.to_string(),
636 from,
637 head.to_string(),
638 )
639 .await?;
640 Ok((
641 stat.additions as i32,
642 stat.deletions as i32,
643 stat.files_changed as i32,
644 ))
645 }
646
647 pub async fn apply_merge_outcome(
648 &self,
649 pull: &pull_requests::Model,
650 caller: Uuid,
651 merged_sha: String,
652 squash_sha: Option<String>,
653 method: models::pull_request::pull_requests::MergeMethod,
654 ) -> Result<pull_requests::Model, PullError> {
655 let mut model: pull_requests::ActiveModel = pull.clone().into();
656 model.has_merged = Set(true);
657 model.merger_id = Set(Some(caller));
658 model.merged_at = Set(Some(OffsetDateTime::now_utc()));
659 model.closed_at = Set(Some(OffsetDateTime::now_utc()));
660 model.merge_method = Set(method);
661 model.squash_commit_sha = Set(squash_sha);
662 model.merged_commit_sha = Set(Some(merged_sha.clone()));
663 model.auto_merge = Set(false);
664 model.updated_at = Set(Some(OffsetDateTime::now_utc()));
665 let updated = model.update(self.db_write()).await?;
666 self.record_event(&updated, Some(caller), EVENT_MERGED, Some(merged_sha), None)
667 .await?;
668 self.recount_open(updated.repo_id).await?;
669 Ok(updated)
670 }
671}
672
673impl PullService {
674 pub async fn set_lock(
675 &self,
676 pull: &pull_requests::Model,
677 caller: Uuid,
678 locked: bool,
679 ) -> Result<pull_requests::Model, PullError> {
680 if pull.is_locked == locked {
681 return Ok(pull.clone());
682 }
683 let mut model: pull_requests::ActiveModel = pull.clone().into();
684 model.is_locked = Set(locked);
685 model.updated_at = Set(Some(OffsetDateTime::now_utc()));
686 let updated = model.update(self.db_write()).await?;
687 self.record_event(
688 &updated,
689 Some(caller),
690 if locked {
691 crate::services::events::EVENT_LOCKED
692 } else {
693 crate::services::events::EVENT_UNLOCKED
694 },
695 None,
696 None,
697 )
698 .await?;
699 Ok(updated)
700 }
701}
702
703#[derive(Debug, Clone, Deserialize, ToSchema)]
704pub struct LinkIssueCmd {
705 pub space_name: String,
706 pub repo_name: String,
707 /// PR 编号或 UUID。
708 pub pull_ref: String,
709}
Added lib/pulls/src/services/relations.rs View lib/pulls/src/services/relations.rs
@@ -0,0 +1,109 @@
1use git::context::facade::GitContext;
2use models::issue::issue_relation_pull;
3use models::pull_request::pull_requests;
4use sea_orm::{
5 ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter, QueryOrder,
6};
7use serde::Deserialize;
8use time::OffsetDateTime;
9use utoipa::ToSchema;
10use uuid::Uuid;
11
12use crate::errors::PullError;
13use crate::services::pulls::PullDto;
14use crate::services::service::PullService;
15
16#[derive(Debug, Clone, Deserialize, ToSchema)]
17pub struct LinkIssueCmd {
18 /// PR 编号或 UUID。
19 pub pull_ref: String,
20}
21
22impl PullService {
23 /// 关联/解绑工单时必须对目标 PR 所在仓库有写权限,且工单与仓库同属一个空间。
24 pub async fn link_issue(
25 &self,
26 ctx: &GitContext,
27 issue_id: Uuid,
28 _caller: Uuid,
29 pull_ref: &str,
30 ) -> Result<pull_requests::Model, PullError> {
31 let pull = crate::share::lookup::find_pull(self, ctx.model.id, pull_ref).await?;
32 let existing = issue_relation_pull::Entity::find()
33 .filter(issue_relation_pull::Column::IssueId.eq(issue_id))
34 .filter(issue_relation_pull::Column::PullRequestId.eq(pull.id))
35 .one(self.db_read())
36 .await?;
37 if existing.is_none() {
38 issue_relation_pull::ActiveModel {
39 id: Set(Uuid::now_v7()),
40 issue_id: Set(issue_id),
41 pull_request_id: Set(pull.id),
42 created_at: Set(OffsetDateTime::now_utc()),
43 ..Default::default()
44 }
45 .insert(self.db_write())
46 .await?;
47 }
48 Ok(pull)
49 }
50
51 pub async fn unlink_issue(
52 &self,
53 ctx: &GitContext,
54 issue_id: Uuid,
55 relation_id: Uuid,
56 ) -> Result<(), PullError> {
57 let deleted = issue_relation_pull::Entity::delete_many()
58 .filter(issue_relation_pull::Column::Id.eq(relation_id))
59 .filter(issue_relation_pull::Column::IssueId.eq(issue_id))
60 .exec(self.db_write())
61 .await?;
62 if deleted.rows_affected == 0 {
63 return Err(PullError::NotFound);
64 }
65 let _ = ctx;
66 Ok(())
67 }
68
69 pub async fn list_linked_pulls(
70 &self,
71 issue_id: Uuid,
72 space_id: Uuid,
73 ) -> Result<Vec<PullDto>, PullError> {
74 let links = issue_relation_pull::Entity::find()
75 .filter(issue_relation_pull::Column::IssueId.eq(issue_id))
76 .order_by_asc(issue_relation_pull::Column::CreatedAt)
77 .all(self.db_read())
78 .await?;
79 if links.is_empty() {
80 return Ok(Vec::new());
81 }
82 let ids: Vec<Uuid> = links.iter().map(|link| link.pull_request_id).collect();
83 let rows = pull_requests::Entity::find()
84 .filter(pull_requests::Column::Id.is_in(ids))
85 .all(self.db_read())
86 .await?;
87 let mut visible: Vec<pull_requests::Model> = Vec::with_capacity(rows.len());
88 for row in rows {
89 let repo = models::repo::repos::Entity::find_by_id(row.repo_id)
90 .one(self.db_read())
91 .await?;
92 let show = match repo {
93 None => false,
94 Some(repo) if !repo.is_private => true,
95 Some(repo) => git::share::access::space_for_dir(self.db_read(), repo.dir_id)
96 .await?
97 .is_some_and(|space| space.id == space_id),
98 };
99 if show {
100 visible.push(row);
101 }
102 }
103 let users = self.pull_users(&visible).await?;
104 Ok(visible
105 .iter()
106 .map(|row| PullService::dto(row, &users))
107 .collect())
108 }
109}
Added lib/pulls/src/services/reviewers.rs View lib/pulls/src/services/reviewers.rs
@@ -0,0 +1,250 @@
1use models::pull_request::{pull_request_review, pull_request_reviewer, pull_requests};
2use sea_orm::{
3 ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter, QueryOrder,
4};
5use serde::{Deserialize, Serialize};
6use space::services::spaces::UserDto;
7use time::OffsetDateTime;
8use utoipa::ToSchema;
9use uuid::Uuid;
10
11use crate::errors::PullError;
12use crate::services::events::{
13 EVENT_REVIEW_REQUEST_CANCELLED, EVENT_REVIEW_REQUESTED, write_event,
14};
15use crate::services::reviews::unique_ids;
16use crate::services::service::PullService;
17use crate::share::authz::require_open;
18use models::pull_request::pull_request_review::ReviewState;
19use models::pull_request::pull_request_reviewer::ReviewerState;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ReviewerOutcome {
23 Approved,
24 ChangesRequested,
25 Commented,
26 Dismissed,
27 Requested,
28}
29
30impl ReviewerOutcome {
31 pub fn from_state(state: ReviewState) -> Self {
32 match state {
33 ReviewState::Approved => Self::Approved,
34 ReviewState::ChangesRequested => Self::ChangesRequested,
35 ReviewState::Commented => Self::Commented,
36 ReviewState::Dismissed => Self::Dismissed,
37 ReviewState::Pending => Self::Requested,
38 }
39 }
40
41 fn to_reviewer_state(self) -> ReviewerState {
42 match self {
43 Self::Approved => ReviewerState::Approved,
44 Self::ChangesRequested => ReviewerState::ChangesRequested,
45 Self::Commented => ReviewerState::Commented,
46 Self::Dismissed => ReviewerState::Dismissed,
47 Self::Requested => ReviewerState::Requested,
48 }
49 }
50}
51
52#[derive(Debug, Clone, Deserialize, ToSchema)]
53pub struct RequestReviewersCmd {
54 /// 用户名或 UUID。
55 pub reviewers: Vec<String>,
56 /// 是否计入必需审批人数。
57 #[serde(default = "default_required")]
58 pub required: bool,
59}
60
61fn default_required() -> bool {
62 true
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
66pub struct PullReviewerDto {
67 pub id: Uuid,
68 pub user: Option<UserDto>,
69 pub state: ReviewerState,
70 pub required: bool,
71 pub review_id: Option<Uuid>,
72 pub requested_by: Option<UserDto>,
73 pub created_at: OffsetDateTime,
74 pub updated_at: Option<OffsetDateTime>,
75}
76
77impl PullService {
78 pub async fn request_reviewers(
79 &self,
80 pull: &pull_requests::Model,
81 caller: Uuid,
82 cmd: RequestReviewersCmd,
83 ) -> Result<Vec<PullReviewerDto>, PullError> {
84 require_open(pull)?;
85 if cmd.reviewers.is_empty() {
86 return Err(PullError::BadRequest(
87 "reviewers must not be empty".to_string(),
88 ));
89 }
90 let mut added: Vec<Uuid> = Vec::new();
91 for reference in &cmd.reviewers {
92 let uid = self.resolve_user_id(reference).await?;
93 if uid == pull.author_id {
94 return Err(PullError::BadRequest(
95 "the pull request author cannot be a requested reviewer".to_string(),
96 ));
97 }
98 let existing = pull_request_reviewer::Entity::find()
99 .filter(pull_request_reviewer::Column::PullRequestId.eq(pull.id))
100 .filter(pull_request_reviewer::Column::UserId.eq(uid))
101 .one(self.db_read())
102 .await?;
103 match existing {
104 Some(row) => {
105 let mut model: pull_request_reviewer::ActiveModel = row.into();
106 model.state = Set(ReviewerState::Requested);
107 model.required = Set(cmd.required);
108 model.requested_by_id = Set(caller);
109 model.review_id = Set(None);
110 model.updated_at = Set(Some(OffsetDateTime::now_utc()));
111 model.update(self.db_write()).await?;
112 }
113 None => {
114 pull_request_reviewer::ActiveModel {
115 id: Set(Uuid::now_v7()),
116 pull_request_id: Set(pull.id),
117 user_id: Set(uid),
118 state: Set(ReviewerState::Requested),
119 required: Set(cmd.required),
120 review_id: Set(None),
121 requested_by_id: Set(caller),
122 created_at: Set(OffsetDateTime::now_utc()),
123 updated_at: Set(None),
124 ..Default::default()
125 }
126 .insert(self.db_write())
127 .await?;
128 }
129 }
130 added.push(uid);
131 }
132 write_event(
133 self.db_write(),
134 pull,
135 Some(caller),
136 EVENT_REVIEW_REQUESTED,
137 None,
138 Some(
139 added
140 .iter()
141 .map(|id| id.to_string())
142 .collect::<Vec<_>>()
143 .join(","),
144 ),
145 )
146 .await?;
147 self.list_reviewers(pull.id).await
148 }
149
150 pub async fn cancel_reviewer_request(
151 &self,
152 pull: &pull_requests::Model,
153 caller: Uuid,
154 reviewer_id: Uuid,
155 ) -> Result<Vec<PullReviewerDto>, PullError> {
156 let row = pull_request_reviewer::Entity::find()
157 .filter(pull_request_reviewer::Column::PullRequestId.eq(pull.id))
158 .filter(pull_request_reviewer::Column::UserId.eq(reviewer_id))
159 .one(self.db_read())
160 .await?
161 .ok_or(PullError::NotFound)?;
162 pull_request_reviewer::Entity::delete_by_id(row.id)
163 .exec(self.db_write())
164 .await?;
165 write_event(
166 self.db_write(),
167 pull,
168 Some(caller),
169 EVENT_REVIEW_REQUEST_CANCELLED,
170 None,
171 Some(reviewer_id.to_string()),
172 )
173 .await?;
174 self.list_reviewers(pull.id).await
175 }
176
177 pub async fn list_reviewers(&self, pull_id: Uuid) -> Result<Vec<PullReviewerDto>, PullError> {
178 let rows = pull_request_reviewer::Entity::find()
179 .filter(pull_request_reviewer::Column::PullRequestId.eq(pull_id))
180 .order_by_asc(pull_request_reviewer::Column::CreatedAt)
181 .all(self.db_read())
182 .await?;
183 let users = self
184 .user_briefs(&unique_ids(
185 rows.iter()
186 .flat_map(|row| [row.user_id, row.requested_by_id]),
187 ))
188 .await?;
189 Ok(rows
190 .iter()
191 .map(|row| PullReviewerDto {
192 id: row.id,
193 user: users.get(&row.user_id).cloned(),
194 state: row.state,
195 required: row.required,
196 review_id: row.review_id,
197 requested_by: users.get(&row.requested_by_id).cloned(),
198 created_at: row.created_at,
199 updated_at: row.updated_at,
200 })
201 .collect())
202 }
203
204 /// 评审结论落库后同步审阅请求状态。
205 pub async fn sync_reviewer(
206 &self,
207 pull_id: Uuid,
208 user_id: Uuid,
209 outcome: ReviewerOutcome,
210 ) -> Result<(), PullError> {
211 let latest = pull_request_review::Entity::find()
212 .filter(pull_request_review::Column::PullRequestId.eq(pull_id))
213 .filter(pull_request_review::Column::UserId.eq(user_id))
214 .order_by_desc(pull_request_review::Column::CreatedAt)
215 .one(self.db_read())
216 .await?;
217 let row = pull_request_reviewer::Entity::find()
218 .filter(pull_request_reviewer::Column::PullRequestId.eq(pull_id))
219 .filter(pull_request_reviewer::Column::UserId.eq(user_id))
220 .one(self.db_read())
221 .await?;
222 let state = outcome.to_reviewer_state();
223 match row {
224 Some(existing) => {
225 let mut model: pull_request_reviewer::ActiveModel = existing.into();
226 model.state = Set(state);
227 model.review_id = Set(latest.map(|review| review.id));
228 model.updated_at = Set(Some(OffsetDateTime::now_utc()));
229 model.update(self.db_write()).await?;
230 }
231 None => {
232 pull_request_reviewer::ActiveModel {
233 id: Set(Uuid::now_v7()),
234 pull_request_id: Set(pull_id),
235 user_id: Set(user_id),
236 state: Set(state),
237 required: Set(false),
238 review_id: Set(latest.map(|review| review.id)),
239 requested_by_id: Set(user_id),
240 created_at: Set(OffsetDateTime::now_utc()),
241 updated_at: Set(None),
242 ..Default::default()
243 }
244 .insert(self.db_write())
245 .await?;
246 }
247 }
248 Ok(())
249 }
250}
Added lib/pulls/src/services/reviews.rs View lib/pulls/src/services/reviews.rs
@@ -0,0 +1,316 @@
1use git::context::facade::GitContext;
2use models::pull_request::{pull_request_comment, pull_request_review, pull_requests};
3use sea_orm::{
4 ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter,
5 QueryOrder, Set as AmSet, TransactionTrait,
6};
7use serde::{Deserialize, Serialize};
8use space::services::spaces::UserDto;
9use time::OffsetDateTime;
10use utoipa::ToSchema;
11use uuid::Uuid;
12
13use crate::errors::PullError;
14use crate::services::events::{EVENT_REVIEW_DISMISSED, EVENT_REVIEW_SUBMITTED, write_event};
15use crate::services::reviewers::ReviewerOutcome;
16use crate::services::service::PullService;
17use crate::share::authz::require_open;
18use models::pull_request::pull_request_review::ReviewState;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
21#[serde(rename_all = "snake_case")]
22pub enum ReviewIntent {
23 Approve,
24 RequestChanges,
25 Comment,
26 Pending,
27}
28
29fn intent_state(intent: ReviewIntent) -> ReviewState {
30 match intent {
31 ReviewIntent::Approve => ReviewState::Approved,
32 ReviewIntent::RequestChanges => ReviewState::ChangesRequested,
33 ReviewIntent::Comment => ReviewState::Commented,
34 ReviewIntent::Pending => ReviewState::Pending,
35 }
36}
37
38#[derive(Debug, Clone, Deserialize, ToSchema)]
39pub struct InlineDraft {
40 pub path: String,
41 pub line: i32,
42 #[serde(default)]
43 pub side: Option<String>,
44 #[serde(default)]
45 pub body: Option<String>,
46}
47
48#[derive(Debug, Clone, Deserialize, ToSchema)]
49pub struct SubmitReviewCmd {
50 pub state: ReviewIntent,
51 #[serde(default)]
52 pub body: Option<String>,
53 #[serde(default)]
54 pub comments: Vec<InlineDraft>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
58pub struct PullReviewDto {
59 pub id: Uuid,
60 pub pull_request_id: Uuid,
61 pub user: Option<UserDto>,
62 pub state: ReviewState,
63 pub commit_sha: String,
64 pub official: bool,
65 pub content: Option<String>,
66 pub comment_count: u64,
67 pub created_at: OffsetDateTime,
68 pub updated_at: Option<OffsetDateTime>,
69}
70
71impl PullService {
72 pub async fn submit_review(
73 &self,
74 ctx: &GitContext,
75 pull: &pull_requests::Model,
76 caller: Uuid,
77 admin: bool,
78 cmd: SubmitReviewCmd,
79 ) -> Result<pull_request_review::Model, PullError> {
80 require_open(pull)?;
81 if pull.is_locked && !admin {
82 return Err(PullError::Forbidden);
83 }
84 let state = intent_state(cmd.state);
85 if state == ReviewState::Approved && pull.author_id == caller && !admin {
86 return Err(PullError::Forbidden);
87 }
88 for draft in &cmd.comments {
89 if draft.path.trim().is_empty() || draft.line <= 0 {
90 return Err(PullError::BadRequest(
91 "invalid inline comment target".to_string(),
92 ));
93 }
94 }
95 let now = OffsetDateTime::now_utc();
96 let txn = self.state.database.write().begin().await?;
97 let review = pull_request_review::ActiveModel {
98 id: Set(Uuid::now_v7()),
99 pull_request_id: Set(pull.id),
100 user_id: Set(caller),
101 commit_sha: Set(pull.head_sha.clone()),
102 official: Set(state == ReviewState::Approved),
103 content: Set(cmd.body.clone()),
104 state: Set(state),
105 created_at: Set(now),
106 updated_at: Set(None),
107 ..Default::default()
108 }
109 .insert(&txn)
110 .await?;
111 for draft in &cmd.comments {
112 let body = draft.body.clone().unwrap_or_default();
113 if body.trim().is_empty() {
114 continue;
115 }
116 pull_request_comment::ActiveModel {
117 id: Set(Uuid::now_v7()),
118 pull_request_id: Set(pull.id),
119 user_id: Set(caller),
120 review_id: Set(Some(review.id)),
121 parent_comment_id: Set(None),
122 commit_sha: Set(Some(pull.head_sha.clone())),
123 original_commit_sha: Set(Some(pull.head_sha.clone())),
124 path: Set(Some(draft.path.clone())),
125 side: Set(Some(
126 draft.side.clone().unwrap_or_else(|| "right".to_string()),
127 )),
128 line: Set(Some(draft.line)),
129 original_line: Set(Some(draft.line)),
130 body: Set(body),
131 resolved_at: Set(None),
132 resolved_by: Set(None),
133 created_at: Set(now),
134 updated_at: Set(None),
135 ..Default::default()
136 }
137 .insert(&txn)
138 .await?;
139 }
140 if state != ReviewState::Pending {
141 write_event(
142 &txn,
143 pull,
144 Some(caller),
145 EVENT_REVIEW_SUBMITTED,
146 Some(pull.head_sha.clone()),
147 Some(format!("{state:?}")),
148 )
149 .await?;
150 }
151 txn.commit().await?;
152 if state != ReviewState::Pending {
153 self.sync_reviewer(pull.id, caller, ReviewerOutcome::from_state(state))
154 .await?;
155 }
156 let _ = ctx;
157 Ok(review)
158 }
159
160 /// 提交一份待提交(pending)评审,并把其草稿行内评论一并发布。
161 pub async fn publish_pending_review(
162 &self,
163 pull: &pull_requests::Model,
164 review_id: Uuid,
165 caller: Uuid,
166 admin: bool,
167 cmd: SubmitReviewCmd,
168 ) -> Result<pull_request_review::Model, PullError> {
169 require_open(pull)?;
170 if pull.is_locked && !admin {
171 return Err(PullError::Forbidden);
172 }
173 let target = intent_state(cmd.state);
174 if target == ReviewState::Pending {
175 return Err(PullError::BadRequest(
176 "state must not be pending".to_string(),
177 ));
178 }
179 let review = pull_request_review::Entity::find_by_id(review_id)
180 .filter(pull_request_review::Column::PullRequestId.eq(pull.id))
181 .one(self.db_read())
182 .await?
183 .ok_or(PullError::NotFound)?;
184 if review.user_id != caller {
185 return Err(PullError::Forbidden);
186 }
187 if review.state != ReviewState::Pending {
188 return Err(PullError::Conflict("review already submitted".to_string()));
189 }
190 if target == ReviewState::Approved && pull.author_id == caller && !admin {
191 return Err(PullError::Forbidden);
192 }
193 let mut model: pull_request_review::ActiveModel = review.clone().into();
194 model.state = Set(target);
195 model.official = Set(target == ReviewState::Approved);
196 if cmd.body.is_some() {
197 model.content = AmSet(cmd.body.clone());
198 }
199 model.updated_at = Set(Some(OffsetDateTime::now_utc()));
200 let updated = model.update(self.db_write()).await?;
201 self.record_event(
202 pull,
203 Some(caller),
204 EVENT_REVIEW_SUBMITTED,
205 Some(review.commit_sha.clone()),
206 Some(format!("{target:?}")),
207 )
208 .await?;
209 self.sync_reviewer(pull.id, caller, ReviewerOutcome::from_state(target))
210 .await?;
211 Ok(updated)
212 }
213
214 pub async fn dismiss_review(
215 &self,
216 pull: &pull_requests::Model,
217 review_id: Uuid,
218 caller: Uuid,
219 ) -> Result<pull_request_review::Model, PullError> {
220 let review = pull_request_review::Entity::find_by_id(review_id)
221 .filter(pull_request_review::Column::PullRequestId.eq(pull.id))
222 .one(self.db_read())
223 .await?
224 .ok_or(PullError::NotFound)?;
225 if review.state == ReviewState::Dismissed {
226 return Ok(review);
227 }
228 let mut model: pull_request_review::ActiveModel = review.clone().into();
229 model.state = Set(ReviewState::Dismissed);
230 model.official = Set(false);
231 model.updated_at = Set(Some(OffsetDateTime::now_utc()));
232 let updated = model.update(self.db_write()).await?;
233 self.record_event(
234 pull,
235 Some(caller),
236 EVENT_REVIEW_DISMISSED,
237 Some(review.commit_sha.clone()),
238 Some(format!("dismissed review of {:?}", review.user_id)),
239 )
240 .await?;
241 self.sync_reviewer(pull.id, review.user_id, ReviewerOutcome::Dismissed)
242 .await?;
243 Ok(updated)
244 }
245
246 pub async fn list_reviews(
247 &self,
248 pull_id: Uuid,
249 viewer: Option<Uuid>,
250 ) -> Result<Vec<PullReviewDto>, PullError> {
251 let rows = pull_request_review::Entity::find()
252 .filter(pull_request_review::Column::PullRequestId.eq(pull_id))
253 .order_by_asc(pull_request_review::Column::CreatedAt)
254 .all(self.db_read())
255 .await?;
256 let visible: Vec<pull_request_review::Model> = rows
257 .into_iter()
258 .filter(|row| row.state != ReviewState::Pending || Some(row.user_id) == viewer)
259 .collect();
260 self.review_dtos(&visible).await
261 }
262
263 pub async fn get_review(
264 &self,
265 pull_id: Uuid,
266 review_id: Uuid,
267 viewer: Option<Uuid>,
268 ) -> Result<PullReviewDto, PullError> {
269 let row = pull_request_review::Entity::find_by_id(review_id)
270 .filter(pull_request_review::Column::PullRequestId.eq(pull_id))
271 .one(self.db_read())
272 .await?
273 .ok_or(PullError::NotFound)?;
274 if row.state == ReviewState::Pending && Some(row.user_id) != viewer {
275 return Err(PullError::NotFound);
276 }
277 let dtos = self.review_dtos(std::slice::from_ref(&row)).await?;
278 dtos.into_iter().next().ok_or(PullError::NotFound)
279 }
280
281 async fn review_dtos(
282 &self,
283 rows: &[pull_request_review::Model],
284 ) -> Result<Vec<PullReviewDto>, PullError> {
285 let users = self
286 .user_briefs(&unique_ids(rows.iter().map(|row| row.user_id)))
287 .await?;
288 let mut out = Vec::with_capacity(rows.len());
289 for row in rows {
290 let comment_count = pull_request_comment::Entity::find()
291 .filter(pull_request_comment::Column::ReviewId.eq(row.id))
292 .count(self.db_read())
293 .await?;
294 out.push(PullReviewDto {
295 id: row.id,
296 pull_request_id: row.pull_request_id,
297 user: users.get(&row.user_id).cloned(),
298 state: row.state,
299 commit_sha: row.commit_sha.clone(),
300 official: row.official,
301 content: row.content.clone(),
302 comment_count,
303 created_at: row.created_at,
304 updated_at: row.updated_at,
305 });
306 }
307 Ok(out)
308 }
309}
310
311pub(crate) fn unique_ids(iter: impl Iterator<Item = Uuid>) -> Vec<Uuid> {
312 let mut ids: Vec<Uuid> = iter.collect();
313 ids.sort();
314 ids.dedup();
315 ids
316}
Added lib/pulls/src/services/service.rs View lib/pulls/src/services/service.rs
@@ -0,0 +1,60 @@
1use config::AppConfig;
2use export::AppState;
3use git::service::GitService;
4use models::pull_request::pull_requests;
5use models::repo::repos;
6use sea_orm::{
7 ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait,
8 PaginatorTrait, QueryFilter,
9};
10use session::Session;
11use uuid::Uuid;
12
13use crate::errors::PullError;
14
15#[derive(Clone)]
16pub struct PullService {
17 pub state: AppState,
18 pub git: GitService,
19}
20
21impl PullService {
22 pub fn new(state: AppState) -> Self {
23 let git = GitService::new(state.clone());
24 Self { state, git }
25 }
26
27 pub fn config(&self) -> &AppConfig {
28 &self.state.config
29 }
30
31 pub fn db_read(&self) -> &DatabaseConnection {
32 self.state.database.read()
33 }
34
35 pub fn db_write(&self) -> &DatabaseConnection {
36 self.state.database.write()
37 }
38
39 pub fn uid(session: &Session) -> Result<Uuid, PullError> {
40 use session::SessionUser;
41 session.uid().ok_or(PullError::Unauthorized)
42 }
43
44 /// 开放 PR 数量回填 `repos.nums_pr`,状态变更后调用。
45 pub async fn recount_open(&self, repo_id: Uuid) -> Result<(), PullError> {
46 let open = pull_requests::Entity::find()
47 .filter(pull_requests::Column::RepoId.eq(repo_id))
48 .filter(pull_requests::Column::HasMerged.eq(false))
49 .filter(pull_requests::Column::ClosedAt.is_null())
50 .count(self.db_read())
51 .await?;
52 let repo = repos::ActiveModel {
53 id: Set(repo_id),
54 nums_pr: Set(open.cast_signed() as i32),
55 ..Default::default()
56 };
57 repo.update(self.db_write()).await?;
58 Ok(())
59 }
60}
Added lib/pulls/src/share/authz.rs View lib/pulls/src/share/authz.rs
@@ -0,0 +1,42 @@
1use models::pull_request::pull_requests;
2use uuid::Uuid;
3
4use crate::errors::PullError;
5
6pub fn is_author(pull: &pull_requests::Model, uid: Option<Uuid>) -> bool {
7 uid.is_some_and(|uid| uid == pull.author_id)
8}
9
10pub fn require_open(pull: &pull_requests::Model) -> Result<(), PullError> {
11 if pull.has_merged {
12 return Err(PullError::Conflict(
13 "pull request already merged".to_string(),
14 ));
15 }
16 if pull.closed_at.is_some() {
17 return Err(PullError::Conflict("pull request is closed".to_string()));
18 }
19 Ok(())
20}
21
22pub fn require_unlocked(pull: &pull_requests::Model) -> Result<(), PullError> {
23 if pull.is_locked {
24 return Err(PullError::Conflict(
25 "pull request is locked; only repository administrators may act".to_string(),
26 ));
27 }
28 Ok(())
29}
30
31/// 作者或有写权限的管理员可以改标题/正文/关闭/合并。
32pub fn require_can_manage(
33 pull: &pull_requests::Model,
34 caller: Option<Uuid>,
35 admin: bool,
36) -> Result<Uuid, PullError> {
37 let uid = caller.ok_or(PullError::Unauthorized)?;
38 if admin || is_author(pull, Some(uid)) {
39 return Ok(uid);
40 }
41 Err(PullError::Forbidden)
42}
Added lib/pulls/src/share/lookup.rs View lib/pulls/src/share/lookup.rs
@@ -0,0 +1,107 @@
1use git::context::facade::GitContext;
2use git::share::access::authorize_repo_access;
3use models::pull_request::pull_requests;
4use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
5use session::{Session, SessionUser};
6use uuid::Uuid;
7
8use crate::errors::PullError;
9use crate::services::service::PullService;
10
11#[derive(Clone)]
12pub struct PullHandle {
13 pub ctx: GitContext,
14 pub pull: pull_requests::Model,
15 pub caller: Option<Uuid>,
16 pub admin: bool,
17}
18
19pub async fn repo_read(
20 svc: &PullService,
21 session: &Session,
22 space_name: String,
23 repo_name: String,
24) -> Result<(GitContext, Option<Uuid>), PullError> {
25 let ctx = svc.git.context(space_name, repo_name).await?;
26 if ctx.model.is_private {
27 let uid = PullService::uid(session)?;
28 authorize_repo_access(&svc.git, uid, &ctx, false, false).await?;
29 return Ok((ctx, Some(uid)));
30 }
31 Ok((ctx, session.uid()))
32}
33
34pub async fn repo_write(
35 svc: &PullService,
36 session: &Session,
37 space_name: String,
38 repo_name: String,
39) -> Result<(GitContext, Uuid), PullError> {
40 let uid = PullService::uid(session)?;
41 let ctx = svc.git.context(space_name, repo_name).await?;
42 authorize_repo_access(&svc.git, uid, &ctx, true, false).await?;
43 Ok((ctx, uid))
44}
45
46pub async fn is_admin(svc: &PullService, uid: Uuid, ctx: &GitContext) -> bool {
47 authorize_repo_access(&svc.git, uid, ctx, true, true)
48 .await
49 .is_ok()
50}
51
52/// 按 PR 编号(`123`)或 UUID 在仓库内定位合并请求。
53pub async fn find_pull(
54 svc: &PullService,
55 repo_id: Uuid,
56 pull_ref: &str,
57) -> Result<pull_requests::Model, PullError> {
58 let trimmed = pull_ref.trim();
59 if trimmed.is_empty() {
60 return Err(PullError::BadRequest("empty pull reference".to_string()));
61 }
62 let row = if let Ok(number) = trimmed.parse::<i32>() {
63 pull_requests::Entity::find()
64 .filter(pull_requests::Column::RepoId.eq(repo_id))
65 .filter(pull_requests::Column::AutoincrementId.eq(number))
66 .one(svc.db_read())
67 .await?
68 } else if let Ok(id) = Uuid::parse_str(trimmed) {
69 pull_requests::Entity::find()
70 .filter(pull_requests::Column::RepoId.eq(repo_id))
71 .filter(pull_requests::Column::Id.eq(id))
72 .one(svc.db_read())
73 .await?
74 } else {
75 return Err(PullError::BadRequest(
76 "pull reference must be a number or a uuid".to_string(),
77 ));
78 };
79 row.ok_or(PullError::NotFound)
80}
81
82pub async fn pull_handle(
83 svc: &PullService,
84 session: &Session,
85 space_name: String,
86 repo_name: String,
87 pull_ref: String,
88 write: bool,
89) -> Result<PullHandle, PullError> {
90 let (ctx, caller) = if write {
91 let (ctx, uid) = repo_write(svc, session, space_name, repo_name).await?;
92 (ctx, Some(uid))
93 } else {
94 repo_read(svc, session, space_name, repo_name).await?
95 };
96 let pull = find_pull(svc, ctx.model.id, &pull_ref).await?;
97 let admin = match caller {
98 Some(uid) => is_admin(svc, uid, &ctx).await,
99 None => false,
100 };
101 Ok(PullHandle {
102 ctx,
103 pull,
104 caller,
105 admin,
106 })
107}
Added lib/pulls/src/share/mod.rs View lib/pulls/src/share/mod.rs
@@ -0,0 +1,3 @@
1pub mod authz;
2pub mod lookup;
3pub mod path;
Added lib/pulls/src/share/path.rs View lib/pulls/src/share/path.rs
@@ -0,0 +1,33 @@
1use axum::extract::{FromRequestParts, Path};
2use axum::http::request::Parts;
3use serde::Deserialize;
4use utoipa::IntoParams;
5
6use crate::errors::ApiError;
7
8#[derive(Debug, Clone, Deserialize, IntoParams)]
9pub struct PullPath {
10 pub space_name: String,
11 pub repo_name: String,
12 /// PR 编号(`12`)或 UUID。
13 pub pull_ref: String,
14}
15
16impl<S> FromRequestParts<S> for PullPath
17where
18 S: Send + Sync,
19{
20 type Rejection = ApiError;
21
22 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
23 Path::<PullPath>::from_request_parts(parts, state)
24 .await
25 .map(|Path(p)| p)
26 .map_err(|_| {
27 ApiError::BadRequest(export::rest::AppResponseData::err(
28 400,
29 "invalid request path",
30 ))
31 })
32 }
33}
Added lib/pulls/src/errors.rs View lib/pulls/src/errors.rs
@@ -0,0 +1,209 @@
1use std::fmt;
2
3use axum::Json;
4use axum::extract::rejection::{JsonRejection, PathRejection, QueryRejection};
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use export::rest::{AppResponseData, NoData};
8use utoipa::IntoResponses;
9
10#[derive(Debug)]
11pub enum PullError {
12 BadRequest(String),
13 Unauthorized,
14 Forbidden,
15 NotFound,
16 Conflict(String),
17 RateLimited(String),
18 Internal(String),
19}
20
21impl fmt::Display for PullError {
22 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23 match self {
24 Self::BadRequest(message) => write!(f, "{message}"),
25 Self::Unauthorized => write!(f, "unauthorized"),
26 Self::Forbidden => write!(f, "forbidden"),
27 Self::NotFound => write!(f, "not found"),
28 Self::Conflict(message) => write!(f, "{message}"),
29 Self::RateLimited(message) => write!(f, "{message}"),
30 Self::Internal(message) => write!(f, "{message}"),
31 }
32 }
33}
34
35impl std::error::Error for PullError {}
36
37fn is_unique_violation(err: &sea_orm::DbErr) -> bool {
38 let msg = err.to_string().to_lowercase();
39 msg.contains("unique") || msg.contains("duplicate") || msg.contains("23505")
40}
41
42impl From<sea_orm::DbErr> for PullError {
43 fn from(error: sea_orm::DbErr) -> Self {
44 if is_unique_violation(&error) {
45 return Self::Conflict("resource already exists".to_string());
46 }
47 Self::Internal(format!("database error: {error}"))
48 }
49}
50
51impl From<sea_orm::TransactionError<sea_orm::DbErr>> for PullError {
52 fn from(error: sea_orm::TransactionError<sea_orm::DbErr>) -> Self {
53 match &error {
54 sea_orm::TransactionError::Transaction(db_err) if is_unique_violation(db_err) => {
55 Self::Conflict("resource already exists".to_string())
56 }
57 _ => Self::Internal(format!("database transaction error: {error}")),
58 }
59 }
60}
61
62impl From<sea_orm::TransactionError<PullError>> for PullError {
63 fn from(error: sea_orm::TransactionError<PullError>) -> Self {
64 match error {
65 sea_orm::TransactionError::Connection(db_err) => Self::from(db_err),
66 sea_orm::TransactionError::Transaction(pull_err) => pull_err,
67 }
68 }
69}
70
71impl From<git::share::errors::GitError> for PullError {
72 fn from(error: git::share::errors::GitError) -> Self {
73 use git::share::errors::GitError as G;
74 match error {
75 G::InvalidArgument(msg) => Self::BadRequest(msg),
76 G::NotFound => Self::NotFound,
77 G::Unauthorized => Self::Unauthorized,
78 G::Forbidden => Self::Forbidden,
79 G::Conflict(msg) => Self::Conflict(msg),
80 G::Cache(_) | G::Git(_) | G::Internal(_) => Self::Internal(error.to_string()),
81 }
82 }
83}
84
85impl From<issues::errors::IssueError> for PullError {
86 fn from(error: issues::errors::IssueError) -> Self {
87 match error {
88 issues::errors::IssueError::BadRequest(msg) => Self::BadRequest(msg),
89 issues::errors::IssueError::Unauthorized => Self::Unauthorized,
90 issues::errors::IssueError::Forbidden => Self::Forbidden,
91 issues::errors::IssueError::NotFound => Self::NotFound,
92 issues::errors::IssueError::Conflict(msg) => Self::Conflict(msg),
93 issues::errors::IssueError::RateLimited(msg) => Self::RateLimited(msg),
94 issues::errors::IssueError::Internal(msg) => Self::Internal(msg),
95 }
96 }
97}
98
99impl From<export::ExportError> for PullError {
100 fn from(error: export::ExportError) -> Self {
101 Self::Internal(format!("infrastructure error: {error}"))
102 }
103}
104
105impl From<serde_json::Error> for PullError {
106 fn from(error: serde_json::Error) -> Self {
107 Self::BadRequest(format!("invalid payload: {error}"))
108 }
109}
110
111#[derive(Debug, IntoResponses)]
112pub enum ApiError {
113 #[response(status = 400, description = "request failed validation")]
114 BadRequest(#[to_schema] AppResponseData<NoData>),
115 #[response(status = 401, description = "authentication required")]
116 Unauthorized(#[to_schema] AppResponseData<NoData>),
117 #[response(status = 403, description = "operation not allowed")]
118 Forbidden(#[to_schema] AppResponseData<NoData>),
119 #[response(status = 404, description = "resource not found")]
120 NotFound(#[to_schema] AppResponseData<NoData>),
121 #[response(status = 409, description = "resource conflict")]
122 Conflict(#[to_schema] AppResponseData<NoData>),
123 #[response(status = 429, description = "rate limit exceeded")]
124 TooManyRequests(#[to_schema] AppResponseData<NoData>),
125 #[response(status = 500, description = "internal server error")]
126 Internal(#[to_schema] AppResponseData<NoData>),
127}
128
129impl ApiError {
130 fn payload(code: i32, message: &'static str) -> AppResponseData<NoData> {
131 AppResponseData::err(code, message)
132 }
133
134 fn decompose(self) -> (u16, AppResponseData<NoData>) {
135 match self {
136 Self::BadRequest(envelope) => (400, envelope),
137 Self::Unauthorized(envelope) => (401, envelope),
138 Self::Forbidden(envelope) => (403, envelope),
139 Self::NotFound(envelope) => (404, envelope),
140 Self::Conflict(envelope) => (409, envelope),
141 Self::TooManyRequests(envelope) => (429, envelope),
142 Self::Internal(envelope) => (500, envelope),
143 }
144 }
145}
146
147impl IntoResponse for ApiError {
148 fn into_response(self) -> Response {
149 let (code, envelope) = self.decompose();
150 let status =
151 StatusCode::from_u16(code).unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR);
152 (status, Json(envelope)).into_response()
153 }
154}
155
156impl From<PullError> for ApiError {
157 fn from(error: PullError) -> Self {
158 match error {
159 PullError::BadRequest(msg) => {
160 Self::BadRequest(AppResponseData::err(400, msg).with_error_code("bad_request"))
161 }
162 PullError::Unauthorized => Self::Unauthorized(
163 Self::payload(401, "unauthorized").with_error_code("unauthorized"),
164 ),
165 PullError::Forbidden => {
166 Self::Forbidden(Self::payload(403, "forbidden").with_error_code("forbidden"))
167 }
168 PullError::NotFound => Self::NotFound(
169 Self::payload(404, "pull request not found").with_error_code("not_found"),
170 ),
171 PullError::Conflict(msg) => {
172 Self::Conflict(AppResponseData::err(409, msg).with_error_code("merge_blocked"))
173 }
174 PullError::RateLimited(msg) => Self::TooManyRequests(
175 AppResponseData::err(429, msg).with_error_code("rate_limited"),
176 ),
177 PullError::Internal(message) => {
178 tracing::warn!(error = %message, "pulls handler failed");
179 Self::Internal(Self::payload(500, "internal server error"))
180 }
181 }
182 }
183}
184
185impl From<PathRejection> for ApiError {
186 fn from(error: PathRejection) -> Self {
187 Self::BadRequest(
188 AppResponseData::err(400, "invalid request path").with_error(error.body_text()),
189 )
190 }
191}
192
193impl From<JsonRejection> for ApiError {
194 fn from(error: JsonRejection) -> Self {
195 Self::BadRequest(
196 AppResponseData::err(400, "invalid request body")
197 .with_error(error.body_text())
198 .with_error_code("invalid_request_body"),
199 )
200 }
201}
202
203impl From<QueryRejection> for ApiError {
204 fn from(error: QueryRejection) -> Self {
205 Self::BadRequest(
206 AppResponseData::err(400, "invalid query parameters").with_error(error.body_text()),
207 )
208 }
209}
Added lib/pulls/src/lib.rs View lib/pulls/src/lib.rs
@@ -0,0 +1,6 @@
1pub mod errors;
2pub mod handlers;
3pub mod openapi;
4pub mod routes;
5pub mod services;
6pub mod share;
Added lib/pulls/src/openapi.rs View lib/pulls/src/openapi.rs
@@ -0,0 +1,114 @@
1use utoipa::OpenApi;
2
3use export::rest::{AppResponseData, NoData};
4
5#[derive(OpenApi)]
6#[openapi(
7 info(title = "gitdata pulls api", version = "1.0"),
8 paths(
9 crate::handlers::pulls::list_pulls::list_pulls,
10 crate::handlers::pulls::create_pull::create_pull,
11 crate::handlers::pulls::get_pull::get_pull,
12 crate::handlers::pulls::update_pull::update_pull,
13 crate::handlers::pulls::delete_pull::delete_pull,
14 crate::handlers::pulls::close_pull::close_pull,
15 crate::handlers::pulls::reopen_pull::reopen_pull,
16 crate::handlers::pulls::merge_pull::merge_pull,
17 crate::handlers::pulls::list_pull_commits::list_pull_commits,
18 crate::handlers::pulls::get_pull_files::get_pull_files,
19 crate::handlers::pulls::get_pull_patch::get_pull_patch,
20 crate::handlers::pulls::refresh_pull_summary::refresh_pull_summary,
21 crate::handlers::pulls::list_pull_events::list_pull_events,
22 crate::handlers::pulls::lock_pull::lock_pull,
23 crate::handlers::pulls::lock_pull::unlock_pull,
24 crate::handlers::pulls::update_branch_pull::update_branch_pull,
25 crate::handlers::reviews::submit_review::submit_review,
26 crate::handlers::reviews::list_reviews::list_reviews,
27 crate::handlers::reviews::get_review::get_review,
28 crate::handlers::reviews::publish_review::publish_review,
29 crate::handlers::reviews::dismiss_review::dismiss_review,
30 crate::handlers::comments::create_pull_comment::create_pull_comment,
31 crate::handlers::comments::list_pull_comments::list_pull_comments,
32 crate::handlers::comments::list_pull_comment_tree::list_pull_comment_tree,
33 crate::handlers::comments::update_pull_comment::update_pull_comment,
34 crate::handlers::comments::delete_pull_comment::delete_pull_comment,
35 crate::handlers::comments::resolve_pull_comment::resolve_pull_comment,
36 crate::handlers::comments::resolve_pull_comment::unresolve_pull_comment,
37 crate::handlers::reviewers::list_requested_reviewers::list_requested_reviewers,
38 crate::handlers::reviewers::request_reviewers::request_reviewers,
39 crate::handlers::reviewers::delete_requested_reviewer::delete_requested_reviewer,
40 crate::handlers::labels::list_pull_labels::list_pull_labels,
41 crate::handlers::labels::attach_pull_label::attach_pull_label,
42 crate::handlers::labels::detach_pull_label::detach_pull_label,
43 crate::handlers::assignees::list_pull_assignees::list_pull_assignees,
44 crate::handlers::assignees::add_pull_assignee::add_pull_assignee,
45 crate::handlers::assignees::remove_pull_assignee::remove_pull_assignee,
46 crate::handlers::checks::get_pull_checks::get_pull_checks,
47 crate::handlers::checks::get_pull_gate::get_pull_gate,
48 crate::handlers::inbox::list_my_pulls,
49 crate::handlers::relations::list_issue_pulls::list_issue_pulls,
50 crate::handlers::relations::link_issue_pull::link_issue_pull,
51 crate::handlers::relations::unlink_issue_pull::unlink_issue_pull,
52 ),
53 components(schemas(
54 AppResponseData<NoData>,
55 AppResponseData<crate::services::pulls::PullDto>,
56 AppResponseData<crate::services::pulls::PullPage>,
57 AppResponseData<crate::services::comments::CommentPage>,
58 AppResponseData<crate::services::events::EventPage>,
59 AppResponseData<Vec<git::service::merge::MergeCommitDto>>,
60 AppResponseData<Vec<git::service::diffs::ChangedPathDiffDto>>,
61 AppResponseData<crate::services::changes::PullSummaryDto>,
62 AppResponseData<crate::services::merge::MergePullDto>,
63 AppResponseData<crate::handlers::pulls::update_branch_pull::UpdateBranchDto>,
64 AppResponseData<Vec<crate::services::reviews::PullReviewDto>>,
65 AppResponseData<crate::services::reviews::PullReviewDto>,
66 AppResponseData<crate::services::comments::PullCommentDto>,
67 AppResponseData<Vec<crate::services::comments::PullCommentThreadDto>>,
68 AppResponseData<Vec<crate::services::reviewers::PullReviewerDto>>,
69 AppResponseData<Vec<issues::services::labels::LabelDto>>,
70 AppResponseData<Vec<crate::services::assignees::PullAssigneeDto>>,
71 AppResponseData<crate::services::checks::PullChecksDto>,
72 AppResponseData<crate::services::checks::PullGateDto>,
73 AppResponseData<crate::services::inbox::InboxPage>,
74 NoData,
75 space::services::spaces::UserDto,
76 issues::services::labels::LabelDto,
77 crate::services::pulls::PullDto,
78 crate::services::pulls::PullRefDto,
79 crate::services::pulls::PullState,
80 crate::services::pulls::CreatePullCmd,
81 crate::services::pulls::UpdatePullCmd,
82 crate::services::pulls::LinkIssueCmd,
83 crate::services::merge::MergePullCmd,
84 crate::services::merge::MergePullDto,
85 crate::services::changes::PullSummaryDto,
86 crate::services::events::PullEventDto,
87 crate::services::reviews::PullReviewDto,
88 crate::services::reviews::ReviewIntent,
89 crate::services::reviews::SubmitReviewCmd,
90 crate::services::reviews::InlineDraft,
91 crate::services::comments::PullCommentDto,
92 crate::services::comments::PullCommentThreadDto,
93 crate::services::comments::CreatePullCommentCmd,
94 crate::services::comments::UpdatePullCommentCmd,
95 crate::services::reviewers::PullReviewerDto,
96 crate::services::reviewers::RequestReviewersCmd,
97 crate::services::labels::AttachLabelCmd,
98 crate::services::assignees::PullAssigneeDto,
99 crate::services::assignees::AssignCmd,
100 crate::services::checks::PullChecksDto,
101 crate::services::checks::PullGateDto,
102 crate::services::approvals::MergeGateDto,
103 crate::handlers::pulls::update_branch_pull::UpdateBranchDto,
104 export::pagination::Page<crate::services::pulls::PullDto>,
105 export::pagination::Page<crate::services::events::PullEventDto>,
106 export::pagination::Page<crate::services::comments::PullCommentDto>,
107 git::service::merge::MergeCommitDto,
108 git::service::merge::MergeResultDto,
109 models::pull_request::pull_requests::MergeMethod,
110 models::pull_request::pull_request_review::ReviewState,
111 models::pull_request::pull_request_reviewer::ReviewerState,
112 ))
113)]
114pub struct PullsApiDoc;
Added lib/pulls/src/routes.rs View lib/pulls/src/routes.rs
@@ -0,0 +1,124 @@
1use axum::Router;
2use axum::routing::{get, post, put};
3
4use crate::handlers::assignees::add_pull_assignee::add_pull_assignee;
5use crate::handlers::assignees::list_pull_assignees::list_pull_assignees;
6use crate::handlers::assignees::remove_pull_assignee::remove_pull_assignee;
7use crate::handlers::checks::get_pull_checks::get_pull_checks;
8use crate::handlers::checks::get_pull_gate::get_pull_gate;
9use crate::handlers::comments::create_pull_comment::create_pull_comment;
10use crate::handlers::comments::delete_pull_comment::delete_pull_comment;
11use crate::handlers::comments::list_pull_comment_tree::list_pull_comment_tree;
12use crate::handlers::comments::list_pull_comments::list_pull_comments;
13use crate::handlers::comments::resolve_pull_comment::{
14 resolve_pull_comment, unresolve_pull_comment,
15};
16use crate::handlers::comments::update_pull_comment::update_pull_comment;
17use crate::handlers::inbox::list_my_pulls;
18use crate::handlers::labels::attach_pull_label::attach_pull_label;
19use crate::handlers::labels::detach_pull_label::detach_pull_label;
20use crate::handlers::labels::list_pull_labels::list_pull_labels;
21use crate::handlers::pulls::close_pull::close_pull;
22use crate::handlers::pulls::create_pull::create_pull;
23use crate::handlers::pulls::delete_pull::delete_pull;
24use crate::handlers::pulls::get_pull::get_pull;
25use crate::handlers::pulls::get_pull_files::get_pull_files;
26use crate::handlers::pulls::get_pull_patch::get_pull_patch;
27use crate::handlers::pulls::list_pull_commits::list_pull_commits;
28use crate::handlers::pulls::list_pull_events::list_pull_events;
29use crate::handlers::pulls::list_pulls::list_pulls;
30use crate::handlers::pulls::lock_pull::{lock_pull, unlock_pull};
31use crate::handlers::pulls::merge_pull::merge_pull;
32use crate::handlers::pulls::refresh_pull_summary::refresh_pull_summary;
33use crate::handlers::pulls::reopen_pull::reopen_pull;
34use crate::handlers::pulls::update_branch_pull::update_branch_pull;
35use crate::handlers::pulls::update_pull::update_pull;
36use crate::handlers::relations::link_issue_pull::link_issue_pull;
37use crate::handlers::relations::list_issue_pulls::list_issue_pulls;
38use crate::handlers::relations::unlink_issue_pull::unlink_issue_pull;
39use crate::handlers::reviewers::delete_requested_reviewer::delete_requested_reviewer;
40use crate::handlers::reviewers::list_requested_reviewers::list_requested_reviewers;
41use crate::handlers::reviewers::request_reviewers::request_reviewers;
42use crate::handlers::reviews::dismiss_review::dismiss_review;
43use crate::handlers::reviews::get_review::get_review;
44use crate::handlers::reviews::list_reviews::list_reviews;
45use crate::handlers::reviews::publish_review::publish_review;
46use crate::handlers::reviews::submit_review::submit_review;
47
48const PULLS: &str = "/repos/{space_name}/{repo_name}/pulls";
49const ITEM: &str = "/repos/{space_name}/{repo_name}/pulls/{pull_ref}";
50const ISSUE_PULLS: &str = "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/pulls";
51
52pub fn router() -> Router<crate::services::service::PullService> {
53 Router::new()
54 .route(PULLS, get(list_pulls).post(create_pull))
55 .route(ITEM, get(get_pull).patch(update_pull).delete(delete_pull))
56 .route(&format!("{ITEM}/close"), post(close_pull))
57 .route(&format!("{ITEM}/reopen"), post(reopen_pull))
58 .route(&format!("{ITEM}/merge"), post(merge_pull))
59 .route(&format!("{ITEM}/commits"), get(list_pull_commits))
60 .route(&format!("{ITEM}/files"), get(get_pull_files))
61 .route(&format!("{ITEM}/patch"), get(get_pull_patch))
62 .route(&format!("{ITEM}/summary"), post(refresh_pull_summary))
63 .route(&format!("{ITEM}/events"), get(list_pull_events))
64 .route(&format!("{ITEM}/checks"), get(get_pull_checks))
65 .route(&format!("{ITEM}/gate"), get(get_pull_gate))
66 .route(&format!("{ITEM}/lock"), put(lock_pull).delete(unlock_pull))
67 .route(&format!("{ITEM}/update-branch"), post(update_branch_pull))
68 .route(
69 &format!("{ITEM}/reviews"),
70 get(list_reviews).post(submit_review),
71 )
72 .route(&format!("{ITEM}/reviews/{{review_id}}"), get(get_review))
73 .route(
74 &format!("{ITEM}/reviews/{{review_id}}/submit"),
75 post(publish_review),
76 )
77 .route(
78 &format!("{ITEM}/reviews/{{review_id}}/dismiss"),
79 put(dismiss_review),
80 )
81 .route(
82 &format!("{ITEM}/comments"),
83 get(list_pull_comments).post(create_pull_comment),
84 )
85 .route(
86 &format!("{ITEM}/comments/tree"),
87 get(list_pull_comment_tree),
88 )
89 .route(
90 &format!("{ITEM}/comments/{{comment_id}}"),
91 axum::routing::patch(update_pull_comment).delete(delete_pull_comment),
92 )
93 .route(
94 &format!("{ITEM}/comments/{{comment_id}}/resolve"),
95 put(resolve_pull_comment).delete(unresolve_pull_comment),
96 )
97 .route(
98 &format!("{ITEM}/requested-reviewers"),
99 get(list_requested_reviewers).post(request_reviewers),
100 )
101 .route(
102 &format!("{ITEM}/requested-reviewers/{{user_id}}"),
103 axum::routing::delete(delete_requested_reviewer),
104 )
105 .route(
106 &format!("{ITEM}/labels"),
107 get(list_pull_labels).post(attach_pull_label),
108 )
109 .route(
110 &format!("{ITEM}/labels/{{label_ref}}"),
111 axum::routing::delete(detach_pull_label),
112 )
113 .route(&format!("{ITEM}/assignees"), get(list_pull_assignees))
114 .route(
115 &format!("{ITEM}/assignees/{{user_ref}}"),
116 post(add_pull_assignee).delete(remove_pull_assignee),
117 )
118 .route("/pulls/@me/{scope}", get(list_my_pulls))
119 .route(ISSUE_PULLS, get(list_issue_pulls).post(link_issue_pull))
120 .route(
121 &format!("{ISSUE_PULLS}/{{relation_id}}"),
122 axum::routing::delete(unlink_issue_pull),
123 )
124}
Added lib/pulls/Cargo.toml View lib/pulls/Cargo.toml
@@ -0,0 +1,22 @@
1[package]
2name = "pulls"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7config = { workspace = true }
8export = { workspace = true }
9git = { workspace = true }
10issues = { workspace = true }
11models = { workspace = true }
12session = { workspace = true }
13space = { workspace = true }
14axum = { workspace = true }
15sea-orm = { workspace = true }
16serde = { workspace = true, features = ["derive"] }
17serde_json = { workspace = true }
18utoipa = { workspace = true }
19uuid = { workspace = true }
20time = { workspace = true }
21tracing = { workspace = true }
22globset = { workspace = true }
Modified lib/webapi/src/lib.rs View lib/webapi/src/lib.rs
@@ -52,6 +52,7 @@
5252 let space = space::services::service::SpaceService::new(state.clone());
5353 let user = user::services::service::UserService::new(state.clone());
5454 let git = git::service::GitService::new(state.clone());
55 let pulls = pulls::services::service::PullService::new(state.clone());
5556 let health_state = state;
5657
5758 let public: Router = Router::new()
@@ -60,7 +61,8 @@
6061 .merge(issues::routes::router(issues.clone()).with_state(issues))
6162 .merge(space::routes::router(space.clone()).with_state(space))
6263 .merge(user::routes::router(user.clone()).with_state(user))
63 .merge(git::routes::router(git.clone()).with_state(git.clone()));
64 .merge(git::routes::router(git.clone()).with_state(git.clone()))
65 .merge(pulls::routes::router().with_state(pulls));
6466 let internal: Router = Router::new()
6567 .merge(git::routes::internal_router(git.clone()).with_state(git))
6668 .route_layer(axum::middleware::from_fn_with_state(
Modified lib/webapi/src/openapi.rs View lib/webapi/src/openapi.rs
@@ -95,9 +95,13 @@
9595 let dbg = std::env::var("OPENAPI_DEBUG").is_ok();
9696 macro_rules! m {
9797 ($name:literal, $expr:expr) => {{
98 if dbg { eprintln!("merge: {}", $name); }
98 if dbg {
99 eprintln!("merge: {}", $name);
100 }
99101 doc.merge($expr);
100 if dbg { eprintln!("done: {}", $name); }
102 if dbg {
103 eprintln!("done: {}", $name);
104 }
101105 }};
102106 }
103107 m!("auth", auth::openapi::AuthApiDoc::openapi());
@@ -106,6 +110,7 @@
106110 m!("space", space::openapi::SpaceApiDoc::openapi());
107111 m!("user", user::openapi::UserApiDoc::openapi());
108112 m!("git", git::openapi::GitApiDoc::openapi());
113 m!("pulls", pulls::openapi::PullsApiDoc::openapi());
109114 doc.servers = Some(vec![utoipa::openapi::Server::new("/api/v1")]);
110115 doc.info.description = Some(CONVENTIONS.to_string());
111116 doc.info.license = None;
Modified lib/webapi/Cargo.toml View lib/webapi/Cargo.toml
@@ -8,6 +8,7 @@
88auth = { workspace = true }
99chat = { workspace = true }
1010issues = { workspace = true }
11pulls = { workspace = true }
1112space = { workspace = true }
1213user = { workspace = true }
1314git = { workspace = true }
Modified Cargo.lock View Cargo.lock
@@ -6206,6 +6206,28 @@
62066206checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
62076207
62086208[[package]]
6209name = "pulls"
6210version = "1.0.0"
6211dependencies = [
6212 "axum",
6213 "config",
6214 "export",
6215 "git",
6216 "globset",
6217 "issues",
6218 "models",
6219 "sea-orm",
6220 "serde",
6221 "serde_json",
6222 "session",
6223 "space",
6224 "time 0.3.55",
6225 "tracing",
6226 "utoipa",
6227 "uuid",
6228]
6229
6230[[package]]
62096231name = "quick-xml"
62106232version = "0.28.2"
62116233source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8846,6 +8868,7 @@
88468868 "opentelemetry",
88478869 "opentelemetry-otlp",
88488870 "opentelemetry_sdk",
8871 "pulls",
88498872 "session",
88508873 "space",
88518874 "tower-http 0.7.0",
Modified Cargo.toml View Cargo.toml
@@ -15,6 +15,7 @@
1515 "lib/issues",
1616 "lib/git",
1717 "lib/webapi",
18 "lib/pulls",
1819 "app/apihost",
1920 "app/githost",
2021 "app/mails",
@@ -47,6 +48,7 @@
4748issues = { path = "lib/issues" }
4849git = { path = "lib/git" }
4950webapi = { path = "lib/webapi" }
51pulls = { path = "lib/pulls" }
5052
5153
5254