9301161
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<String>,
pub severity: Option<String>,
pub body: String,
/// 该 finding 对应的行内评论 id(前端据此跳转)。
pub comment_id: Option<Uuid>,
}
/// AI 评审结果视图。
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AiReviewDto {
pub id: Uuid,
pub pull_request_id: Option<Uuid>,
pub commit_sha: String,
pub verdict: AiReviewVerdict,
pub summary: String,
pub findings: Vec<AiFindingDto>,
pub truncated: bool,
/// 生成该评审时使用的输出语言(BCP-47 或 `auto`)。
pub language: String,
pub created_at: OffsetDateTime,
/// 当前是否仍是最新 head 上的评审。
pub current: bool,
pub job_error: Option<String>,
}
pub type AiReviewPage = Page<AiReviewDto>;
impl PullService {
/// 手动(重新)触发 AI 评审。
pub async fn trigger_ai_review(
&self,
_ctx: &GitContext,
pull: &pull_requests::Model,
actor: Option<Uuid>,
language: Option<String>,
) -> Result<AiReviewJobDto, PullError> {
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,
},
trace: export::telemetry::traceparent(),
};
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<Vec<AiReviewDto>, 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<AiReviewDto, PullError> {
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<ReviewRow>,
head_sha: &str,
) -> Result<Vec<AiReviewDto>, PullError> {
if rows.is_empty() {
return Ok(Vec::new());
}
let review_ids: Vec<Uuid> = 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<Uuid> = 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<AiFinding> =
serde_json::from_value(row.findings_json.clone()).unwrap_or_default();
let mut comment_ids: Vec<Uuid> = 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)
}
}