use git::context::facade::GitContext; use models::issue::issue_event_line::{self, IssueEventType}; use models::issue::issue_relation_pull; use models::pull_request::pull_requests; use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::errors::PullError; use crate::services::pulls::PullDto; use crate::services::service::PullService; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct LinkIssueCmd { /// PR 编号或 UUID。 pub pull_ref: String, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct IssuePullLinkDto { /// 关联行主键(issue_relation_pull.id),解除关联时作为路径参数回传。 pub relation_id: Uuid, /// 建立关联的时间。 pub linked_at: OffsetDateTime, pub pull: PullDto, } impl PullService { /// 关联/解绑工单时必须对目标 PR 所在仓库有写权限,且工单与仓库同属一个空间。 pub async fn link_issue( &self, ctx: &GitContext, issue_id: Uuid, caller: i64, pull_ref: &str, ) -> Result { let pull = crate::share::lookup::find_pull(self, ctx.model.id, pull_ref).await?; let existing = issue_relation_pull::Entity::find() .filter(issue_relation_pull::Column::IssueId.eq(issue_id)) .filter(issue_relation_pull::Column::PullRequestId.eq(pull.id)) .one(self.db_read()) .await?; if existing.is_none() { let now = OffsetDateTime::now_utc(); issue_relation_pull::ActiveModel { id: Set(Uuid::now_v7()), issue_id: Set(issue_id), pull_request_id: Set(pull.id), created_at: Set(now), } .insert(self.db_write()) .await?; // 关联也是一条活动记录:与「添加了标签」同级的 issue 事件。 self.write_pull_event(issue_id, caller, IssueEventType::PullLinked, &pull) .await?; } Ok(pull) } pub async fn unlink_issue( &self, ctx: &GitContext, issue_id: Uuid, relation_id: Uuid, caller: i64, ) -> Result<(), PullError> { let relation = issue_relation_pull::Entity::find_by_id(relation_id) .filter(issue_relation_pull::Column::IssueId.eq(issue_id)) .one(self.db_read()) .await?; let deleted = issue_relation_pull::Entity::delete_many() .filter(issue_relation_pull::Column::Id.eq(relation_id)) .filter(issue_relation_pull::Column::IssueId.eq(issue_id)) .exec(self.db_write()) .await?; if deleted.rows_affected == 0 { return Err(PullError::NotFound); } if let Some(relation) = relation && let Some(pull) = pull_requests::Entity::find_by_id(relation.pull_request_id) .one(self.db_read()) .await? { self.write_pull_event(issue_id, caller, IssueEventType::PullUnlinked, &pull) .await?; } let _ = ctx; Ok(()) } /// 写一条「关联/解除关联合并请求」的 issue 事件;事件文本为 `#<编号> <标题>`。 async fn write_pull_event( &self, issue_id: Uuid, caller: i64, event_type: IssueEventType, pull: &pull_requests::Model, ) -> Result<(), PullError> { let title = pull.title.trim(); let clipped: String = title.chars().take(80).collect(); let label = format!( "#{} {}{}", pull.autoincrement_id, clipped, if title.chars().count() > 80 { "…" } else { "" } ); let linked = matches!(event_type, IssueEventType::PullLinked); issue_event_line::ActiveModel { id: Set(Uuid::now_v7()), issue_id: Set(Some(issue_id)), project_id: Set(None), user_id: Set(caller), event_type: Set(event_type), comment_id: Set(None), commit_sha: Set(None), old_title: Set(None), new_title: Set(None), old_ref: Set((!linked).then(|| label.clone())), new_ref: Set(linked.then(|| label.clone())), content: Set(Some(label)), created_at: Set(OffsetDateTime::now_utc()), } .insert(self.db_write()) .await?; Ok(()) } pub async fn list_linked_pulls( &self, issue_id: Uuid, space_id: Uuid, ) -> Result, PullError> { let links = issue_relation_pull::Entity::find() .filter(issue_relation_pull::Column::IssueId.eq(issue_id)) .order_by_asc(issue_relation_pull::Column::CreatedAt) .all(self.db_read()) .await?; if links.is_empty() { return Ok(Vec::new()); } let ids: Vec = links.iter().map(|link| link.pull_request_id).collect(); let rows = pull_requests::Entity::find() .filter(pull_requests::Column::Id.is_in(ids)) .all(self.db_read()) .await?; let mut visible: Vec = Vec::with_capacity(rows.len()); for row in rows { let repo = models::repo::repos::Entity::find_by_id(row.repo_id) .one(self.db_read()) .await?; let show = match repo { None => false, Some(repo) if !repo.is_private => true, Some(repo) => git::share::access::space_for_dir(&self.state, repo.dir_id) .await? .is_some_and(|space| space.id == space_id), }; if show { visible.push(row); } } let users = self.pull_users(&visible).await?; let by_id: HashMap = visible.iter().map(|row| (row.id, row)).collect(); Ok(links .iter() .filter_map(|link| { by_id .get(&link.pull_request_id) .map(|row| IssuePullLinkDto { relation_id: link.id, linked_at: link.created_at, pull: PullService::dto(row, &users), }) }) .collect()) } }