Skip to content
Clone
2687ace
235 lines 8.1 KB Rust
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<String> {
let mut files: Vec<String> = 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<String> {
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 <s@example.com>\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"));
}
}