diff --git a/libs/service/git_tools/branch.rs b/libs/service/git_tools/branch.rs new file mode 100644 index 0000000..2b7518a --- /dev/null +++ b/libs/service/git_tools/branch.rs @@ -0,0 +1,93 @@ +//! Git branch tools. + +use super::ctx::GitToolCtx; +use agent::ToolRegistry; + +async fn git_branch_list_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + let p: serde_json::Map = 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 remote_only = p.get("remote_only").and_then(|v| v.as_bool()).unwrap_or(false); + + let domain = ctx.open_repo(project_name, repo_name).await?; + let branches = domain.branch_list(remote_only).map_err(|e| e.to_string())?; + + let result: Vec<_> = branches.iter().map(|b| { + let oid = b.oid.to_string(); + serde_json::json!({ + "name": b.name, "oid": oid.clone(), "short_oid": oid.get(..7).unwrap_or(&oid).to_string(), + "is_head": b.is_head, "is_remote": b.is_remote, "is_current": b.is_current, + "upstream": b.upstream.clone() + }) + }).collect(); + + Ok(serde_json::to_value(result).map_err(|e| e.to_string())?) +} + +async fn git_branch_info_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + let p: serde_json::Map = 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 name = p.get("name").and_then(|v| v.as_str()).ok_or("missing name")?; + + let domain = ctx.open_repo(project_name, repo_name).await?; + let info = domain.branch_get(name).map_err(|e| e.to_string())?; + + let ahead_behind = if let Some(ref upstream) = info.upstream { + let (ahead, behind) = domain.branch_ahead_behind(name, upstream).unwrap_or((0, 0)); + Some(serde_json::json!({ "ahead": ahead, "behind": behind })) + } else { None }; + + Ok(serde_json::json!({ + "branch": { "name": info.name, "oid": info.oid.to_string(), "is_head": info.is_head, + "is_remote": info.is_remote, "is_current": info.is_current, "upstream": info.upstream }, + "ahead_behind": ahead_behind + })) +} + +async fn git_branches_merged_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + let p: serde_json::Map = 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 branch = p.get("branch").and_then(|v| v.as_str()).ok_or("missing branch")?; + let into = p.get("into").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "main".to_string()); + + let domain = ctx.open_repo(project_name, repo_name).await?; + let is_merged = domain.branch_is_merged(branch, &into).map_err(|e| e.to_string())?; + let merge_base = domain.merge_base(&git::commit::types::CommitOid::new(branch), &git::commit::types::CommitOid::new(&into)) + .map(|oid| oid.to_string()).ok(); + + Ok(serde_json::json!({ "branch": branch, "into": into, "is_merged": is_merged, "merge_base": merge_base })) +} + +async fn git_branch_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + let p: serde_json::Map = 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 local = p.get("local").and_then(|v| v.as_str()).ok_or("missing local")?; + let remote = p.get("remote").and_then(|v| v.as_str()).unwrap_or(local).to_string(); + + let domain = ctx.open_repo(project_name, repo_name).await?; + let diff = domain.branch_diff(local, &remote).map_err(|e| e.to_string())?; + + Ok(serde_json::json!({ "ahead": diff.ahead, "behind": diff.behind, "diverged": diff.diverged })) +} + +macro_rules! register_fn { + ($registry:expr, $name:expr, $exec:expr) => { + let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move { + let gctx = super::ctx::GitToolCtx::new(ctx); + $exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) + }; + $registry.register_fn($name, handler_fn); + }; +} + +pub fn register_git_tools(registry: &mut ToolRegistry) { + register_fn!(registry, "git_branch_list", git_branch_list_exec); + register_fn!(registry, "git_branch_info", git_branch_info_exec); + register_fn!(registry, "git_branches_merged", git_branches_merged_exec); + register_fn!(registry, "git_branch_diff", git_branch_diff_exec); +} \ No newline at end of file diff --git a/libs/service/git_tools/commit.rs b/libs/service/git_tools/commit.rs new file mode 100644 index 0000000..866be0c --- /dev/null +++ b/libs/service/git_tools/commit.rs @@ -0,0 +1,230 @@ +//! Git commit-related tools. + +use super::ctx::GitToolCtx; +use agent::ToolRegistry; +use chrono::TimeZone; + +// --- Execution functions for each tool --- + +async fn git_log_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + let p: serde_json::Map = 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::>(), + "tree_oid": c.tree_id.to_string() + }) + }).collect(); + + Ok(serde_json::to_value(result).map_err(|e| e.to_string())?) +} + +async fn git_show_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + let p: serde_json::Map = 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 = if rev.len() >= 40 { + domain.commit_get(&git::commit::types::CommitOid::new(rev)).map_err(|e| e.to_string())? + } else { + domain.commit_get_prefix(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::>(), + "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::>() + })) +} + +async fn git_search_commits_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + let p: serde_json::Map = 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?; + let commits = domain.commit_log(Some("HEAD"), 0, 100).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::>(), + "tree_oid": c.tree_id.to_string() + }) +} + +async fn git_commit_info_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + let p: serde_json::Map = 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 = if rev.len() >= 40 { + domain.commit_get(&git::commit::types::CommitOid::new(rev)).map_err(|e| e.to_string())? + } else { + domain.commit_get_prefix(rev).map_err(|e| e.to_string())? + }; + + Ok(flatten_commit(&meta)) +} + +async fn git_graph_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + let p: serde_json::Map = 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 = 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::>().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::>() + }) + }).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 { + let p: serde_json::Map = 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| { + let ts = e.time_secs; + let time_str = chrono::Utc.timestamp_opt(ts, 0).single() + .map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", ts)); + 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())?) +} + +// --- Registration macro --- + +macro_rules! register_fn { + ($registry:expr, $name:expr, $exec:expr) => { + let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move { + let gctx = super::ctx::GitToolCtx::new(ctx); + $exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) + }; + $registry.register_fn($name, handler_fn); + }; +} + +pub fn register_git_tools(registry: &mut ToolRegistry) { + register_fn!(registry, "git_log", git_log_exec); + register_fn!(registry, "git_show", git_show_exec); + register_fn!(registry, "git_search_commits", git_search_commits_exec); + register_fn!(registry, "git_commit_info", git_commit_info_exec); + register_fn!(registry, "git_graph", git_graph_exec); + register_fn!(registry, "git_reflog", git_reflog_exec); +} diff --git a/libs/service/git_tools/ctx.rs b/libs/service/git_tools/ctx.rs new file mode 100644 index 0000000..9bd126a --- /dev/null +++ b/libs/service/git_tools/ctx.rs @@ -0,0 +1,53 @@ +//! Context wrapper for git tool handlers. +//! +//! Provides `GitToolCtx` which wraps `ToolContext` and adds git-domain operations. + +use agent::ToolContext; +use git::GitDomain; +use models::projects::project; +use models::repos::repo; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + +/// Wrapper around `ToolContext` providing git-domain operations for tool handlers. +#[derive(Clone)] +pub struct GitToolCtx { + pub ctx: ToolContext, +} + +impl GitToolCtx { + pub fn new(ctx: ToolContext) -> Self { + Self { ctx } + } + + /// Opens a git repository by project name and repo name. + pub async fn open_repo(&self, project_name: &str, repo_name: &str) -> Result { + let db = self.ctx.db(); + resolve_project_and_repo(db, project_name, repo_name) + .await + .and_then(|(_, path)| GitDomain::open(&path).map_err(|e| e.to_string())) + } +} + +/// Free helper to resolve project_id + storage_path from names. Used by registry. +async fn resolve_project_and_repo( + db: &db::database::AppDatabase, + project_name: &str, + repo_name: &str, +) -> Result<(uuid::Uuid, String), String> { + let project = project::Entity::find() + .filter(project::Column::Name.eq(project_name)) + .one(db) + .await + .map_err(|e| format!("DB error looking up project '{}': {}", project_name, e))? + .ok_or_else(|| format!("project '{}' not found", project_name))?; + + let repo_model = repo::Entity::find() + .filter(repo::Column::Project.eq(project.id)) + .filter(repo::Column::RepoName.eq(repo_name)) + .one(db) + .await + .map_err(|e| format!("DB error looking up repo '{}/{}': {}", project_name, repo_name, e))? + .ok_or_else(|| format!("repo '{}/{}' not found", project_name, repo_name))?; + + Ok((project.id, repo_model.storage_path)) +} diff --git a/libs/service/git_tools/diff.rs b/libs/service/git_tools/diff.rs new file mode 100644 index 0000000..c47e4fe --- /dev/null +++ b/libs/service/git_tools/diff.rs @@ -0,0 +1,147 @@ +//! Git diff and blame tools. + +use super::ctx::GitToolCtx; +use agent::ToolRegistry; + +async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + let p: serde_json::Map = 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 base = p.get("base").and_then(|v| v.as_str()).map(|s| s.to_string()); + let head = p.get("head").and_then(|v| v.as_str()).map(|s| s.to_string()); + let paths = p.get("paths").and_then(|v| v.as_array()).map(|a| { + a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect::>() + }); + + let domain = ctx.open_repo(project_name, repo_name).await?; + + let resolve = |rev: &str| -> Result { + if rev.len() >= 40 { + Ok(git::commit::types::CommitOid::new(rev)) + } else { + domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid) + } + }; + + let base_oid = match &base { + Some(b) => Some(resolve(b)?), + None => None, + }; + let head_oid = match &head { + Some(h) => Some(resolve(h)?), + None => None, + }; + + let opts = paths.map(|ps| { + let mut o = git::diff::types::DiffOptions::new(); + for p in ps { o = o.pathspec(&p); } + Some(o) + }).flatten(); + + let result = match (&base_oid, &head_oid) { + (None, None) => { + let head_meta = domain.commit_get_prefix("HEAD").map_err(|e| e.to_string())?; + domain.diff_commit_to_workdir(&head_meta.oid, opts).map_err(|e| e.to_string())? + } + (Some(base), None) => { + domain.diff_commit_to_workdir(base, opts).map_err(|e| e.to_string())? + } + (Some(base), Some(head_oid_val)) => { + domain.diff_tree_to_tree(Some(base), Some(head_oid_val), opts).map_err(|e| e.to_string())? + } + (None, Some(_)) => { + return Err("base revision required when head is specified".into()); + } + }; + + let files: Vec<_> = result.deltas.iter().map(|d| { + serde_json::json!({ "path": d.new_file.path, "status": format!("{:?}", d.status), "is_binary": d.new_file.is_binary }) + }).collect(); + + Ok(serde_json::json!({ + "stats": { "files_changed": result.stats.files_changed, "insertions": result.stats.insertions, "deletions": result.stats.deletions }, + "files": files + })) +} + +async fn git_diff_stats_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + let p: serde_json::Map = 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 base = p.get("base").and_then(|v| v.as_str()).ok_or("missing base")?; + let head = p.get("head").and_then(|v| v.as_str()).ok_or("missing head")?; + + let domain = ctx.open_repo(project_name, repo_name).await?; + + let stats = if base.len() >= 40 || head.len() >= 40 { + domain.diff_stats(&git::commit::types::CommitOid::new(base), &git::commit::types::CommitOid::new(head)) + .map_err(|e| e.to_string())? + } else { + let b = domain.commit_get_prefix(base).map_err(|e| e.to_string())?.oid; + let h = domain.commit_get_prefix(head).map_err(|e| e.to_string())?.oid; + domain.diff_stats(&b, &h).map_err(|e| e.to_string())? + }; + + Ok(serde_json::json!({ + "files_changed": stats.files_changed, + "insertions": stats.insertions, + "deletions": stats.deletions + })) +} + +async fn git_blame_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + let p: serde_json::Map = 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 path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?; + let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string()); + let from_line = p.get("from_line").and_then(|v| v.as_u64().map(|n| n as u32)); + let to_line = p.get("to_line").and_then(|v| v.as_u64().map(|n| n as u32)); + + let domain = ctx.open_repo(project_name, repo_name).await?; + let oid = if rev.len() >= 40 { + git::commit::types::CommitOid::new(&rev) + } else { + domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid + }; + + use git::blame::ops::BlameOptions; + let mut bopts = BlameOptions::new(); + if let Some(fl) = from_line { bopts = bopts.min_line(fl as usize); } + if let Some(tl) = to_line { bopts = bopts.max_line(tl as usize); } + + let hunks = domain.blame_file(&oid, path, Some(bopts)).map_err(|e| e.to_string())?; + + let result: Vec<_> = hunks.iter().map(|h| { + let oid = h.commit_oid.to_string(); + serde_json::json!({ + "commit_oid": oid.clone(), + "short_oid": oid.get(..7).unwrap_or(&oid).to_string(), + "final_start_line": h.final_start_line, + "final_lines": h.final_lines, + "orig_start_line": h.orig_start_line, + "orig_path": h.orig_path, + "boundary": h.boundary + }) + }).collect(); + + Ok(serde_json::to_value(result).map_err(|e| e.to_string())?) +} + +macro_rules! register_fn { + ($registry:expr, $name:expr, $exec:expr) => { + let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move { + let gctx = super::ctx::GitToolCtx::new(ctx); + $exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) + }; + $registry.register_fn($name, handler_fn); + }; +} + +pub fn register_git_tools(registry: &mut ToolRegistry) { + register_fn!(registry, "git_diff", git_diff_exec); + register_fn!(registry, "git_diff_stats", git_diff_stats_exec); + register_fn!(registry, "git_blame", git_blame_exec); +} \ No newline at end of file diff --git a/libs/service/git_tools/mod.rs b/libs/service/git_tools/mod.rs new file mode 100644 index 0000000..f2f08ed --- /dev/null +++ b/libs/service/git_tools/mod.rs @@ -0,0 +1,21 @@ +//! Git tools for AI agent function calling. +//! +//! Each module defines async exec functions + a `register_git_tools()` call. +//! All tools take `project_name` + `repo_name` as required params. + +pub mod branch; +pub mod commit; +pub mod ctx; +pub mod diff; +pub mod tag; +pub mod tree; +pub mod types; + +/// Batch-register all git tools into a ToolRegistry. +pub fn register_all(registry: &mut agent::ToolRegistry) { + commit::register_git_tools(registry); + branch::register_git_tools(registry); + diff::register_git_tools(registry); + tree::register_git_tools(registry); + tag::register_git_tools(registry); +} diff --git a/libs/service/git_tools/tag.rs b/libs/service/git_tools/tag.rs new file mode 100644 index 0000000..a6331f7 --- /dev/null +++ b/libs/service/git_tools/tag.rs @@ -0,0 +1,73 @@ +//! Git tag tools. + +use super::ctx::GitToolCtx; +use agent::ToolRegistry; + +async fn git_tag_list_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + let p: serde_json::Map = 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 pattern = p.get("pattern").and_then(|v| v.as_str()).map(|s| s.to_string()); + + let domain = ctx.open_repo(project_name, repo_name).await?; + let all_tags = domain.tag_list().map_err(|e| e.to_string())?; + + let result: Vec<_> = match pattern { + Some(ref pat) => { + let pat_lower = pat.to_lowercase(); + let has_wildcard = pat.contains('*'); + all_tags.iter() + .filter(|t| { + let n = t.name.to_lowercase(); + if has_wildcard { n.contains(&pat_lower.replace('*', "")) } + else { n.contains(&pat_lower) } + }) + .map(|t| tag_to_json(t)) + .collect() + } + None => all_tags.into_iter().map(|t| tag_to_json(&t)).collect(), + }; + + Ok(serde_json::to_value(result).map_err(|e| e.to_string())?) +} + +fn tag_to_json(t: &git::tags::types::TagInfo) -> serde_json::Value { + serde_json::json!({ + "name": t.name, + "oid": t.oid.to_string(), + "target": t.target.to_string(), + "is_annotated": t.is_annotated, + "message": t.message.clone(), + "tagger_name": t.tagger.clone(), + "tagger_email": t.tagger_email.clone() + }) +} + +async fn git_tag_info_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + let p: serde_json::Map = 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 name = p.get("name").and_then(|v| v.as_str()).ok_or("missing name")?; + + let domain = ctx.open_repo(project_name, repo_name).await?; + let info = domain.tag_get(name).map_err(|e| e.to_string())?; + + Ok(tag_to_json(&info)) +} + +macro_rules! register_fn { + ($registry:expr, $name:expr, $exec:expr) => { + let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move { + let gctx = super::ctx::GitToolCtx::new(ctx); + $exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) + }; + $registry.register_fn($name, handler_fn); + }; +} + +pub fn register_git_tools(registry: &mut ToolRegistry) { + register_fn!(registry, "git_tag_list", git_tag_list_exec); + register_fn!(registry, "git_tag_info", git_tag_info_exec); +} \ No newline at end of file diff --git a/libs/service/git_tools/tree.rs b/libs/service/git_tools/tree.rs new file mode 100644 index 0000000..b474d7b --- /dev/null +++ b/libs/service/git_tools/tree.rs @@ -0,0 +1,126 @@ +//! Git tree and file tools. + +use super::ctx::GitToolCtx; +use agent::ToolRegistry; +use base64::Engine; + +async fn git_file_content_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + let p: serde_json::Map = 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 path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?; + let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string()); + + let domain = ctx.open_repo(project_name, repo_name).await?; + let oid = if rev.len() >= 40 { + git::commit::types::CommitOid::new(&rev) + } else { + domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid + }; + + let entry = domain.tree_entry_by_path_from_commit(&oid, path).map_err(|e| e.to_string())?; + let blob_info = domain.blob_get(&entry.oid).map_err(|e| e.to_string())?; + + let content = domain.blob_content(&entry.oid).map_err(|e| e.to_string())?; + let (display_content, is_binary) = if blob_info.is_binary { + (base64::engine::general_purpose::STANDARD.encode(&content.content), true) + } else { + (String::from_utf8_lossy(&content.content).to_string(), false) + }; + + Ok(serde_json::json!({ + "path": path, + "oid": entry.oid.to_string(), + "size": blob_info.size, + "content": display_content, + "is_binary": is_binary + })) +} + +async fn git_tree_ls_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + let p: serde_json::Map = 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 dir_path = p.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()); + let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string()); + + let domain = ctx.open_repo(project_name, repo_name).await?; + let commit_oid = if rev.len() >= 40 { + git::commit::types::CommitOid::new(&rev) + } else { + domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid + }; + + let entries = match dir_path { + Some(ref dp) => { + let entry = domain.tree_entry_by_path(&commit_oid, dp).map_err(|e| e.to_string())?; + domain.tree_list(&entry.oid).map_err(|e| e.to_string())? + } + None => domain.tree_list(&commit_oid).map_err(|e| e.to_string())?, + }; + + let result: Vec<_> = entries.iter().map(|e| { + serde_json::json!({ + "name": e.name, + "oid": e.oid.to_string(), + "kind": e.kind, + "is_binary": e.is_binary + }) + }).collect(); + + Ok(serde_json::to_value(result).map_err(|e| e.to_string())?) +} + +async fn git_file_history_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { + let p: serde_json::Map = 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 path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?; + 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(Some("HEAD"), 0, 500).map_err(|e| e.to_string())?; + + let result: Vec<_> = commits.iter() + .filter(|c| domain.tree_entry_by_path(&c.tree_id, path).is_ok()) + .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::>(), + "tree_oid": c.tree_id.to_string() + }) +} + +macro_rules! register_fn { + ($registry:expr, $name:expr, $exec:expr) => { + let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move { + let gctx = super::ctx::GitToolCtx::new(ctx); + $exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) + }; + $registry.register_fn($name, handler_fn); + }; +} + +pub fn register_git_tools(registry: &mut ToolRegistry) { + register_fn!(registry, "git_file_content", git_file_content_exec); + register_fn!(registry, "git_tree_ls", git_tree_ls_exec); + register_fn!(registry, "git_file_history", git_file_history_exec); +} \ No newline at end of file diff --git a/libs/service/git_tools/types.rs b/libs/service/git_tools/types.rs new file mode 100644 index 0000000..7f9f5c2 --- /dev/null +++ b/libs/service/git_tools/types.rs @@ -0,0 +1,411 @@ +//! Flat, JSON-friendly output types for git tools. +//! +//! These types convert from internal `git` crate types to clean, flat structures +//! suitable for JSON serialization (function call responses). + +use base64::Engine; +use chrono::TimeZone; +use git::commit::types::{CommitMeta, CommitReflogEntry}; +use git::diff::types::{DiffDelta, DiffStats}; +use git::tree::types::TreeEntry; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Parameter structs used by all tool modules +// --------------------------------------------------------------------------- + +#[derive(serde::Deserialize)] +pub struct RevQuery { pub rev: String } + +#[derive(serde::Deserialize)] +pub struct SearchCommits { pub query: String, #[serde(default = "dl")] pub limit: u32 } +fn dl() -> u32 { 20 } + +#[derive(serde::Deserialize)] +pub struct GraphParams { #[serde(default)] pub rev: Option, #[serde(default = "dl")] pub limit: u32 } + +#[derive(serde::Deserialize)] +pub struct ReflogParams { #[serde(default)] pub ref_name: Option, #[serde(default = "reflog_def")] pub limit: u32 } +fn reflog_def() -> u32 { 50 } + +#[derive(serde::Deserialize)] +pub struct SingleBranch { pub name: String } + +#[derive(serde::Deserialize)] +pub struct BranchesMerged { pub branch: String, #[serde(default)] pub into: Option } + +#[derive(serde::Deserialize)] +pub struct BranchDiffP { pub local: String, #[serde(default)] pub remote: Option } + +#[derive(serde::Deserialize)] +pub struct DiffP { #[serde(default)] pub base: Option, #[serde(default)] pub head: Option, #[serde(default)] pub paths: Option> } + +#[derive(serde::Deserialize)] +pub struct DiffStatsP { pub base: String, pub head: String } + +#[derive(serde::Deserialize)] +pub struct BlameP { pub path: String, #[serde(default)] pub rev: Option, #[serde(default)] pub from_line: Option, #[serde(default)] pub to_line: Option } + +#[derive(serde::Deserialize)] +pub struct FileContentP { pub path: String, #[serde(default)] pub rev: Option } + +#[derive(serde::Deserialize)] +pub struct TreeLsP { #[serde(default)] pub path: Option, #[serde(default)] pub rev: Option } + +#[derive(serde::Deserialize)] +pub struct FileHistoryP { pub path: String, #[serde(default = "dl")] pub limit: u32 } + +#[derive(serde::Deserialize)] +pub struct TagListP { #[serde(default)] pub pattern: Option } + +#[derive(serde::Deserialize)] +pub struct SingleTagP { pub name: String } + +#[derive(serde::Deserialize)] +pub struct GitLogP { #[serde(default)] pub rev: Option, #[serde(default = "dl")] pub limit: u32, #[serde(default)] pub skip: u32 } + +// --------------------------------------------------------------------------- +// Commit types +// --------------------------------------------------------------------------- + +/// Flat commit information for tool responses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommitInfo { + pub oid: String, + pub short_oid: String, + pub message: String, + pub summary: String, + pub author_name: String, + pub author_email: String, + pub author_time: String, + pub committer_name: String, + pub committer_email: String, + pub parent_oids: Vec, + pub tree_oid: String, +} + +impl CommitInfo { + pub fn from_meta(meta: &CommitMeta) -> Self { + let ts = meta.author.time_secs; + let offset = meta.author.offset_minutes; + let author_time = format_rfc3339(ts, offset); + + Self { + oid: meta.oid.to_string(), + short_oid: meta.oid.to_string().get(..7).unwrap_or(&meta.oid.to_string()).to_string(), + message: meta.message.clone(), + summary: meta.summary.clone(), + author_name: meta.author.name.clone(), + author_email: meta.author.email.clone(), + author_time, + committer_name: meta.committer.name.clone(), + committer_email: meta.committer.email.clone(), + parent_oids: meta.parent_ids.iter().map(|p| p.to_string()).collect(), + tree_oid: meta.tree_id.to_string(), + } + } +} + +/// Commit reflog entry for tool responses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReflogEntryInfo { + pub oid_new: String, + pub oid_old: String, + pub committer_name: String, + pub committer_email: String, + pub time: String, + pub message: Option, + pub ref_name: String, +} + +impl ReflogEntryInfo { + pub fn from_entry(entry: &CommitReflogEntry) -> Self { + let ts = entry.time_secs; + let time = format_rfc3339(ts, 0); + Self { + oid_new: entry.oid_new.to_string(), + oid_old: entry.oid_old.to_string(), + committer_name: entry.committer_name.clone(), + committer_email: entry.committer_email.clone(), + time, + message: entry.message.clone(), + ref_name: entry.ref_name.clone(), + } + } +} + +// --------------------------------------------------------------------------- +// Branch types +// --------------------------------------------------------------------------- + +/// Flat branch info for tool responses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BranchInfoOut { + pub name: String, + pub oid: String, + pub short_oid: String, + pub is_head: bool, + pub is_remote: bool, + pub is_current: bool, + pub upstream: Option, +} + +impl From<&git::branch::types::BranchInfo> for BranchInfoOut { + fn from(b: &git::branch::types::BranchInfo) -> Self { + let oid = b.oid.to_string(); + Self { + name: b.name.clone(), + oid: oid.clone(), + short_oid: oid.get(..7).unwrap_or(&oid).to_string(), + is_head: b.is_head, + is_remote: b.is_remote, + is_current: b.is_current, + upstream: b.upstream.clone(), + } + } +} + +/// Branch diff (ahead/behind) for tool responses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BranchDiffOut { + pub ahead: usize, + pub behind: usize, + pub diverged: bool, +} + +impl From<&git::branch::types::BranchDiff> for BranchDiffOut { + fn from(d: &git::branch::types::BranchDiff) -> Self { + Self { + ahead: d.ahead, + behind: d.behind, + diverged: d.diverged, + } + } +} + +// --------------------------------------------------------------------------- +// Diff types +// --------------------------------------------------------------------------- + +/// Diff statistics for tool responses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiffStatsOut { + pub files_changed: u32, + pub insertions: u32, + pub deletions: u32, +} + +impl From<&DiffStats> for DiffStatsOut { + fn from(s: &DiffStats) -> Self { + Self { + files_changed: s.files_changed as u32, + insertions: s.insertions as u32, + deletions: s.deletions as u32, + } + } +} + +/// A single diff file change for tool responses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiffFileOut { + pub path: Option, + pub status: String, + pub is_binary: bool, + pub size: u64, +} + +impl DiffFileOut { + pub fn from_delta(delta: &DiffDelta) -> Self { + Self { + path: delta.new_file.path.clone(), + status: format!("{:?}", delta.status), + is_binary: delta.new_file.is_binary, + size: delta.new_file.size, + } + } +} + +/// Diff summary (files + stats) for tool responses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiffOut { + pub stats: DiffStatsOut, + pub files: Vec, +} + +impl DiffOut { + pub fn from_result(result: &git::diff::types::DiffResult) -> Self { + let stats = DiffStatsOut::from(&result.stats); + let files = result.deltas.iter().map(DiffFileOut::from_delta).collect(); + Self { stats, files } + } +} + +/// Blame hunk for tool responses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlameHunkOut { + pub commit_oid: String, + pub short_oid: String, + pub final_start_line: u32, + pub final_lines: u32, + pub orig_start_line: u32, + pub orig_path: Option, + pub boundary: bool, +} + +impl From<&git::commit::types::CommitBlameHunk> for BlameHunkOut { + fn from(h: &git::commit::types::CommitBlameHunk) -> Self { + let oid = h.commit_oid.to_string(); + Self { + commit_oid: oid.clone(), + short_oid: oid.get(..7).unwrap_or(&oid).to_string(), + final_start_line: h.final_start_line, + final_lines: h.final_lines, + orig_start_line: h.orig_start_line, + orig_path: h.orig_path.clone(), + boundary: h.boundary, + } + } +} + +// --------------------------------------------------------------------------- +// Tree types +// --------------------------------------------------------------------------- + +/// Directory entry for tool responses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TreeLsEntry { + pub name: String, + pub oid: String, + pub kind: String, + pub is_binary: bool, +} + +impl From<&TreeEntry> for TreeLsEntry { + fn from(entry: &TreeEntry) -> Self { + Self { + name: entry.name.clone(), + oid: entry.oid.to_string(), + kind: entry.kind.clone(), + is_binary: entry.is_binary, + } + } +} + +/// File content for tool responses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileContentOut { + pub path: String, + pub oid: String, + pub size: u64, + pub content: String, + pub is_binary: bool, +} + +impl FileContentOut { + pub fn from_blob( + path: String, + oid: &git::commit::types::CommitOid, + content: &[u8], + is_binary: bool, + ) -> Self { + let (display_content, size) = if is_binary { + ( + base64::engine::general_purpose::STANDARD.encode(content), + content.len() as u64, + ) + } else { + ( + String::from_utf8_lossy(content).to_string(), + content.len() as u64, + ) + }; + + Self { + path, + oid: oid.to_string(), + size, + content: display_content, + is_binary, + } + } +} + +// --------------------------------------------------------------------------- +// Tag types +// --------------------------------------------------------------------------- + +/// Tag info for tool responses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TagInfoOut { + pub name: String, + pub oid: String, + pub target: String, + pub is_annotated: bool, + pub message: Option, + pub tagger_name: Option, + pub tagger_email: Option, +} + +impl From<&git::tags::types::TagInfo> for TagInfoOut { + fn from(t: &git::tags::types::TagInfo) -> Self { + Self { + name: t.name.clone(), + oid: t.oid.to_string(), + target: t.target.to_string(), + is_annotated: t.is_annotated, + message: t.message.clone(), + tagger_name: t.tagger.clone(), + tagger_email: t.tagger_email.clone(), + } + } +} + +// --------------------------------------------------------------------------- +// Graph types +// --------------------------------------------------------------------------- + +/// Commit graph line for tool responses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphLineOut { + pub oid: String, + pub short_oid: String, + pub refs: String, + pub short_message: String, + pub lane_index: usize, + pub author_name: String, + pub author_email: String, + pub author_time: String, + pub parent_oids: Vec, +} + +impl From<&git::commit::graph::CommitGraphLine> for GraphLineOut { + fn from(line: &git::commit::graph::CommitGraphLine) -> Self { + let oid = line.oid.to_string(); + let ts = line.meta.author.time_secs; + let offset = line.meta.author.offset_minutes; + Self { + oid: oid.clone(), + short_oid: oid.get(..7).unwrap_or(&oid).to_string(), + refs: line.refs.clone(), + short_message: line.short_message.clone(), + lane_index: line.lane_index, + author_name: line.meta.author.name.clone(), + author_email: line.meta.author.email.clone(), + author_time: format_rfc3339(ts, offset), + parent_oids: line.meta.parent_ids.iter().map(|p| p.to_string()).collect(), + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn format_rfc3339(time_secs: i64, offset_minutes: i32) -> String { + let secs = time_secs + (offset_minutes as i64 * 60); + chrono::Utc + .timestamp_opt(secs, 0) + .single() + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| format!("{}", time_secs)) +}