use git::context::facade::GitContext; /// 组装好的模型上下文:提示词正文 + 是否发生截断。 #[derive(Debug, Clone)] pub struct BuiltContext { pub text: String, pub truncated: bool, } const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; impl super::AgentService { /// PR 评审上下文:merge-base..head 的三点差异。 pub async fn build_pull_diff( &self, space_name: &str, repo_name: &str, head_sha: &str, base_ref: &str, ) -> Result<(BuiltContext, String), git::share::errors::GitError> { let ctx = self .git .context(space_name.to_string(), repo_name.to_string()) .await?; let plan = self .git .merge_plan(&ctx, &format!("refs/heads/{base_ref}"), head_sha) .await?; let from = plan .merge_base .clone() .unwrap_or_else(|| plan.base_sha.clone()); let diff = self .git .diff_raw( space_name.to_string(), repo_name.to_string(), from.clone(), plan.head_sha.clone(), ) .await?; let label = format!("{}..{}", short(&from), short(&plan.head_sha)); Ok((self.truncate_diff(diff), label)) } /// Commit 评审上下文:父提交..该提交的差异。 pub async fn build_commit_diff( &self, space_name: &str, repo_name: &str, commit_sha: &str, ) -> Result<(BuiltContext, String), git::share::errors::GitError> { let ctx = self .git .context(space_name.to_string(), repo_name.to_string()) .await?; let baby = self.git.baby(&ctx); let info = baby .get_commit(commit_sha) .await .map_err(git::share::errors::map_baby)?; let parent = info .parents .first() .map(|p| p.to_string()) .unwrap_or_else(|| EMPTY_TREE.to_string()); let sha = info.id.to_string(); let diff = self .git .diff_raw( space_name.to_string(), repo_name.to_string(), parent.clone(), sha.clone(), ) .await?; let label = format!("{}..{}", short(&parent), short(&sha)); Ok((self.truncate_diff(diff), label)) } /// 提交列表(评审用不到,但总结/release 复用同一格式化)。 /// /// 附上正文首段:提交标题往往只有「fix bug」这类动作,真正说明意图、影响面 /// 与取舍的信息在正文里。总结与 release 文案靠这些内容才能写出实质差别, /// 只喂标题时模型只能把标题换个说法复述一遍——那正是输出干瘪的直接原因。 /// 正文按提交截断,避免一条超长 message 把预算吃光。 pub(crate) fn format_commits(commits: &[git::service::merge::MergeCommitDto]) -> String { let mut out = String::new(); for commit in commits { out.push_str(&format!( "- {} {} ({}, {})\n", short(&commit.sha), commit.subject.trim(), commit.author_name, commit.author_at )); if let Some(body) = commit_body(&commit.message, &commit.subject) { out.push_str(&body); } } out } /// 按字节预算截断差异:超限时逐文件裁剪并标记 truncated。 pub(crate) fn truncate_diff(&self, diff: String) -> BuiltContext { let limit = self.config().ai.context_max_bytes; if diff.len() <= limit { return BuiltContext { text: diff, truncated: false, }; } let mut text = String::with_capacity(limit); let mut truncated = false; for file in split_diff_files(&diff) { if text.len() + file.len() > limit { truncated = true; break; } text.push_str(&file); } if text.is_empty() { // 单个文件本身就超限:按字符边界硬截断。 text = diff.chars().take(limit).collect(); truncated = true; } BuiltContext { text, truncated } } } /// 按 `diff --git` 切分,保留每段完整补丁文本。 pub fn split_diff_files(diff: &str) -> Vec { let mut files: Vec = Vec::new(); let mut current = String::new(); for line in diff.split_inclusive('\n') { if line.starts_with("diff --git ") && !current.is_empty() { files.push(std::mem::take(&mut current)); } current.push_str(line); } if !current.is_empty() { files.push(current); } files } pub(crate) fn short(sha: &str) -> String { sha.chars().take(8).collect() } /// 单条提交正文进入提示词的字符上限。 const COMMIT_BODY_MAX_CHARS: usize = 400; /// 提交正文的摘要片段:跳过标题行、去掉签名等尾部噪音、保留首个实质段落。 fn commit_body(message: &str, subject: &str) -> Option { let mut lines = message.lines(); // 第一行是标题,调用方已经输出过。 lines.next(); let mut body = String::new(); for line in lines { let trimmed = line.trim(); if trimmed.is_empty() { // 首个空行代表首个段落结束;段落之前的前导空行直接跳过。 if !body.is_empty() { break; } continue; } // 「Signed-off-by:」「Co-authored-by:」这类 trailer 不描述变更本身。 let lower = trimmed.to_ascii_lowercase(); if lower.starts_with("signed-off-by:") || lower.starts_with("co-authored-by:") { continue; } body.push_str(trimmed); body.push(' '); if body.chars().count() >= COMMIT_BODY_MAX_CHARS { break; } } let body = body.trim(); if body.is_empty() || body == subject.trim() { return None; } Some(format!( " 正文:{}\n", truncate_chars(body, COMMIT_BODY_MAX_CHARS) )) } fn truncate_chars(text: &str, limit: usize) -> String { if text.chars().count() <= limit { return text.to_string(); } let mut out: String = text.chars().take(limit).collect(); out.push('…'); out } /// 让 `GitContext` 在本 crate 内可用(避免调用方各自引入 git crate)。 pub type RepoContext = GitContext; #[cfg(test)] mod tests { use super::*; /// 提交正文是总结质量的来源:只喂标题时模型无从区分「一次重构」与 /// 「一行修复」,输出必然退化成标题复述。这条测试把正文必须进入提示词 /// 这件事钉住,同时确认签名之类的尾部噪音不会被带上。 #[test] fn commit_body_keeps_prose_and_drops_trailers() { let message = "fix: 修正接收器计数\n\nBufferedUdpMetricSink 在批量发送后少记一次,\n对监控口径有影响。\n\nSigned-off-by: Someone \n"; let body = commit_body(message, "fix: 修正接收器计数").expect("正文应被保留"); assert!(body.contains("少记一次"), "正文要点必须保留: {body}"); assert!( !body.contains("Signed-off-by"), "trailer 不应进入提示词: {body}" ); } /// 没有正文、或正文与标题重复时不必占用预算。 #[test] fn commit_body_is_skipped_when_empty_or_identical() { assert!(commit_body("only a subject", "only a subject").is_none()); assert!(commit_body("subject\n\nsubject", "subject").is_none()); } /// 超长正文按字符截断,避免一条 message 吃掉整段提示词预算。 #[test] fn commit_body_is_bounded() { let long = format!("subject\n\n{}", "改".repeat(COMMIT_BODY_MAX_CHARS * 2)); let body = commit_body(&long, "subject").expect("应有正文"); assert!(body.chars().count() < COMMIT_BODY_MAX_CHARS + 16); assert!(body.ends_with("…\n")); } }