use ci_protocol::ai::AgentTask; use export::pagination::Page; use git::context::facade::GitContext; use models::ai::ai_job; use models::ai::ai_review_result::{self, AiFinding, AiReviewVerdict, Model as ReviewRow}; use models::pull_request::{pull_request_comment, pull_requests}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::errors::PullError; use crate::services::service::PullService; /// AI 评审任务提交结果。 #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AiReviewJobDto { pub job_id: Uuid, pub status: String, pub model: String, } /// 一条 AI 行级发现(对外视图)。 #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AiFindingDto { pub path: String, pub line: i32, pub side: Option, pub severity: Option, pub body: String, /// 该 finding 对应的行内评论 id(前端据此跳转)。 pub comment_id: Option, } /// AI 评审结果视图。 #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AiReviewDto { pub id: Uuid, pub pull_request_id: Option, pub commit_sha: String, pub verdict: AiReviewVerdict, pub summary: String, pub findings: Vec, pub truncated: bool, /// 生成该评审时使用的输出语言(BCP-47 或 `auto`)。 pub language: String, pub created_at: OffsetDateTime, /// 当前是否仍是最新 head 上的评审。 pub current: bool, pub job_error: Option, } pub type AiReviewPage = Page; impl PullService { /// 手动(重新)触发 AI 评审。 pub async fn trigger_ai_review( &self, _ctx: &GitContext, pull: &pull_requests::Model, actor: Option, language: Option, ) -> Result { let cfg = &self.config().ai; if !cfg.review_on() { return Err(PullError::BadRequest( "ai review is disabled by config".to_string(), )); } let task = AgentTask::PullReview { job_id: Uuid::now_v7(), repo_id: pull.repo_id, pull_request_id: pull.id, head_sha: pull.head_sha.clone(), // 请求显式指定语言时优先,否则用触发者的偏好。 language: match language.filter(|value| !value.trim().is_empty()) { Some(explicit) => Some(ci_protocol::ai_languages::normalize(&explicit)), None => git::sync::actor_language(self.db_read(), actor).await, }, }; let job_id = git::sync::ai_task::submit_ai_task(self.db_write(), &self.state.redis, cfg, &task) .await .map_err(|e| PullError::Internal(format!("submit ai review: {e}")))?; Ok(AiReviewJobDto { job_id, status: "pending".to_string(), model: cfg.model.trim().to_string(), }) } /// 列出该 PR 的 AI 评审(新→旧)。 pub async fn list_ai_reviews( &self, pull: &pull_requests::Model, ) -> Result, PullError> { let rows = ai_review_result::Entity::find() .filter(ai_review_result::Column::PullRequestId.eq(pull.id)) .order_by_desc(ai_review_result::Column::CreatedAt) .all(self.db_read()) .await?; self.assemble_ai_reviews(rows, &pull.head_sha).await } /// 单条 AI 评审详情。 pub async fn get_ai_review( &self, pull: &pull_requests::Model, review_id: Uuid, ) -> Result { let row = ai_review_result::Entity::find_by_id(review_id) .filter(ai_review_result::Column::PullRequestId.eq(pull.id)) .one(self.db_read()) .await? .ok_or(PullError::NotFound)?; let mut views = self.assemble_ai_reviews(vec![row], &pull.head_sha).await?; views.pop().ok_or(PullError::NotFound) } /// 把结果行拼装为对外视图,并回填 findings 对应的评论 id 与作业错误。 async fn assemble_ai_reviews( &self, rows: Vec, head_sha: &str, ) -> Result, PullError> { if rows.is_empty() { return Ok(Vec::new()); } let review_ids: Vec = rows.iter().map(|row| row.id).collect(); let comments = pull_request_comment::Entity::find() .filter(pull_request_comment::Column::ReviewId.is_in(review_ids)) .order_by_asc(pull_request_comment::Column::CreatedAt) .all(self.db_read()) .await?; let job_ids: Vec = rows.iter().map(|row| row.job_id).collect(); let jobs = ai_job::Entity::find() .filter(ai_job::Column::Id.is_in(job_ids)) .all(self.db_read()) .await?; let mut out = Vec::with_capacity(rows.len()); for row in rows { let findings: Vec = serde_json::from_value(row.findings_json.clone()).unwrap_or_default(); let mut comment_ids: Vec = comments .iter() .filter(|c| c.review_id == Some(row.id)) .map(|c| c.id) .collect(); comment_ids.sort(); let findings = findings .into_iter() .enumerate() .map(|(index, finding)| AiFindingDto { path: finding.path, line: finding.line, side: finding.side, severity: finding.severity, body: finding.body, comment_id: comment_ids.get(index).copied(), }) .collect(); let job_error = jobs .iter() .find(|job| job.id == row.job_id) .and_then(|job| job.error.clone()); out.push(AiReviewDto { id: row.id, pull_request_id: row.pull_request_id, commit_sha: row.commit_sha.clone(), verdict: row.verdict, summary: row.summary, findings, truncated: row.truncated, language: row.language, created_at: row.created_at, current: row.commit_sha == head_sha, job_error, }); } Ok(out) } }