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::commit_service_client::CommitServiceClient; use serde_json::{json, Value}; use super::helpers::{arg_str, arg_opt_str, arg_u64, git_ctx, require_repo_member, rpc_err}; use crate::agent::run::AppAgentContext; pub struct GitCommitHistoryTool; impl GitCommitHistoryTool { pub fn new() -> Self { Self } } impl Default for GitCommitHistoryTool { fn default() -> Self { Self::new() } } #[async_trait] impl FunctionCall for GitCommitHistoryTool { type Context = AppAgentContext; fn name(&self) -> &'static str { "git_commit_history" } fn description(&self) -> &'static str { "List recent commits on a branch. Returns commit OID, message, author, and timestamp." } fn schema(&self) -> Value { json!({ "type": "object", "properties": { "workspace": { "type": "string", "description": "Workspace name (e.g. 'my-org')" }, "repo": { "type": "string", "description": "Repository name" }, "branch": { "type": "string", "description": "Branch name (optional, defaults to default branch)" }, "limit": { "type": "integer", "description": "Max commits to return (default 20, max 100)" }, "skip": { "type": "integer", "description": "Number of commits to skip (for pagination)" } }, "required": ["workspace", "repo"] }) } async fn call(&self, ctx: &mut AppAgentContext, args: Value) -> AiResult { let git = git_ctx(ctx)?; let workspace = arg_str(&args, "workspace")?; let repo_name = arg_str(&args, "repo")?; let branch = arg_opt_str(&args, "branch").map(String::from); let limit = arg_u64(&args, "limit", 20).min(100); let skip = arg_u64(&args, "skip", 0); let repo = require_repo_member(git, ctx.user_id, workspace, repo_name).await?; let mut client = CommitServiceClient::new(git.channel.clone()); let resp = client .commit_history(p::CommitHistoryRequest { repo_id: repo.id.to_string(), limit, skip, sort: p::CommitWalkSort::Time as i32, branch, }) .await .map_err(rpc_err)? .into_inner(); let commits: Vec = resp .commits .iter() .map(|c| { json!({ "oid": c.oid.as_ref().map(|o| &o.value).unwrap_or(&String::new()), "summary": c.summary, "message": c.message, "author_name": c.author.as_ref().map(|a| &a.name).unwrap_or(&String::new()), "author_email": c.author.as_ref().map(|a| &a.email).unwrap_or(&String::new()), "time": c.author.as_ref().map(|a| a.time_secs).unwrap_or(0), }) }) .collect(); Ok(json!({ "commits": commits, "count": commits.len() })) } } pub struct GitCommitInfoTool; impl GitCommitInfoTool { pub fn new() -> Self { Self } } impl Default for GitCommitInfoTool { fn default() -> Self { Self::new() } } #[async_trait] impl FunctionCall for GitCommitInfoTool { type Context = AppAgentContext; fn name(&self) -> &'static str { "git_commit_info" } fn description(&self) -> &'static str { "Get detailed information about a specific commit by its OID." } fn schema(&self) -> Value { json!({ "type": "object", "properties": { "workspace": { "type": "string", "description": "Workspace name" }, "repo": { "type": "string", "description": "Repository name" }, "oid": { "type": "string", "description": "Commit OID (SHA)" } }, "required": ["workspace", "repo", "oid"] }) } async fn call(&self, ctx: &mut AppAgentContext, args: Value) -> AiResult { let git = git_ctx(ctx)?; let workspace = arg_str(&args, "workspace")?; let repo_name = arg_str(&args, "repo")?; let oid = arg_str(&args, "oid")?; let repo = require_repo_member(git, ctx.user_id, workspace, repo_name).await?; let mut client = CommitServiceClient::new(git.channel.clone()); let resp = client .commit_info(p::CommitInfoRequest { repo_id: repo.id.to_string(), oid: Some(p::ObjectId { value: oid.to_string() }), }) .await .map_err(rpc_err)? .into_inner(); let c = resp .commit .ok_or_else(|| AiError::Response("commit not found".to_string()))?; let parent_ids: Vec = c.parent_ids.iter().map(|o| o.value.clone()).collect(); Ok(json!({ "oid": c.oid.as_ref().map(|o| &o.value), "summary": c.summary, "message": c.message, "author_name": c.author.as_ref().map(|a| &a.name), "author_email": c.author.as_ref().map(|a| &a.email), "author_time": c.author.as_ref().map(|a| a.time_secs), "committer_name": c.committer.as_ref().map(|a| &a.name), "tree_id": c.tree_id.as_ref().map(|o| &o.value), "parent_ids": parent_ids, })) } } pub struct GitCommitExistsTool; impl GitCommitExistsTool { pub fn new() -> Self { Self } } impl Default for GitCommitExistsTool { fn default() -> Self { Self::new() } } #[async_trait] impl FunctionCall for GitCommitExistsTool { type Context = AppAgentContext; fn name(&self) -> &'static str { "git_commit_exists" } fn description(&self) -> &'static str { "Check whether a specific commit OID exists in the repository." } fn schema(&self) -> Value { json!({ "type": "object", "properties": { "workspace": { "type": "string", "description": "Workspace name" }, "repo": { "type": "string", "description": "Repository name" }, "oid": { "type": "string", "description": "Commit OID (SHA)" } }, "required": ["workspace", "repo", "oid"] }) } async fn call(&self, ctx: &mut AppAgentContext, args: Value) -> AiResult { let git = git_ctx(ctx)?; let workspace = arg_str(&args, "workspace")?; let repo_name = arg_str(&args, "repo")?; let oid = arg_str(&args, "oid")?; let repo = require_repo_member(git, ctx.user_id, workspace, repo_name).await?; let mut client = CommitServiceClient::new(git.channel.clone()); let resp = client .commit_exists(p::CommitExistsRequest { repo_id: repo.id.to_string(), oid: Some(p::ObjectId { value: oid.to_string() }), }) .await .map_err(rpc_err)? .into_inner(); Ok(json!({ "exists": resp.exists })) } } pub struct GitCherryPickTool; impl GitCherryPickTool { pub fn new() -> Self { Self } } impl Default for GitCherryPickTool { fn default() -> Self { Self::new() } } #[async_trait] impl FunctionCall for GitCherryPickTool { type Context = AppAgentContext; fn name(&self) -> &'static str { "git_cherry_pick" } fn description(&self) -> &'static str { "Cherry-pick a commit onto the current branch. Requires write access." } fn schema(&self) -> Value { json!({ "type": "object", "properties": { "workspace": { "type": "string", "description": "Workspace name" }, "repo": { "type": "string", "description": "Repository name" }, "oid": { "type": "string", "description": "Commit OID to cherry-pick" }, "message": { "type": "string", "description": "Override commit message (optional)" }, "update_ref": { "type": "string", "description": "Branch ref to update (optional)" } }, "required": ["workspace", "repo", "oid"] }) } async fn call(&self, ctx: &mut AppAgentContext, args: Value) -> AiResult { let git = git_ctx(ctx)?; let workspace = arg_str(&args, "workspace")?; let repo_name = arg_str(&args, "repo")?; let oid = arg_str(&args, "oid")?; let message = args.get("message").and_then(|v| v.as_str()).map(String::from); let update_ref = arg_opt_str(&args, "update_ref").map(String::from); let repo = require_repo_member(git, ctx.user_id, workspace, repo_name).await?; let mut client = CommitServiceClient::new(git.channel.clone()); let resp = client .cherry_pick(p::CherryPickRequest { repo_id: repo.id.to_string(), params: Some(p::CommitCherryPickParams { cherrypick_oid: Some(p::ObjectId { value: oid.to_string() }), message, update_ref, ..Default::default() }), }) .await .map_err(rpc_err)? .into_inner(); Ok(json!({ "success": true, "new_oid": resp.oid.as_ref().map(|o| &o.value), })) } } pub struct GitCommitCreateTool; impl GitCommitCreateTool { pub fn new() -> Self { Self } } impl Default for GitCommitCreateTool { fn default() -> Self { Self::new() } } #[async_trait] impl FunctionCall for GitCommitCreateTool { type Context = AppAgentContext; fn name(&self) -> &'static str { "git_commit_create" } fn description(&self) -> &'static str { "Create a commit with new or updated files in a workspace repository. Author is set to the requesting user, committer is redpanda . Provide workspace name, repo name, branch, commit message, and a list of files (path and content for each)." } fn schema(&self) -> Value { json!({ "type": "object", "properties": { "workspace": { "type": "string", "description": "Workspace name (e.g. 'my-org')" }, "repo": { "type": "string", "description": "Repository name" }, "branch": { "type": "string", "description": "Branch name to commit to (e.g. 'main'). If the branch does not exist, it will be created." }, "message": { "type": "string", "description": "Commit message" }, "files": { "type": "array", "description": "List of file changes", "items": { "type": "object", "properties": { "path": { "type": "string", "description": "File path relative to repo root (e.g. 'src/main.rs')" }, "content": { "type": "string", "description": "File content as a string" } }, "required": ["path", "content"] } } }, "required": ["workspace", "repo", "branch", "message", "files"] }) } async fn call(&self, ctx: &mut AppAgentContext, args: Value) -> AiResult { let git = git_ctx(ctx)?; let workspace = arg_str(&args, "workspace")?; let repo_name = arg_str(&args, "repo")?; let branch = arg_str(&args, "branch")?; let message = arg_str(&args, "message")?; let files_val = args .get("files") .and_then(|v| v.as_array()) .ok_or_else(|| AiError::Config("'files' must be an array of {path, content} objects".to_string()))?; let mut file_changes: Vec = Vec::new(); for f in files_val { let path = f .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| AiError::Config("each file must have a 'path' field".to_string()))?; let content = f .get("content") .and_then(|v| v.as_str()) .ok_or_else(|| AiError::Config("each file must have a 'content' field".to_string()))?; file_changes.push(super::helpers::FileChange { path: path.to_string(), content: content.as_bytes().to_vec(), }); } if file_changes.is_empty() { return Err(AiError::Config("'files' array must not be empty".to_string())); } let repo = require_repo_member(git, ctx.user_id, workspace, repo_name).await?; let mut client = CommitServiceClient::new(git.channel.clone()); let resp = client .create_commit(tonic::Request::new(p::CreateCommitRequest { repo_id: repo.id.to_string(), branch: branch.to_string(), message: message.to_string(), author_name: ctx.user_id.to_string(), author_email: format!("{}@gitdata.ai", ctx.user_id), committer_name: "redpanda".to_string(), committer_email: "redpanda@gitdata.ai".to_string(), files: file_changes .into_iter() .map(|fc| p::FileChange { path: fc.path, content: fc.content, }) .collect(), })) .await .map_err(rpc_err)? .into_inner(); Ok(json!({ "success": true, "oid": resp.oid.as_ref().map(|o| &o.value), "files_committed": files_val.len(), })) } }