use std::sync::Arc; use ci_protocol::ai::AgentTask as Task; use config::AppConfig; use export::AppState; use git::service::GitService; use models::ai::ai_review_result::{ActiveModel as ResultModel, AiReviewVerdict}; use models::ai::repo_ai_summary::ActiveModel as SummaryModel; use models::pull_request::{pull_request_comment, pull_request_review, pull_requests}; use models::repo::repo_release; use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, }; use time::OffsetDateTime; use uuid::Uuid; use crate::client::{AgentClient, AgentError, AgentResult, extract_json}; use crate::prompt::{ PromptContext, ReleaseNotesOutput, ReviewOutput, SummaryOutput, detect_languages, release_system, release_user_prompt, review_system, review_user_prompt, summary_system, summary_user_prompt, }; /// 总结最多纳入的提交数(与 `[ai].summary_commit_limit` 取较小值)。 const MAX_SUMMARY_COMMITS: u64 = 200; /// release 文案最多纳入的提交数。 const MAX_RELEASE_COMMITS: u32 = 200; /// AI 运行核心:持有单一模型客户端与共享状态,执行队列中的任务。 #[derive(Clone)] pub struct AgentService { pub state: AppState, pub git: GitService, client: Arc, } impl AgentService { pub fn new(state: AppState) -> AgentResult { let client = AgentClient::new(&state.config.ai)?; let git = GitService::new(state.clone()); Ok(Self { state, git, client: Arc::new(client), }) } pub fn config(&self) -> &AppConfig { &self.state.config } pub fn db_read(&self) -> &DatabaseConnection { self.state.database.read() } pub fn db_write(&self) -> &DatabaseConnection { self.state.database.write() } pub fn client(&self) -> &AgentClient { &self.client } /// 执行一条队列任务。返回 `Err` 时由调用方决定重投或落 DLQ。 pub async fn run_task(&self, task: &Task) -> AgentResult<()> { let job_id = task.job_id(); self.mark_running(job_id, task.kind_str()) .await .map_err(|e| AgentError::InvalidTask(e.to_string()))?; let outcome = match task { Task::PullReview { .. } => self.run_pull_review(task).await, Task::CommitReview { .. } => self.run_commit_review(task).await, Task::RepoSummary { .. } => self.run_repo_summary(task).await, Task::ReleaseNotes { .. } => self.run_release_notes(task).await, }; if let Err(error) = &outcome { tracing::warn!(%error, %job_id, "ai task failed"); } outcome } /// 评审提示词上下文:输出语言 + 本次补丁涉及的语言。 fn review_context(&self, language: &str, diff: &str) -> PromptContext { PromptContext::new(language, detect_languages(diff)) } /// 总结与 release 提示词上下文:输出语言 + 仓库已登记的语言统计。 /// /// 提交列表本身不含路径,因此复用 sync 阶段写入的 `git_language` 统计; /// 统计缺失时只保留语言指令,不影响生成。 async fn repo_context(&self, language: &str, repo_id: Uuid) -> PromptContext { let languages = self.repo_languages(repo_id).await; PromptContext::new(language, languages) } /// 读取仓库语言统计(按占比降序,最多 5 种)。 async fn repo_languages(&self, repo_id: Uuid) -> Vec { use models::git::git_languages; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; let rows = git_languages::Entity::find() .filter(git_languages::Column::RepoId.eq(repo_id)) .order_by_desc(git_languages::Column::PercentageBp) .limit(5) .all(self.db_read()) .await; match rows { Ok(rows) => rows.into_iter().map(|row| row.language).collect(), Err(error) => { tracing::debug!(%error, "repo language lookup failed; omitting language hint"); Vec::new() } } } /// 仓库全名(space/repo),用于提示词与通知文案。 pub(crate) async fn repo_label(&self, repo_id: Uuid) -> String { use models::repo::repos; let repo = repos::Entity::find_by_id(repo_id) .one(self.db_read()) .await .ok() .flatten(); match repo { Some(row) => row.name, None => repo_id.to_string(), } } pub(crate) async fn space_name_for(&self, repo_id: Uuid) -> Option { use models::repo::repos; let repo = repos::Entity::find_by_id(repo_id) .one(self.db_read()) .await .ok()??; git::share::access::space_for_dir(self.db_read(), repo.dir_id) .await .ok()? .map(|space| space.lower_name) } // ---- PR 评审 ------------------------------------------------------- async fn run_pull_review(&self, task: &Task) -> AgentResult<()> { let Task::PullReview { job_id, repo_id, pull_request_id, head_sha, language, } = task else { return Err(AgentError::InvalidTask("not a pull review".to_string())); }; let pull = pull_requests::Entity::find_by_id(*pull_request_id) .filter(pull_requests::Column::RepoId.eq(*repo_id)) .one(self.db_read()) .await .map_err(db_err)? .ok_or_else(|| AgentError::InvalidTask("pull request not found".to_string()))?; let space = self .space_name_for(*repo_id) .await .ok_or_else(|| AgentError::InvalidTask("space not found".to_string()))?; let repo_name = self.repo_label(*repo_id).await; let (diff, label) = self .build_pull_diff(&space, &repo_name, head_sha, &pull.base_branch) .await .map_err(|e| AgentError::Execution(format!("build diff: {e}")))?; let lang = self .resolve_language(Some(pull.author_id), language.as_deref(), *repo_id) .await; let ctx = self.review_context(&lang, &diff.text); let prompt = review_user_prompt( &repo_name, &label, &diff.text, diff.truncated, self.config().ai.max_findings, ); let completion = self .client .complete(&review_system(&ctx), &prompt, true) .await?; let parsed: ReviewOutput = parse_output(&completion.content)?; let (findings, dropped) = crate::diff::validate_findings( &diff.text, parsed.findings, self.config().ai.max_findings, ); if dropped > 0 { tracing::info!(dropped, "dropped ai findings outside changed lines"); } let verdict = match parsed.verdict.as_deref() { Some("changes_requested") => AiReviewVerdict::ChangesRequested, _ => AiReviewVerdict::Commented, }; self.persist_pull_review( job_id, repo_id, &pull, head_sha, &lang, verdict, &parsed.summary, &findings, diff.truncated, &completion.raw, completion.prompt_tokens, completion.completion_tokens, ) .await } #[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)] async fn persist_pull_review( &self, job_id: &Uuid, repo_id: &Uuid, pull: &pull_requests::Model, head_sha: &str, language: &str, verdict: AiReviewVerdict, summary: &str, findings: &[crate::prompt::ReviewFinding], truncated: bool, raw: &serde_json::Value, prompt_tokens: Option, completion_tokens: Option, ) -> AgentResult<()> { let bot = crate::bot::resolve_bot_user(self.db_write(), &self.config().ai) .await .map_err(db_err)?; let now = OffsetDateTime::now_utc(); let state = match verdict { AiReviewVerdict::ChangesRequested => pull_request_review::ReviewState::ChangesRequested, AiReviewVerdict::Commented => pull_request_review::ReviewState::Commented, }; let review_id = Uuid::now_v7(); pull_request_review::ActiveModel { id: Set(review_id), pull_request_id: Set(pull.id), user_id: Set(bot), commit_sha: Set(head_sha.to_string()), official: Set(false), content: Set(Some(summary.to_string())), state: Set(state), created_at: Set(now), updated_at: Set(None), } .insert(self.db_write()) .await .map_err(db_err)?; for finding in findings { pull_request_comment::ActiveModel { id: Set(Uuid::now_v7()), pull_request_id: Set(pull.id), user_id: Set(bot), review_id: Set(Some(review_id)), parent_comment_id: Set(None), commit_sha: Set(Some(head_sha.to_string())), original_commit_sha: Set(Some(head_sha.to_string())), path: Set(Some(finding.path.clone())), side: Set(finding.side.clone()), line: Set(Some(finding.line)), original_line: Set(Some(finding.line)), body: Set(finding_body(finding)), resolved_at: Set(None), resolved_by: Set(None), created_at: Set(now), updated_at: Set(None), } .insert(self.db_write()) .await .map_err(db_err)?; } let findings_value = serde_json::to_value(findings).unwrap_or(serde_json::Value::Null); ResultModel { id: Set(Uuid::now_v7()), repo_id: Set(*repo_id), pull_request_id: Set(Some(pull.id)), commit_sha: Set(head_sha.to_string()), verdict: Set(verdict), summary: Set(summary.to_string()), findings_json: Set(findings_value), truncated: Set(truncated), language: Set(language.to_string()), job_id: Set(*job_id), created_at: Set(now), } .insert(self.db_write()) .await .map_err(db_err)?; self.mark_success( *job_id, None, Some(raw.clone()), prompt_tokens, completion_tokens, ) .await; self.notify_pull_ai(pull, bot, language, summary, findings.len()) .await; Ok(()) } // ---- Commit 评审 --------------------------------------------------- async fn run_commit_review(&self, task: &Task) -> AgentResult<()> { let Task::CommitReview { job_id, repo_id, commit_sha, language, } = task else { return Err(AgentError::InvalidTask("not a commit review".to_string())); }; let space = self .space_name_for(*repo_id) .await .ok_or_else(|| AgentError::InvalidTask("space not found".to_string()))?; let repo_name = self.repo_label(*repo_id).await; let (diff, label) = self .build_commit_diff(&space, &repo_name, commit_sha) .await .map_err(|e| AgentError::Execution(format!("build diff: {e}")))?; let lang = self .resolve_language(None, language.as_deref(), *repo_id) .await; let ctx = self.review_context(&lang, &diff.text); let prompt = review_user_prompt( &repo_name, &label, &diff.text, diff.truncated, self.config().ai.max_findings, ); let completion = self .client .complete(&review_system(&ctx), &prompt, true) .await?; let parsed: ReviewOutput = parse_output(&completion.content)?; let (findings, _dropped) = crate::diff::validate_findings( &diff.text, parsed.findings, self.config().ai.max_findings, ); let verdict = match parsed.verdict.as_deref() { Some("changes_requested") => AiReviewVerdict::ChangesRequested, _ => AiReviewVerdict::Commented, }; let now = OffsetDateTime::now_utc(); let findings_value = serde_json::to_value(&findings).unwrap_or(serde_json::Value::Null); ResultModel { id: Set(Uuid::now_v7()), repo_id: Set(*repo_id), pull_request_id: Set(None), commit_sha: Set(commit_sha.clone()), verdict: Set(verdict), summary: Set(parsed.summary.clone()), findings_json: Set(findings_value), truncated: Set(diff.truncated), language: Set(lang.clone()), job_id: Set(*job_id), created_at: Set(now), } .insert(self.db_write()) .await .map_err(db_err)?; self.mark_success( *job_id, None, Some(completion.raw.clone()), completion.prompt_tokens, completion.completion_tokens, ) .await; self.publish_commit_status( *repo_id, commit_sha, verdict, &parsed.summary, findings.len(), ) .await; Ok(()) } /// 把提交级评审结论写成 `context=ai-review` 的提交状态。 /// /// 默认不进入 `required_status_contexts`;是否作为合并门禁由管理员配置。 async fn publish_commit_status( &self, repo_id: Uuid, commit_sha: &str, verdict: AiReviewVerdict, summary: &str, findings: usize, ) { use git::service::commits::{CommitStatusDtoState, SetCommitStatusCmd}; let (space, repo) = match self.repo_location(repo_id).await { Some(pair) => pair, None => { tracing::warn!(%repo_id, "skip commit status: repo location unknown"); return; } }; let bot = match crate::bot::resolve_bot_user(self.db_write(), &self.config().ai).await { Ok(uid) => uid, Err(error) => { tracing::warn!(%error, "skip commit status: bot user unavailable"); return; } }; let (state, description) = match verdict { AiReviewVerdict::ChangesRequested => ( CommitStatusDtoState::Failure, format!("AI 评审要求修改({findings} 条建议)"), ), AiReviewVerdict::Commented => (CommitStatusDtoState::Success, summary.to_string()), }; let cmd = SetCommitStatusCmd { sha: commit_sha.to_string(), state, context: git::service::ai_review::AI_REVIEW_CONTEXT.to_string(), description: Some(crate::client::truncate(&description, 200)), target_url: None, }; if let Err(error) = self.git.set_commit_status(space, repo, bot, cmd).await { tracing::warn!(%error, %commit_sha, "failed to publish ai-review commit status"); } } /// 由仓库 id 解析 (space, repo) 名称对。 async fn repo_location(&self, repo_id: Uuid) -> Option<(String, String)> { use models::repo::repos; let repo = repos::Entity::find_by_id(repo_id) .one(self.db_read()) .await .ok()??; let space = self.space_name_for(repo_id).await?; Some((space, repo.name)) } // ---- 仓库总结 ------------------------------------------------------ async fn run_repo_summary(&self, task: &Task) -> AgentResult<()> { let Task::RepoSummary { job_id, repo_id, head_sha, base_sha, limit, language, } = task else { return Err(AgentError::InvalidTask("not a repo summary".to_string())); }; let space = self .space_name_for(*repo_id) .await .ok_or_else(|| AgentError::InvalidTask("space not found".to_string()))?; let repo_name = self.repo_label(*repo_id).await; // 总结的口径是「最近 N 个提交」,不是两点区间: // 有显式 base 时按 base..head 取,否则直接从 head 起步。 let limit = limit .unwrap_or(self.config().ai.summary_commit_limit) .clamp(1, MAX_SUMMARY_COMMITS); let trimmed = match base_sha { Some(base) => self .git .commits_between( space.clone(), repo_name.clone(), base, head_sha, limit as u32, ) .await .map_err(|e| AgentError::Execution(format!("list commits: {e}")))?, None => self .git .recent_commits( space.clone(), repo_name.clone(), Some(head_sha), limit as u32, 0, ) .await .map_err(|e| AgentError::Execution(format!("list commits: {e}")))?, }; let commit_text = Self::format_commits(&trimmed); let prompt = summary_user_prompt(&repo_name, head_sha, &commit_text); let lang = self .resolve_language(None, language.as_deref(), *repo_id) .await; let ctx = self.repo_context(&lang, *repo_id).await; let completion = self .client .complete(&summary_system(&ctx), &prompt, true) .await?; let parsed: SummaryOutput = parse_output(&completion.content)?; let now = OffsetDateTime::now_utc(); let highlights = serde_json::to_value(&parsed.highlights).unwrap_or(serde_json::Value::Null); let risks = serde_json::to_value(&parsed.risks).unwrap_or(serde_json::Value::Null); let existing = models::ai::repo_ai_summary::Entity::find() .filter(models::ai::repo_ai_summary::Column::RepoId.eq(*repo_id)) .filter(models::ai::repo_ai_summary::Column::HeadSha.eq(head_sha.clone())) .one(self.db_read()) .await .map_err(db_err)?; match existing { Some(row) => { let mut model: SummaryModel = row.into(); model.content = Set(parsed.content.clone()); model.highlights_json = Set(highlights); model.risks_json = Set(risks); model.commit_count = Set(trimmed.len() as i32); model.model = Set(self.client.model().to_string()); model.language = Set(lang.clone()); model.job_id = Set(Some(*job_id)); model.updated_at = Set(now); model.update(self.db_write()).await.map_err(db_err)?; } None => { SummaryModel { id: Set(Uuid::now_v7()), repo_id: Set(*repo_id), head_sha: Set(head_sha.clone()), base_sha: Set(base_sha.clone()), commit_count: Set(trimmed.len() as i32), content: Set(parsed.content.clone()), highlights_json: Set(highlights), risks_json: Set(risks), model: Set(self.client.model().to_string()), language: Set(lang.clone()), job_id: Set(Some(*job_id)), created_at: Set(now), updated_at: Set(now), } .insert(self.db_write()) .await .map_err(db_err)?; } } self.mark_success( *job_id, None, Some(completion.raw.clone()), completion.prompt_tokens, completion.completion_tokens, ) .await; Ok(()) } // ---- Release 文案 -------------------------------------------------- async fn run_release_notes(&self, task: &Task) -> AgentResult<()> { let Task::ReleaseNotes { job_id, repo_id, release_id, tag_name, from_sha, to_sha, language, } = task else { return Err(AgentError::InvalidTask("not a release notes".to_string())); }; let release = repo_release::Entity::find_by_id(*release_id) .filter(repo_release::Column::RepoId.eq(*repo_id)) .one(self.db_read()) .await .map_err(db_err)? .ok_or_else(|| AgentError::InvalidTask("release not found".to_string()))?; let space = self .space_name_for(*repo_id) .await .ok_or_else(|| AgentError::InvalidTask("space not found".to_string()))?; let repo_name = self.repo_label(*repo_id).await; // 有上一个标签时按区间取;首个版本没有起点,则回退到「最近若干提交」, // 而不是留空——否则首个 release 的文案会没有任何素材。 let commits = match from_sha { Some(from) => self .git .commits_between( space.clone(), repo_name.clone(), from, to_sha, MAX_RELEASE_COMMITS, ) .await .map_err(|e| AgentError::Execution(format!("list commits: {e}")))?, None => self .git .recent_commits( space, repo_name.clone(), Some(to_sha), MAX_RELEASE_COMMITS, 0, ) .await .map_err(|e| AgentError::Execution(format!("list commits: {e}")))?, }; let commit_text = Self::format_commits(&commits); let prompt = release_user_prompt(&repo_name, tag_name, from_sha.as_deref(), &commit_text); let lang = self .resolve_language(None, language.as_deref(), *repo_id) .await; let ctx = self.repo_context(&lang, *repo_id).await; let completion = self .client .complete(&release_system(&ctx), &prompt, true) .await?; let parsed: ReleaseNotesOutput = parse_output(&completion.content)?; let now = OffsetDateTime::now_utc(); // 已发布的 release 不再被 AI 覆盖,只记录作业结果。 if release.is_draft { let mut model: repo_release::ActiveModel = release.into(); let title = if parsed.title.trim().is_empty() { tag_name.clone() } else { parsed.title.clone() }; model.title = Set(title); model.content = Set(Some(parsed.content.clone())); model.num_commits = Set(commits.len() as i32); model.updated_at = Set(now); model.update(self.db_write()).await.map_err(db_err)?; } self.mark_success( *job_id, None, Some(completion.raw.clone()), completion.prompt_tokens, completion.completion_tokens, ) .await; Ok(()) } /// 记录 release 草稿的作业请求体(便于运维排查)。 pub async fn record_release_request(&self, job_id: Uuid, request: serde_json::Value) { use models::ai::ai_job; use sea_orm::EntityTrait; if let Ok(Some(row)) = ai_job::Entity::find_by_id(job_id) .one(self.db_write()) .await { let mut model: ai_job::ActiveModel = row.into(); model.request_json = Set(Some(request)); model.updated_at = Set(Some(OffsetDateTime::now_utc())); let _ = model.update(self.db_write()).await; } } } fn finding_body(finding: &crate::prompt::ReviewFinding) -> String { let severity = finding.severity.as_deref().unwrap_or("minor"); format!("**[{severity}]** {}", finding.body.trim()) } fn parse_output(content: &str) -> AgentResult { let value = extract_json(content)?; serde_json::from_value(value).map_err(|e| AgentError::Decode(format!("shape mismatch: {e}"))) } /// 数据库抖动是可重试的:连接中断、死锁等都会在下一轮恢复。 /// 记录本身缺失另有专门的 `InvalidTask` 分支(`ok_or_else`),不走这里。 fn db_err(error: sea_orm::DbErr) -> AgentError { AgentError::Execution(format!("database error: {error}")) }