gitdataai/lib/service/agent/git_tools/commit.rs

495 lines
15 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::commit_service_client::CommitServiceClient;
use serde_json::{Value, json};
use super::helpers::{
arg_opt_str, arg_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<Value> {
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<Value> = 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<Value> {
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<String> =
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<Value> {
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<Value> {
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 <redpanda@gitdata.ai>. 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<Value> {
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<super::helpers::FileChange> = 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(),
}))
}
}