gitdataai/lib/service/agent/git_tools/diff.rs
2026-05-30 01:38:40 +08:00

189 lines
7.1 KiB
Rust

use ai::error::{AiError, AiResult};
use ai::tool::tools::FunctionCall;
use async_trait::async_trait;
use git::rpc::proto as p;
use git::rpc::proto::diff_service_client::DiffServiceClient;
use serde_json::{json, Value};
use super::helpers::{arg_str, git_ctx, require_repo_member, rpc_err};
use crate::agent::run::AppAgentContext;
pub struct GitDiffStatsTool;
impl GitDiffStatsTool {
pub fn new() -> Self { Self }
}
impl Default for GitDiffStatsTool {
fn default() -> Self { Self::new() }
}
#[async_trait]
impl FunctionCall for GitDiffStatsTool {
type Context = AppAgentContext;
fn name(&self) -> &'static str { "git_diff_stats" }
fn description(&self) -> &'static str {
"Get diff statistics between two commits: files changed, insertions, deletions."
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"workspace": { "type": "string", "description": "Workspace name" },
"repo": { "type": "string", "description": "Repository name" },
"old_oid": { "type": "string", "description": "Base commit OID" },
"new_oid": { "type": "string", "description": "Target commit OID" }
},
"required": ["workspace", "repo", "old_oid", "new_oid"]
})
}
async fn call(&self, ctx: &mut AppAgentContext, args: Value) -> AiResult<Value> {
let git = git_ctx(ctx)?;
let workspace = arg_str(&args, "workspace")?;
let repo_name = arg_str(&args, "repo")?;
let old_oid = arg_str(&args, "old_oid")?;
let new_oid = arg_str(&args, "new_oid")?;
let repo = require_repo_member(git, ctx.user_id, workspace, repo_name).await?;
let mut client = DiffServiceClient::new(git.channel.clone());
let resp = client
.diff_stats(p::DiffStatsRequest {
repo_id: repo.id.to_string(),
old_oid: Some(p::ObjectId { value: old_oid.to_string() }),
new_oid: Some(p::ObjectId { value: new_oid.to_string() }),
options: None,
})
.await
.map_err(rpc_err)?
.into_inner();
let result = resp.result.ok_or_else(|| AiError::Response("no diff result".to_string()))?;
let stats = result.stats.ok_or_else(|| AiError::Response("no stats".to_string()))?;
let files: Vec<Value> = result.deltas.iter().map(|d| {
let status = match p::DiffDeltaStatus::try_from(d.status) {
Ok(p::DiffDeltaStatus::Added) => "added",
Ok(p::DiffDeltaStatus::Deleted) => "deleted",
Ok(p::DiffDeltaStatus::Modified) => "modified",
Ok(p::DiffDeltaStatus::Renamed) => "renamed",
_ => "unknown",
};
let old_path = d.old_file.as_ref().and_then(|f| f.path.as_deref()).unwrap_or("");
let new_path = d.new_file.as_ref().and_then(|f| f.path.as_deref()).unwrap_or("");
json!({ "status": status, "old_path": old_path, "new_path": new_path, "hunks": d.hunks.len() })
}).collect();
Ok(json!({
"files_changed": stats.files_changed,
"insertions": stats.insertions,
"deletions": stats.deletions,
"files": files,
}))
}
}
pub struct GitDiffPatchTool;
impl GitDiffPatchTool {
pub fn new() -> Self { Self }
}
impl Default for GitDiffPatchTool {
fn default() -> Self { Self::new() }
}
#[async_trait]
impl FunctionCall for GitDiffPatchTool {
type Context = AppAgentContext;
fn name(&self) -> &'static str { "git_diff_patch" }
fn description(&self) -> &'static str {
"Get the full diff (unified format) between two commits, including line-level changes."
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"workspace": { "type": "string", "description": "Workspace name" },
"repo": { "type": "string", "description": "Repository name" },
"old_oid": { "type": "string", "description": "Base commit OID" },
"new_oid": { "type": "string", "description": "Target commit OID" },
"context_lines": { "type": "integer", "description": "Lines of context around changes (default 3)" }
},
"required": ["workspace", "repo", "old_oid", "new_oid"]
})
}
async fn call(&self, ctx: &mut AppAgentContext, args: Value) -> AiResult<Value> {
let git = git_ctx(ctx)?;
let workspace = arg_str(&args, "workspace")?;
let repo_name = arg_str(&args, "repo")?;
let old_oid = arg_str(&args, "old_oid")?;
let new_oid = arg_str(&args, "new_oid")?;
let ctx_lines = args.get("context_lines").and_then(|v| v.as_u64()).unwrap_or(3) as u32;
let repo = require_repo_member(git, ctx.user_id, workspace, repo_name).await?;
let mut client = DiffServiceClient::new(git.channel.clone());
let resp = client
.diff_patch(p::DiffPatchRequest {
repo_id: repo.id.to_string(),
old_oid: Some(p::ObjectId { value: old_oid.to_string() }),
new_oid: Some(p::ObjectId { value: new_oid.to_string() }),
options: Some(p::DiffOptions {
context_lines: ctx_lines,
..Default::default()
}),
})
.await
.map_err(rpc_err)?
.into_inner();
let result = resp.result.ok_or_else(|| AiError::Response("no diff result".to_string()))?;
let stats = result.stats.ok_or_else(|| AiError::Response("no stats".to_string()))?;
let mut patch_text = String::new();
for delta in &result.deltas {
let status = match p::DiffDeltaStatus::try_from(delta.status) {
Ok(p::DiffDeltaStatus::Added) => "added",
Ok(p::DiffDeltaStatus::Deleted) => "deleted",
Ok(p::DiffDeltaStatus::Modified) => "modified",
Ok(p::DiffDeltaStatus::Renamed) => "renamed",
_ => "unknown",
};
let old = delta.old_file.as_ref().and_then(|f| f.path.as_deref()).unwrap_or("unknown");
let new = delta.new_file.as_ref().and_then(|f| f.path.as_deref()).unwrap_or("unknown");
patch_text.push_str(&format!("--- {}\n+++ {}\n@@ status: {status} @@\n", old, new));
for hunk in &delta.hunks {
patch_text.push_str(&hunk.header);
patch_text.push('\n');
for line in &delta.lines {
patch_text.push_str(&format!("{}{}\n", line.origin, line.content));
}
patch_text.push('\n');
}
}
let truncated = patch_text.len() > 32_000;
if truncated {
patch_text = format!("{}...(truncated)", &patch_text[..32_000]);
}
Ok(json!({
"files_changed": stats.files_changed,
"insertions": stats.insertions,
"deletions": stats.deletions,
"patch": patch_text,
"truncated": truncated,
}))
}
}