622 lines
21 KiB
Rust
622 lines
21 KiB
Rust
//! Git commit-related tools.
|
|
|
|
use super::ctx::GitToolCtx;
|
|
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
|
|
use chrono::TimeZone;
|
|
use std::collections::HashMap;
|
|
|
|
// --- Execution functions for each tool ---
|
|
|
|
async fn git_log_exec(
|
|
ctx: GitToolCtx,
|
|
args: serde_json::Value,
|
|
) -> Result<serde_json::Value, String> {
|
|
let p: serde_json::Map<String, serde_json::Value> =
|
|
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
|
let project_name = p
|
|
.get("project_name")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("missing project_name")?;
|
|
let repo_name = p
|
|
.get("repo_name")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("missing repo_name")?;
|
|
let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
|
|
let skip = p.get("skip").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
|
|
|
let domain = ctx.open_repo(project_name, repo_name).await?;
|
|
let commits = domain
|
|
.commit_log(rev.as_deref(), skip, limit)
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
// Flatten to simple JSON
|
|
let result: Vec<_> = commits
|
|
.iter()
|
|
.map(|c| {
|
|
use chrono::TimeZone;
|
|
let ts = c.author.time_secs - (c.author.offset_minutes as i64 * 60);
|
|
let time_str = chrono::Utc
|
|
.timestamp_opt(ts, 0)
|
|
.single()
|
|
.map(|dt| dt.to_rfc3339())
|
|
.unwrap_or_else(|| format!("{}", c.author.time_secs));
|
|
|
|
let oid = c.oid.to_string();
|
|
let short_oid = oid.get(..7).unwrap_or(&oid).to_string();
|
|
|
|
serde_json::json!({
|
|
"oid": oid,
|
|
"short_oid": short_oid,
|
|
"message": c.message,
|
|
"summary": c.summary,
|
|
"author_name": c.author.name,
|
|
"author_email": c.author.email,
|
|
"author_time": time_str,
|
|
"committer_name": c.committer.name,
|
|
"committer_email": c.committer.email,
|
|
"parent_oids": c.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>(),
|
|
"tree_oid": c.tree_id.to_string()
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
|
|
}
|
|
|
|
/// Resolve a rev string to commit metadata using the full rev-parse machinery
|
|
/// (branch names, tags, HEAD, hex prefixes, etc.).
|
|
fn resolve_commit(
|
|
domain: &git::GitDomain,
|
|
rev: &str,
|
|
) -> Result<git::commit::types::CommitMeta, String> {
|
|
let oid = domain.resolve_rev(rev).map_err(|e| e.to_string())?;
|
|
domain.commit_get(&oid).map_err(|e| e.to_string())
|
|
}
|
|
|
|
async fn git_show_exec(
|
|
ctx: GitToolCtx,
|
|
args: serde_json::Value,
|
|
) -> Result<serde_json::Value, String> {
|
|
let p: serde_json::Map<String, serde_json::Value> =
|
|
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
|
let project_name = p
|
|
.get("project_name")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("missing project_name")?;
|
|
let repo_name = p
|
|
.get("repo_name")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("missing repo_name")?;
|
|
let rev = p.get("rev").and_then(|v| v.as_str()).ok_or("missing rev")?;
|
|
|
|
let domain = ctx.open_repo(project_name, repo_name).await?;
|
|
let meta = resolve_commit(&domain, rev).map_err(|e| e.to_string())?;
|
|
|
|
let refs = domain.commit_refs(&meta.oid).map_err(|e| e.to_string())?;
|
|
|
|
use chrono::TimeZone;
|
|
let ts = meta.author.time_secs - (meta.author.offset_minutes as i64 * 60);
|
|
let author_time = chrono::Utc
|
|
.timestamp_opt(ts, 0)
|
|
.single()
|
|
.map(|dt| dt.to_rfc3339())
|
|
.unwrap_or_else(|| format!("{}", meta.author.time_secs));
|
|
|
|
let oid = meta.oid.to_string();
|
|
let short_oid = oid.get(..7).unwrap_or(&oid).to_string();
|
|
|
|
Ok(serde_json::json!({
|
|
"commit": {
|
|
"oid": oid, "short_oid": short_oid, "message": meta.message, "summary": meta.summary,
|
|
"author_name": meta.author.name, "author_email": meta.author.email, "author_time": author_time,
|
|
"committer_name": meta.committer.name, "committer_email": meta.committer.email,
|
|
"parent_oids": meta.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>(),
|
|
"tree_oid": meta.tree_id.to_string()
|
|
},
|
|
"refs": refs.into_iter().map(|r| serde_json::json!({ "name": r.name, "is_tag": r.is_tag })).collect::<Vec<_>>()
|
|
}))
|
|
}
|
|
|
|
async fn git_search_commits_exec(
|
|
ctx: GitToolCtx,
|
|
args: serde_json::Value,
|
|
) -> Result<serde_json::Value, String> {
|
|
let p: serde_json::Map<String, serde_json::Value> =
|
|
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
|
let project_name = p
|
|
.get("project_name")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("missing project_name")?;
|
|
let repo_name = p
|
|
.get("repo_name")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("missing repo_name")?;
|
|
let query = p
|
|
.get("query")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("missing query")?;
|
|
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
|
|
|
|
let domain = ctx.open_repo(project_name, repo_name).await?;
|
|
// Fetch extra commits to have enough candidates after filtering
|
|
let walk_limit = limit.saturating_mul(2).max(100);
|
|
let commits = domain
|
|
.commit_log(Some("HEAD"), 0, walk_limit)
|
|
.map_err(|e| e.to_string())?;
|
|
let q = query.to_lowercase();
|
|
|
|
let result: Vec<_> = commits
|
|
.iter()
|
|
.filter(|c| c.message.to_lowercase().contains(&q))
|
|
.take(limit)
|
|
.map(|c| flatten_commit(c))
|
|
.collect();
|
|
|
|
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
|
|
}
|
|
|
|
fn flatten_commit(c: &git::commit::types::CommitMeta) -> serde_json::Value {
|
|
use chrono::TimeZone;
|
|
let ts = c.author.time_secs - (c.author.offset_minutes as i64 * 60);
|
|
let author_time = chrono::Utc
|
|
.timestamp_opt(ts, 0)
|
|
.single()
|
|
.map(|dt| dt.to_rfc3339())
|
|
.unwrap_or_else(|| format!("{}", c.author.time_secs));
|
|
let oid = c.oid.to_string();
|
|
serde_json::json!({
|
|
"oid": oid.clone(),
|
|
"short_oid": oid.get(..7).unwrap_or(&oid).to_string(),
|
|
"message": c.message, "summary": c.summary,
|
|
"author_name": c.author.name, "author_email": c.author.email, "author_time": author_time,
|
|
"committer_name": c.committer.name, "committer_email": c.committer.email,
|
|
"parent_oids": c.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>(),
|
|
"tree_oid": c.tree_id.to_string()
|
|
})
|
|
}
|
|
|
|
async fn git_commit_info_exec(
|
|
ctx: GitToolCtx,
|
|
args: serde_json::Value,
|
|
) -> Result<serde_json::Value, String> {
|
|
let p: serde_json::Map<String, serde_json::Value> =
|
|
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
|
let project_name = p
|
|
.get("project_name")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("missing project_name")?;
|
|
let repo_name = p
|
|
.get("repo_name")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("missing repo_name")?;
|
|
let rev = p.get("rev").and_then(|v| v.as_str()).ok_or("missing rev")?;
|
|
|
|
let domain = ctx.open_repo(project_name, repo_name).await?;
|
|
let meta = resolve_commit(&domain, rev).map_err(|e| e.to_string())?;
|
|
|
|
Ok(flatten_commit(&meta))
|
|
}
|
|
|
|
async fn git_graph_exec(
|
|
ctx: GitToolCtx,
|
|
args: serde_json::Value,
|
|
) -> Result<serde_json::Value, String> {
|
|
let p: serde_json::Map<String, serde_json::Value> =
|
|
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
|
let project_name = p
|
|
.get("project_name")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("missing project_name")?;
|
|
let repo_name = p
|
|
.get("repo_name")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("missing repo_name")?;
|
|
let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
|
|
|
|
let domain = ctx.open_repo(project_name, repo_name).await?;
|
|
let commits = domain
|
|
.commit_log(rev.as_deref(), 0, limit)
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let mut col_map: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
|
let lines: Vec<_> = commits
|
|
.iter()
|
|
.map(|m| {
|
|
let lane_index = *col_map.get(m.oid.as_str()).unwrap_or(&0);
|
|
let oid = m.oid.to_string();
|
|
let refs = match domain.commit_refs(&m.oid) {
|
|
Ok(refs) => refs
|
|
.iter()
|
|
.map(|r| {
|
|
if r.is_tag {
|
|
format!("tag: {}", r.name.trim_start_matches("refs/tags/"))
|
|
} else if r.is_remote {
|
|
r.name.trim_start_matches("refs/remotes/").to_string()
|
|
} else {
|
|
r.name.trim_start_matches("refs/heads/").to_string()
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(", "),
|
|
Err(_) => String::new(),
|
|
};
|
|
for (i, p) in m.parent_ids.iter().enumerate() {
|
|
if i == 0 {
|
|
col_map.insert(p.to_string(), lane_index);
|
|
} else {
|
|
col_map.remove(p.as_str());
|
|
}
|
|
}
|
|
let ts = m.author.time_secs - (m.author.offset_minutes as i64 * 60);
|
|
let author_time = chrono::Utc
|
|
.timestamp_opt(ts, 0)
|
|
.single()
|
|
.map(|dt| dt.to_rfc3339())
|
|
.unwrap_or_else(|| format!("{}", m.author.time_secs));
|
|
|
|
serde_json::json!({
|
|
"oid": oid.clone(),
|
|
"short_oid": oid.get(..7).unwrap_or(&oid).to_string(),
|
|
"refs": refs,
|
|
"short_message": m.summary,
|
|
"lane_index": lane_index,
|
|
"author_name": m.author.name,
|
|
"author_email": m.author.email,
|
|
"author_time": author_time,
|
|
"parent_oids": m.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>()
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(serde_json::to_value(lines).map_err(|e| e.to_string())?)
|
|
}
|
|
|
|
async fn git_reflog_exec(
|
|
ctx: GitToolCtx,
|
|
args: serde_json::Value,
|
|
) -> Result<serde_json::Value, String> {
|
|
let p: serde_json::Map<String, serde_json::Value> =
|
|
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
|
let project_name = p
|
|
.get("project_name")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("missing project_name")?;
|
|
let repo_name = p
|
|
.get("repo_name")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("missing repo_name")?;
|
|
let ref_name = p
|
|
.get("ref_name")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string());
|
|
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
|
|
|
|
let domain = ctx.open_repo(project_name, repo_name).await?;
|
|
let entries = domain
|
|
.reflog_entries(ref_name.as_deref())
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let result: Vec<_> = entries
|
|
.iter()
|
|
.take(limit)
|
|
.map(|e| {
|
|
// Convert to UTC by subtracting the timezone offset, consistent
|
|
// with all other timestamp conversions in this module.
|
|
let ts = e.time_secs - (e.offset_minutes as i64 * 60);
|
|
let time_str = chrono::Utc
|
|
.timestamp_opt(ts, 0)
|
|
.single()
|
|
.map(|dt| dt.to_rfc3339())
|
|
.unwrap_or_else(|| format!("{}", e.time_secs));
|
|
serde_json::json!({
|
|
"oid_new": e.oid_new.to_string(), "oid_old": e.oid_old.to_string(),
|
|
"committer_name": e.committer_name, "committer_email": e.committer_email,
|
|
"time": time_str, "message": e.message, "ref_name": e.ref_name
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
|
|
}
|
|
|
|
/// Common required params used across all git tools.
|
|
fn common_params() -> HashMap<String, ToolParam> {
|
|
HashMap::from([
|
|
(
|
|
"project_name".into(),
|
|
ToolParam {
|
|
name: "project_name".into(),
|
|
param_type: "string".into(),
|
|
description: Some("Project name (slug)".into()),
|
|
required: true,
|
|
properties: None,
|
|
items: None,
|
|
},
|
|
),
|
|
(
|
|
"repo_name".into(),
|
|
ToolParam {
|
|
name: "repo_name".into(),
|
|
param_type: "string".into(),
|
|
description: Some("Repository name".into()),
|
|
required: true,
|
|
properties: None,
|
|
items: None,
|
|
},
|
|
),
|
|
])
|
|
}
|
|
|
|
pub fn register_git_tools(registry: &mut ToolRegistry) {
|
|
// git_log
|
|
let mut p = common_params();
|
|
p.insert(
|
|
"rev".into(),
|
|
ToolParam {
|
|
name: "rev".into(),
|
|
param_type: "string".into(),
|
|
description: Some("Revision/range specifier (branch name, commit hash, etc.)".into()),
|
|
required: false,
|
|
properties: None,
|
|
items: None,
|
|
},
|
|
);
|
|
p.insert(
|
|
"limit".into(),
|
|
ToolParam {
|
|
name: "limit".into(),
|
|
param_type: "integer".into(),
|
|
description: Some("Maximum number of commits to return".into()),
|
|
required: false,
|
|
properties: None,
|
|
items: None,
|
|
},
|
|
);
|
|
p.insert(
|
|
"skip".into(),
|
|
ToolParam {
|
|
name: "skip".into(),
|
|
param_type: "integer".into(),
|
|
description: Some("Number of commits to skip".into()),
|
|
required: false,
|
|
properties: None,
|
|
items: None,
|
|
},
|
|
);
|
|
let schema = ToolSchema {
|
|
schema_type: "object".into(),
|
|
properties: Some(p),
|
|
required: Some(vec!["project_name".into(), "repo_name".into()]),
|
|
};
|
|
registry.register(
|
|
ToolDefinition::new("git_log")
|
|
.description("List commits in a repository, optionally filtered by revision range.")
|
|
.parameters(schema),
|
|
ToolHandler::new(|ctx, args| {
|
|
let gctx = super::ctx::GitToolCtx::new(ctx);
|
|
Box::pin(async move {
|
|
git_log_exec(gctx, args)
|
|
.await
|
|
.map_err(agent::ToolError::ExecutionError)
|
|
})
|
|
}),
|
|
);
|
|
|
|
// git_show
|
|
let mut p = common_params();
|
|
p.insert(
|
|
"rev".into(),
|
|
ToolParam {
|
|
name: "rev".into(),
|
|
param_type: "string".into(),
|
|
description: Some("Revision to show (commit hash, branch, tag)".into()),
|
|
required: true,
|
|
properties: None,
|
|
items: None,
|
|
},
|
|
);
|
|
let schema = ToolSchema {
|
|
schema_type: "object".into(),
|
|
properties: Some(p),
|
|
required: Some(vec![
|
|
"project_name".into(),
|
|
"repo_name".into(),
|
|
"rev".into(),
|
|
]),
|
|
};
|
|
registry.register(
|
|
ToolDefinition::new("git_show")
|
|
.description(
|
|
"Show detailed commit information including message, author, refs, and diff stats.",
|
|
)
|
|
.parameters(schema),
|
|
ToolHandler::new(|ctx, args| {
|
|
let gctx = super::ctx::GitToolCtx::new(ctx);
|
|
Box::pin(async move {
|
|
git_show_exec(gctx, args)
|
|
.await
|
|
.map_err(agent::ToolError::ExecutionError)
|
|
})
|
|
}),
|
|
);
|
|
|
|
// git_search_commits
|
|
let mut p = common_params();
|
|
p.insert(
|
|
"query".into(),
|
|
ToolParam {
|
|
name: "query".into(),
|
|
param_type: "string".into(),
|
|
description: Some("Keyword to search in commit messages".into()),
|
|
required: true,
|
|
properties: None,
|
|
items: None,
|
|
},
|
|
);
|
|
p.insert(
|
|
"limit".into(),
|
|
ToolParam {
|
|
name: "limit".into(),
|
|
param_type: "integer".into(),
|
|
description: Some("Maximum results to return".into()),
|
|
required: false,
|
|
properties: None,
|
|
items: None,
|
|
},
|
|
);
|
|
let schema = ToolSchema {
|
|
schema_type: "object".into(),
|
|
properties: Some(p),
|
|
required: Some(vec![
|
|
"project_name".into(),
|
|
"repo_name".into(),
|
|
"query".into(),
|
|
]),
|
|
};
|
|
registry.register(
|
|
ToolDefinition::new("git_search_commits")
|
|
.description("Search commit messages for a keyword and return matching commits.")
|
|
.parameters(schema),
|
|
ToolHandler::new(|ctx, args| {
|
|
let gctx = super::ctx::GitToolCtx::new(ctx);
|
|
Box::pin(async move {
|
|
git_search_commits_exec(gctx, args)
|
|
.await
|
|
.map_err(agent::ToolError::ExecutionError)
|
|
})
|
|
}),
|
|
);
|
|
|
|
// git_commit_info
|
|
let mut p = common_params();
|
|
p.insert(
|
|
"rev".into(),
|
|
ToolParam {
|
|
name: "rev".into(),
|
|
param_type: "string".into(),
|
|
description: Some(
|
|
"Revision to look up (full or short commit hash, branch, tag)".into(),
|
|
),
|
|
required: true,
|
|
properties: None,
|
|
items: None,
|
|
},
|
|
);
|
|
let schema = ToolSchema {
|
|
schema_type: "object".into(),
|
|
properties: Some(p),
|
|
required: Some(vec![
|
|
"project_name".into(),
|
|
"repo_name".into(),
|
|
"rev".into(),
|
|
]),
|
|
};
|
|
registry.register(
|
|
ToolDefinition::new("git_commit_info")
|
|
.description(
|
|
"Get detailed metadata for a specific commit (author, committer, parents, tree).",
|
|
)
|
|
.parameters(schema),
|
|
ToolHandler::new(|ctx, args| {
|
|
let gctx = super::ctx::GitToolCtx::new(ctx);
|
|
Box::pin(async move {
|
|
git_commit_info_exec(gctx, args)
|
|
.await
|
|
.map_err(agent::ToolError::ExecutionError)
|
|
})
|
|
}),
|
|
);
|
|
|
|
// git_graph
|
|
let mut p = common_params();
|
|
p.insert(
|
|
"rev".into(),
|
|
ToolParam {
|
|
name: "rev".into(),
|
|
param_type: "string".into(),
|
|
description: Some("Starting revision (default: HEAD)".into()),
|
|
required: false,
|
|
properties: None,
|
|
items: None,
|
|
},
|
|
);
|
|
p.insert(
|
|
"limit".into(),
|
|
ToolParam {
|
|
name: "limit".into(),
|
|
param_type: "integer".into(),
|
|
description: Some("Maximum number of commits (default: 20)".into()),
|
|
required: false,
|
|
properties: None,
|
|
items: None,
|
|
},
|
|
);
|
|
let schema = ToolSchema {
|
|
schema_type: "object".into(),
|
|
properties: Some(p),
|
|
required: Some(vec!["project_name".into(), "repo_name".into()]),
|
|
};
|
|
registry.register(
|
|
ToolDefinition::new("git_graph")
|
|
.description("Show an ASCII commit graph with branch lanes and refs.")
|
|
.parameters(schema),
|
|
ToolHandler::new(|ctx, args| {
|
|
let gctx = super::ctx::GitToolCtx::new(ctx);
|
|
Box::pin(async move {
|
|
git_graph_exec(gctx, args)
|
|
.await
|
|
.map_err(agent::ToolError::ExecutionError)
|
|
})
|
|
}),
|
|
);
|
|
|
|
// git_reflog
|
|
let mut p = common_params();
|
|
p.insert(
|
|
"ref_name".into(),
|
|
ToolParam {
|
|
name: "ref_name".into(),
|
|
param_type: "string".into(),
|
|
description: Some(
|
|
"Reference name (e.g. refs/heads/main). Defaults to all refs.".into(),
|
|
),
|
|
required: false,
|
|
properties: None,
|
|
items: None,
|
|
},
|
|
);
|
|
p.insert(
|
|
"limit".into(),
|
|
ToolParam {
|
|
name: "limit".into(),
|
|
param_type: "integer".into(),
|
|
description: Some("Maximum number of entries (default: 50)".into()),
|
|
required: false,
|
|
properties: None,
|
|
items: None,
|
|
},
|
|
);
|
|
let schema = ToolSchema {
|
|
schema_type: "object".into(),
|
|
properties: Some(p),
|
|
required: Some(vec!["project_name".into(), "repo_name".into()]),
|
|
};
|
|
registry.register(
|
|
ToolDefinition::new("git_reflog")
|
|
.description(
|
|
"Show the reference log (reflog) recording when branch tips and refs were updated.",
|
|
)
|
|
.parameters(schema),
|
|
ToolHandler::new(|ctx, args| {
|
|
let gctx = super::ctx::GitToolCtx::new(ctx);
|
|
Box::pin(async move {
|
|
git_reflog_exec(gctx, args)
|
|
.await
|
|
.map_err(agent::ToolError::ExecutionError)
|
|
})
|
|
}),
|
|
);
|
|
}
|