refactor(fctool): apply rustfmt formatting

This commit is contained in:
ZhenYi 2026-05-14 10:01:52 +08:00
parent 02a1020f75
commit 2dcb5b3028
26 changed files with 3575 additions and 1218 deletions

View File

@ -64,7 +64,8 @@ pub async fn retract_message_exec(
if !ctx.is_sent_in_turn(message_id) {
return Err(ToolError::ExecutionError(
"can only retract messages sent in the current turn — \
cross-turn retraction is not allowed".into(),
cross-turn retraction is not allowed"
.into(),
));
}
@ -106,16 +107,25 @@ async fn is_room_admin(
) -> bool {
use models::rooms::room;
let room_model = room::Entity::find_by_id(room_id)
.one(db.reader()).await.ok().flatten();
.one(db.reader())
.await
.ok()
.flatten();
match room_model {
Some(r) if r.created_by == user_id => true,
Some(_) => {
let member = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project_id))
.filter(project_members::Column::User.eq(user_id))
.one(db.reader()).await.ok().flatten();
.one(db.reader())
.await
.ok()
.flatten();
match member {
Some(m) => matches!(m.scope_role(), Ok(models::projects::MemberRole::Admin | models::projects::MemberRole::Owner)),
Some(m) => matches!(
m.scope_role(),
Ok(models::projects::MemberRole::Admin | models::projects::MemberRole::Owner)
),
None => false,
}
}

View File

@ -9,15 +9,13 @@
use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
use chrono::Utc;
use models::rooms::{
room_message, MessageContentType, MessageSenderType,
};
use models::rooms::{MessageContentType, MessageSenderType, room_message};
use queue::{ProjectRoomEvent, RoomMessageEnvelope};
use sea_orm::*;
use std::collections::HashMap;
use uuid::Uuid;
const MAX_CONTENT_LEN: usize = 10000;
const MAX_CONTENT_LEN: usize = 600;
/// Send a brief message to the specified room.
/// The message content may include mentions in `@[type:id:label]` format.
@ -135,7 +133,9 @@ pub async fn send_message_exec(
seq: Some(seq),
timestamp: now,
};
producer.publish_project_room_event(ctx.project_id(), project_event).await;
producer
.publish_project_room_event(ctx.project_id(), project_event)
.await;
}
Ok(serde_json::json!({

View File

@ -2,7 +2,7 @@
use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
use chrono::Utc;
use models::ai::{ai_conversation, ai_message, AiMessage};
use models::ai::{AiMessage, ai_conversation, ai_message};
use sea_orm::*;
use std::collections::HashMap;

View File

@ -8,18 +8,71 @@ use std::collections::HashMap;
/// Text file extensions to search (skip binary files).
const TEXT_EXTS: &[&str] = &[
"rs", "toml", "yaml", "yml", "json", "jsonc", "js", "jsx", "ts", "tsx",
"css", "scss", "less", "html", "htm", "xml", "svg", "vue", "svelte",
"py", "rb", "go", "java", "kt", "swift", "c", "cpp", "h", "hpp",
"cs", "php", "pl", "sh", "bash", "zsh", "fish", "ps1", "bat", "cmd",
"sql", "md", "markdown", "rst", "txt", "log", "ini", "cfg", "conf",
"dockerfile", "makefile", "cmake", "gradle", "properties", "env",
"proto", "graphql", "vue", "lock",
"rs",
"toml",
"yaml",
"yml",
"json",
"jsonc",
"js",
"jsx",
"ts",
"tsx",
"css",
"scss",
"less",
"html",
"htm",
"xml",
"svg",
"vue",
"svelte",
"py",
"rb",
"go",
"java",
"kt",
"swift",
"c",
"cpp",
"h",
"hpp",
"cs",
"php",
"pl",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"bat",
"cmd",
"sql",
"md",
"markdown",
"rst",
"txt",
"log",
"ini",
"cfg",
"conf",
"dockerfile",
"makefile",
"cmake",
"gradle",
"properties",
"env",
"proto",
"graphql",
"vue",
"lock",
];
fn is_text_ext(path: &str) -> bool {
let lower = path.to_lowercase();
TEXT_EXTS.iter().any(|&e| lower.ends_with(&format!(".{}", e)))
TEXT_EXTS
.iter()
.any(|&e| lower.ends_with(&format!(".{}", e)))
}
fn is_binary_content(data: &[u8]) -> bool {
@ -51,18 +104,9 @@ async fn git_grep_exec(
.and_then(|v| v.as_str())
.ok_or("missing pattern")?;
let glob = p.get("glob").and_then(|v| v.as_str()).map(String::from);
let is_regex = p
.get("is_regex")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let context_lines = p
.get("context_lines")
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let max_results = p
.get("max_results")
.and_then(|v| v.as_u64())
.unwrap_or(100) as usize;
let is_regex = p.get("is_regex").and_then(|v| v.as_bool()).unwrap_or(true);
let context_lines = p.get("context_lines").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let max_results = p.get("max_results").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
let domain = ctx.open_repo(project_name, repo_name).await?;
@ -115,7 +159,8 @@ async fn git_grep_exec(
};
if entry.kind() == Some(git2::ObjectType::Tree) {
if let Some(subtree) = entry.to_object(&repo).ok().and_then(|o| o.into_tree().ok()) {
if let Some(subtree) = entry.to_object(&repo).ok().and_then(|o| o.into_tree().ok())
{
stack.push((subtree, path));
}
continue;
@ -217,7 +262,11 @@ fn glob_match(path: &str, pattern: &str) -> bool {
}
if let Some(star) = pattern_part.find('*') {
let (prefix, suffix) = pattern_part.split_at(star);
let suffix = if suffix.starts_with('*') { &suffix[1..] } else { suffix };
let suffix = if suffix.starts_with('*') {
&suffix[1..]
} else {
suffix
};
if !prefix.is_empty() && !path_part.starts_with(prefix) {
return false;
}
@ -246,7 +295,11 @@ fn glob_match(path: &str, pattern: &str) -> bool {
break;
}
// Try skipping segments until the next pattern part matches
let next_part = parts.iter().skip_while(|p| **p == "**").next().unwrap_or(&"*");
let next_part = parts
.iter()
.skip_while(|p| **p == "**")
.next()
.unwrap_or(&"*");
while pi < path_parts.len() && !matches_part(path_parts[pi], next_part) {
pi += 1;
}
@ -263,76 +316,110 @@ fn glob_match(path: &str, pattern: &str) -> bool {
pub fn register_grep_tools(registry: &mut ToolRegistry) {
let p = 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,
}),
("pattern".into(), ToolParam {
name: "pattern".into(),
param_type: "string".into(),
description: Some("Search pattern (regex or literal string)".into()),
required: true,
properties: None,
items: None,
}),
("rev".into(), ToolParam {
name: "rev".into(),
param_type: "string".into(),
description: Some("Git revision to search in (branch, tag, commit). Default: HEAD".into()),
required: false,
properties: None,
items: None,
}),
("glob".into(), ToolParam {
name: "glob".into(),
param_type: "string".into(),
description: Some("File glob pattern to filter (e.g. *.rs, src/**/*.ts)".into()),
required: false,
properties: None,
items: None,
}),
("is_regex".into(), ToolParam {
name: "is_regex".into(),
param_type: "boolean".into(),
description: Some("If true, pattern is a regex. If false, literal string. Default: true".into()),
required: false,
properties: None,
items: None,
}),
("context_lines".into(), ToolParam {
name: "context_lines".into(),
param_type: "integer".into(),
description: Some("Number of surrounding lines to include for each match. Default: 0".into()),
required: false,
properties: None,
items: None,
}),
("max_results".into(), ToolParam {
name: "max_results".into(),
param_type: "integer".into(),
description: Some("Maximum number of matches to return. Default: 100".into()),
required: false,
properties: None,
items: None,
}),
(
"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,
},
),
(
"pattern".into(),
ToolParam {
name: "pattern".into(),
param_type: "string".into(),
description: Some("Search pattern (regex or literal string)".into()),
required: true,
properties: None,
items: None,
},
),
(
"rev".into(),
ToolParam {
name: "rev".into(),
param_type: "string".into(),
description: Some(
"Git revision to search in (branch, tag, commit). Default: HEAD".into(),
),
required: false,
properties: None,
items: None,
},
),
(
"glob".into(),
ToolParam {
name: "glob".into(),
param_type: "string".into(),
description: Some("File glob pattern to filter (e.g. *.rs, src/**/*.ts)".into()),
required: false,
properties: None,
items: None,
},
),
(
"is_regex".into(),
ToolParam {
name: "is_regex".into(),
param_type: "boolean".into(),
description: Some(
"If true, pattern is a regex. If false, literal string. Default: true".into(),
),
required: false,
properties: None,
items: None,
},
),
(
"context_lines".into(),
ToolParam {
name: "context_lines".into(),
param_type: "integer".into(),
description: Some(
"Number of surrounding lines to include for each match. Default: 0".into(),
),
required: false,
properties: None,
items: None,
},
),
(
"max_results".into(),
ToolParam {
name: "max_results".into(),
param_type: "integer".into(),
description: Some("Maximum number of matches to return. Default: 100".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(), "pattern".into()]),
required: Some(vec![
"project_name".into(),
"repo_name".into(),
"pattern".into(),
]),
};
registry.register(

View File

@ -118,7 +118,10 @@ async fn read_json_exec(
.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 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())
@ -213,7 +216,11 @@ fn query_json(value: &JsonValue, query: &str) -> Result<JsonValue, String> {
let mut current = value.clone();
// Parse into access segments: Key("name"), Index(0), BracketKey("key.with.dots")
enum Segment { Key(String), Index(usize), BracketKey(String) }
enum Segment {
Key(String),
Index(usize),
BracketKey(String),
}
let mut segments: Vec<Segment> = Vec::new();
let mut i = 0;
let q_chars: Vec<char> = query.chars().collect();
@ -230,10 +237,10 @@ fn query_json(value: &JsonValue, query: &str) -> Result<JsonValue, String> {
let content = bracket_content.trim();
// Check if it's a quoted string key or a numeric index
if content.starts_with('"') && content.ends_with('"') {
let key = content[1..content.len()-1].to_string();
let key = content[1..content.len() - 1].to_string();
segments.push(Segment::BracketKey(key));
} else if content.starts_with("'") && content.ends_with("'") {
let key = content[1..content.len()-1].to_string();
let key = content[1..content.len() - 1].to_string();
segments.push(Segment::BracketKey(key));
} else if let Ok(idx) = content.parse::<usize>() {
segments.push(Segment::Index(idx));
@ -297,7 +304,15 @@ pub fn register_json_tools(registry: &mut ToolRegistry) {
("schema_depth".into(), ToolParam { name: "schema_depth".into(), param_type: "integer".into(), description: Some("How deep to infer the JSON schema (default: 4)".into()), required: false, properties: None, items: None }),
("pretty".into(), ToolParam { name: "pretty".into(), param_type: "boolean".into(), description: Some("Pretty-print the output (default: false)".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(), "path".into()]) };
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec![
"project_name".into(),
"repo_name".into(),
"path".into(),
]),
};
registry.register(
ToolDefinition::new("read_json")
.description("Parse, validate, and query JSON and JSONC files. Supports JSONPath-like queries ($.key, $.arr[0]), schema inference, and pretty-printing. Automatically detects JSONC (with // comments).")

View File

@ -24,4 +24,4 @@ pub fn register_all(registry: &mut ToolRegistry) {
markdown::register_markdown_tools(registry);
sql::register_sql_tools(registry);
json::register_json_tools(registry);
}
}

View File

@ -3,7 +3,7 @@
use crate::file_tools::MAX_FILE_SIZE;
use crate::git_tools::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use sqlparser::ast::{Statement, ColumnDef};
use sqlparser::ast::{ColumnDef, Statement};
use sqlparser::dialect::{GenericDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect};
use sqlparser::parser::Parser;
use std::collections::HashMap;
@ -23,13 +23,19 @@ async fn read_sql_exec(
.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 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(String::from)
.unwrap_or_else(|| "HEAD".to_string());
let dialect = p.get("dialect").and_then(|v| v.as_str()).unwrap_or("generic");
let dialect = p
.get("dialect")
.and_then(|v| v.as_str())
.unwrap_or("generic");
let domain = ctx.open_repo(project_name, repo_name).await?;
@ -74,10 +80,15 @@ async fn read_sql_exec(
let mut views: Vec<serde_json::Value> = Vec::new();
let mut functions: Vec<serde_json::Value> = Vec::new();
let mut indexes: Vec<serde_json::Value> = Vec::new();
let mut statement_kinds: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut statement_kinds: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for statement in &statements {
let kind = format!("{:?}", statement).split('{').next().unwrap_or("unknown").to_string();
let kind = format!("{:?}", statement)
.split('{')
.next()
.unwrap_or("unknown")
.to_string();
*statement_kinds.entry(kind).or_insert(0) += 1;
match statement {
@ -135,13 +146,73 @@ fn format_column_def(col: &ColumnDef) -> String {
pub fn register_sql_tools(registry: &mut ToolRegistry) {
let p = 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 }),
("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path to the SQL file".into()), required: true, properties: None, items: None }),
("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Git revision (default: HEAD)".into()), required: false, properties: None, items: None }),
("dialect".into(), ToolParam { name: "dialect".into(), param_type: "string".into(), description: Some("SQL dialect: generic, mysql, postgresql, sqlite. Default: generic".into()), required: false, properties: None, items: None }),
(
"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,
},
),
(
"path".into(),
ToolParam {
name: "path".into(),
param_type: "string".into(),
description: Some("File path to the SQL file".into()),
required: true,
properties: None,
items: None,
},
),
(
"rev".into(),
ToolParam {
name: "rev".into(),
param_type: "string".into(),
description: Some("Git revision (default: HEAD)".into()),
required: false,
properties: None,
items: None,
},
),
(
"dialect".into(),
ToolParam {
name: "dialect".into(),
param_type: "string".into(),
description: Some(
"SQL dialect: generic, mysql, postgresql, sqlite. Default: generic".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(), "path".into()]) };
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec![
"project_name".into(),
"repo_name".into(),
"path".into(),
]),
};
registry.register(
ToolDefinition::new("read_sql")
.description("Parse and analyze a SQL file. Extracts CREATE TABLE statements (with columns and types), CREATE VIEW, CREATE INDEX, CREATE FUNCTION, and counts all statement types.")

View File

@ -11,8 +11,14 @@ async fn git_blob_info_exec(
) -> 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 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 oid = p.get("oid").and_then(|v| v.as_str()).ok_or("missing oid")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
@ -32,8 +38,14 @@ async fn git_blob_exists_exec(
) -> 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 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 oid = p.get("oid").and_then(|v| v.as_str()).ok_or("missing oid")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
@ -49,10 +61,19 @@ async fn git_blob_content_exec(
) -> 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 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 oid = p.get("oid").and_then(|v| v.as_str()).ok_or("missing oid")?;
let max_size = p.get("max_size").and_then(|v| v.as_u64()).unwrap_or(1_048_576) as usize; // 1MB default
let max_size = p
.get("max_size")
.and_then(|v| v.as_u64())
.unwrap_or(1_048_576) as usize; // 1MB default
let domain = ctx.open_repo(project_name, repo_name).await?;
let blob_oid = git::commit::types::CommitOid::new(oid);
@ -66,7 +87,10 @@ async fn git_blob_content_exec(
}
let (content, is_binary) = if blob.is_binary {
(base64::engine::general_purpose::STANDARD.encode(&blob.content), true)
(
base64::engine::general_purpose::STANDARD.encode(&blob.content),
true,
)
} else {
(String::from_utf8_lossy(&blob.content).to_string(), false)
};
@ -85,17 +109,34 @@ async fn git_blob_create_exec(
) -> 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 content = p.get("content").and_then(|v| v.as_str()).ok_or("missing content")?;
let encoding = p.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8");
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 content = p
.get("content")
.and_then(|v| v.as_str())
.ok_or("missing content")?;
let encoding = p
.get("encoding")
.and_then(|v| v.as_str())
.unwrap_or("utf-8");
let data = match encoding {
"base64" => base64::engine::general_purpose::STANDARD
.decode(content)
.map_err(|e| format!("invalid base64: {}", e))?,
"utf-8" => content.as_bytes().to_vec(),
other => return Err(format!("unsupported encoding '{}'. Use 'utf-8' or 'base64'.", other)),
other => {
return Err(format!(
"unsupported encoding '{}'. Use 'utf-8' or 'base64'.",
other
));
}
};
let domain = ctx.open_repo(project_name, repo_name).await?;
@ -109,30 +150,51 @@ async fn git_blob_create_exec(
}))
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
// git_blob_info
let p = 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,
}),
("oid".into(), ToolParam {
name: "oid".into(), param_type: "string".into(),
description: Some("Blob OID (full 40-char hex or short prefix)".into()),
required: true, properties: None, items: None,
}),
(
"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,
},
),
(
"oid".into(),
ToolParam {
name: "oid".into(),
param_type: "string".into(),
description: Some("Blob OID (full 40-char hex or short prefix)".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(), "oid".into()]),
required: Some(vec![
"project_name".into(),
"repo_name".into(),
"oid".into(),
]),
};
registry.register(
ToolDefinition::new("git_blob_info")
@ -148,26 +210,48 @@ pub fn register_git_tools(registry: &mut ToolRegistry) {
// git_blob_exists
let p = 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,
}),
("oid".into(), ToolParam {
name: "oid".into(), param_type: "string".into(),
description: Some("Blob OID to check".into()),
required: true, properties: None, items: None,
}),
(
"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,
},
),
(
"oid".into(),
ToolParam {
name: "oid".into(),
param_type: "string".into(),
description: Some("Blob OID to check".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(), "oid".into()]),
required: Some(vec![
"project_name".into(),
"repo_name".into(),
"oid".into(),
]),
};
registry.register(
ToolDefinition::new("git_blob_exists")
@ -176,38 +260,68 @@ pub fn register_git_tools(registry: &mut ToolRegistry) {
ToolHandler::new(|ctx, args| {
let gctx = GitToolCtx::new(ctx);
Box::pin(async move {
git_blob_exists_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
git_blob_exists_exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
})
}),
);
// git_blob_content
let p = 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,
}),
("oid".into(), ToolParam {
name: "oid".into(), param_type: "string".into(),
description: Some("Blob OID to retrieve content for".into()),
required: true, properties: None, items: None,
}),
("max_size".into(), ToolParam {
name: "max_size".into(), param_type: "integer".into(),
description: Some("Maximum blob size in bytes (default: 1MB)".into()),
required: false, properties: None, items: None,
}),
(
"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,
},
),
(
"oid".into(),
ToolParam {
name: "oid".into(),
param_type: "string".into(),
description: Some("Blob OID to retrieve content for".into()),
required: true,
properties: None,
items: None,
},
),
(
"max_size".into(),
ToolParam {
name: "max_size".into(),
param_type: "integer".into(),
description: Some("Maximum blob size in bytes (default: 1MB)".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(), "oid".into()]),
required: Some(vec![
"project_name".into(),
"repo_name".into(),
"oid".into(),
]),
};
registry.register(
ToolDefinition::new("git_blob_content")
@ -223,31 +337,59 @@ pub fn register_git_tools(registry: &mut ToolRegistry) {
// git_blob_create
let p = 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,
}),
("content".into(), ToolParam {
name: "content".into(), param_type: "string".into(),
description: Some("Blob content (utf-8 string or base64-encoded bytes)".into()),
required: true, properties: None, items: None,
}),
("encoding".into(), ToolParam {
name: "encoding".into(), param_type: "string".into(),
description: Some("Encoding of content: 'utf-8' (default) or 'base64'".into()),
required: false, properties: None, items: None,
}),
(
"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,
},
),
(
"content".into(),
ToolParam {
name: "content".into(),
param_type: "string".into(),
description: Some("Blob content (utf-8 string or base64-encoded bytes)".into()),
required: true,
properties: None,
items: None,
},
),
(
"encoding".into(),
ToolParam {
name: "encoding".into(),
param_type: "string".into(),
description: Some("Encoding of content: 'utf-8' (default) or 'base64'".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(), "content".into()]),
required: Some(vec![
"project_name".into(),
"repo_name".into(),
"content".into(),
]),
};
registry.register(
ToolDefinition::new("git_blob_create")

View File

@ -4,11 +4,24 @@ use super::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use std::collections::HashMap;
async fn git_branch_list_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 remote_only = p.get("remote_only").and_then(|v| v.as_bool()).unwrap_or(false);
async fn git_branch_list_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 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())?;
@ -25,11 +38,24 @@ async fn git_branch_list_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul
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<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 name = p.get("name").and_then(|v| v.as_str()).ok_or("missing name")?;
async fn git_branch_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 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())?;
@ -39,7 +65,9 @@ async fn git_branch_info_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul
Ok((ahead, behind)) => Some(serde_json::json!({ "ahead": ahead, "behind": behind })),
Err(e) => Some(serde_json::json!({ "error": e.to_string() })),
}
} else { None };
} else {
None
};
Ok(serde_json::json!({
"branch": { "name": info.name, "oid": info.oid.to_string(), "is_head": info.is_head,
@ -48,84 +76,188 @@ async fn git_branch_info_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul
}))
}
async fn git_branches_merged_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 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());
async fn git_branches_merged_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 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 is_merged = domain
.branch_is_merged(branch, &into)
.map_err(|e| e.to_string())?;
// Resolve branch names to commit OIDs before calling merge_base
let branch_oid = domain.branch_target(branch)
let branch_oid = domain
.branch_target(branch)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("branch '{}' not found or has no target", branch))?;
let into_oid = domain.branch_target(&into)
let into_oid = domain
.branch_target(&into)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("branch '{}' not found or has no target", into))?;
let merge_base = domain.merge_base(&branch_oid, &into_oid)
.map(|oid| oid.to_string()).ok();
let merge_base = domain
.merge_base(&branch_oid, &into_oid)
.map(|oid| oid.to_string())
.ok();
Ok(serde_json::json!({ "branch": branch, "into": into, "is_merged": is_merged, "merge_base": merge_base }))
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<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 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();
async fn git_branch_diff_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 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())?;
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 }))
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
let mut p = 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,
}),
(
"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,
},
),
]);
// git_branch_list
p.insert("remote_only".into(), ToolParam {
name: "remote_only".into(), param_type: "boolean".into(),
description: Some("If true, list only remote-tracking branches".into()),
required: false, properties: None, items: None,
});
let schema = ToolSchema { schema_type: "object".into(), properties: Some(std::mem::take(&mut p)), required: Some(vec!["project_name".into(), "repo_name".into()]) };
p.insert(
"remote_only".into(),
ToolParam {
name: "remote_only".into(),
param_type: "boolean".into(),
description: Some("If true, list only remote-tracking branches".into()),
required: false,
properties: None,
items: None,
},
);
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(std::mem::take(&mut p)),
required: Some(vec!["project_name".into(), "repo_name".into()]),
};
p = 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 }),
(
"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,
},
),
]);
registry.register(
ToolDefinition::new("git_branch_list").description("List all local or remote branches with their current HEAD commit.").parameters(schema),
ToolDefinition::new("git_branch_list")
.description("List all local or remote branches with their current HEAD commit.")
.parameters(schema),
ToolHandler::new(move |ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
git_branch_list_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
git_branch_list_exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
})
}),
);
// git_branch_info
p.insert("name".into(), ToolParam {
name: "name".into(), param_type: "string".into(),
description: Some("Branch name (e.g. main, feature/my-branch)".into()),
required: true, properties: None, items: None,
});
let schema = ToolSchema { schema_type: "object".into(), properties: Some(std::mem::take(&mut p)), required: Some(vec!["project_name".into(), "repo_name".into(), "name".into()]) };
p.insert(
"name".into(),
ToolParam {
name: "name".into(),
param_type: "string".into(),
description: Some("Branch name (e.g. main, feature/my-branch)".into()),
required: true,
properties: None,
items: None,
},
);
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(std::mem::take(&mut p)),
required: Some(vec![
"project_name".into(),
"repo_name".into(),
"name".into(),
]),
};
registry.register(
ToolDefinition::new("git_branch_info").description("Get detailed information about a specific branch including ahead/behind status vs upstream.").parameters(schema),
ToolHandler::new(move |ctx, args| {
@ -138,12 +270,60 @@ pub fn register_git_tools(registry: &mut ToolRegistry) {
// git_branches_merged
p = 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 }),
("branch".into(), ToolParam { name: "branch".into(), param_type: "string".into(), description: Some("Branch to check (source)".into()), required: true, properties: None, items: None }),
("into".into(), ToolParam { name: "into".into(), param_type: "string".into(), description: Some("Branch to check against (target, default: main)".into()), required: false, properties: None, items: None }),
(
"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,
},
),
(
"branch".into(),
ToolParam {
name: "branch".into(),
param_type: "string".into(),
description: Some("Branch to check (source)".into()),
required: true,
properties: None,
items: None,
},
),
(
"into".into(),
ToolParam {
name: "into".into(),
param_type: "string".into(),
description: Some("Branch to check against (target, default: main)".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(), "branch".into()]) };
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec![
"project_name".into(),
"repo_name".into(),
"branch".into(),
]),
};
registry.register(
ToolDefinition::new("git_branches_merged").description("Check whether a branch has been merged into another branch, and find the merge base.").parameters(schema),
ToolHandler::new(move |ctx, args| {
@ -156,12 +336,60 @@ pub fn register_git_tools(registry: &mut ToolRegistry) {
// git_branch_diff
let p = 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 }),
("local".into(), ToolParam { name: "local".into(), param_type: "string".into(), description: Some("Local branch name".into()), required: true, properties: None, items: None }),
("remote".into(), ToolParam { name: "remote".into(), param_type: "string".into(), description: Some("Remote branch name (defaults to local)".into()), required: false, properties: None, items: None }),
(
"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,
},
),
(
"local".into(),
ToolParam {
name: "local".into(),
param_type: "string".into(),
description: Some("Local branch name".into()),
required: true,
properties: None,
items: None,
},
),
(
"remote".into(),
ToolParam {
name: "remote".into(),
param_type: "string".into(),
description: Some("Remote branch name (defaults to local)".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(), "local".into()]) };
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec![
"project_name".into(),
"repo_name".into(),
"local".into(),
]),
};
registry.register(
ToolDefinition::new("git_branch_diff").description("Compare a local branch against its remote counterpart to see how many commits ahead/behind they are.").parameters(schema),
ToolHandler::new(move |ctx, args| {
@ -171,4 +399,4 @@ pub fn register_git_tools(registry: &mut ToolRegistry) {
})
}),
);
}
}

View File

@ -7,57 +7,87 @@ 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")?;
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)
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 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();
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()
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();
.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> {
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")?;
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?;
@ -67,8 +97,11 @@ async fn git_show_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde
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 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();
@ -85,20 +118,36 @@ async fn git_show_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde
}))
}
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")?;
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 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()
let result: Vec<_> = commits
.iter()
.filter(|c| c.message.to_lowercase().contains(&q))
.take(limit)
.map(|c| flatten_commit(c))
@ -110,8 +159,11 @@ async fn git_search_commits_exec(ctx: GitToolCtx, args: serde_json::Value) -> Re
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 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(),
@ -124,10 +176,20 @@ fn flatten_commit(c: &git::commit::types::CommitMeta) -> serde_json::Value {
})
}
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")?;
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?;
@ -136,69 +198,118 @@ async fn git_commit_info_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul
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")?;
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 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));
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<_>>()
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();
.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());
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 entries = domain
.reflog_entries(ref_name.as_deref())
.map_err(|e| e.to_string())?;
let result: Vec<_> = entries.iter()
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));
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,
@ -213,155 +324,297 @@ async fn git_reflog_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<ser
/// 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,
}),
(
"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()]) };
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),
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_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()]) };
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),
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_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()]) };
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),
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_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()]) };
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),
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_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()]) };
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),
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_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()]) };
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),
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)
git_reflog_exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
})
}),
);

View File

@ -20,7 +20,11 @@ impl GitToolCtx {
}
/// Opens a git repository by project name and repo name.
pub async fn open_repo(&self, project_name: &str, repo_name: &str) -> Result<GitDomain, String> {
pub async fn open_repo(
&self,
project_name: &str,
repo_name: &str,
) -> Result<GitDomain, String> {
let db = self.ctx.db();
resolve_project_and_repo(db, project_name, repo_name)
.await
@ -46,7 +50,12 @@ async fn resolve_project_and_repo(
.filter(repo::Column::RepoName.eq(repo_name))
.one(db)
.await
.map_err(|e| format!("DB error looking up repo '{}/{}': {}", project_name, repo_name, e))?
.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))

View File

@ -4,14 +4,32 @@ use super::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use std::collections::HashMap;
async fn git_diff_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 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());
async fn git_diff_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 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::<Vec<_>>()
a.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>()
});
let domain = ctx.open_repo(project_name, repo_name).await?;
@ -22,7 +40,10 @@ async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde
} else if let Ok(Some(oid)) = domain.ref_target(rev) {
Ok(oid)
} else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid)
domain
.commit_get_prefix(rev)
.map_err(|e| e.to_string())
.map(|m| m.oid)
}
};
@ -35,11 +56,15 @@ async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde
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 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) => {
@ -47,25 +72,35 @@ async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde
if domain.repo().head().is_err() {
return Err("No commits found in repository".into());
}
let head_oid = domain.ref_target("HEAD").map_err(|e| e.to_string())?
let head_oid = domain
.ref_target("HEAD")
.map_err(|e| e.to_string())?
.ok_or_else(|| "HEAD reference not found".to_string())?;
// Bare repos have no working tree — use tree-to-tree diff instead
if domain.repo().is_bare() {
domain.diff_tree_to_tree(None, Some(&head_oid), opts).map_err(|e| e.to_string())?
domain
.diff_tree_to_tree(None, Some(&head_oid), opts)
.map_err(|e| e.to_string())?
} else {
domain.diff_commit_to_workdir(&head_oid, opts).map_err(|e| e.to_string())?
domain
.diff_commit_to_workdir(&head_oid, opts)
.map_err(|e| e.to_string())?
}
}
(Some(base), None) => {
if domain.repo().is_bare() {
domain.diff_tree_to_tree(Some(base), None, opts).map_err(|e| e.to_string())?
domain
.diff_tree_to_tree(Some(base), None, opts)
.map_err(|e| e.to_string())?
} else {
domain.diff_commit_to_workdir(base, opts).map_err(|e| e.to_string())?
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())?
}
(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());
}
@ -87,12 +122,28 @@ async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde
}))
}
async fn git_diff_stats_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 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")?;
async fn git_diff_stats_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 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?;
@ -102,7 +153,10 @@ async fn git_diff_stats_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result
} else if let Ok(Some(oid)) = domain.ref_target(rev) {
Ok(oid)
} else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid)
domain
.commit_get_prefix(rev)
.map_err(|e| e.to_string())
.map(|m| m.oid)
}
};
let b = resolve(base).map_err(|e| e.to_string())?;
@ -116,13 +170,32 @@ async fn git_diff_stats_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result
}))
}
async fn git_blame_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 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));
async fn git_blame_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 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?;
@ -131,28 +204,40 @@ async fn git_blame_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serd
} else if let Ok(Some(oid)) = domain.ref_target(&rev) {
Ok(oid)
} else {
domain.commit_get_prefix(&rev).map_err(|e| e.to_string()).map(|m| m.oid)
domain
.commit_get_prefix(&rev)
.map_err(|e| e.to_string())
.map(|m| m.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); }
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 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
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();
.collect();
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
@ -160,13 +245,78 @@ async fn git_blame_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serd
pub fn register_git_tools(registry: &mut ToolRegistry) {
// git_diff
let p = 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 }),
("base".into(), ToolParam { name: "base".into(), param_type: "string".into(), description: Some("Base revision (commit hash or branch). Defaults to HEAD.".into()), required: false, properties: None, items: None }),
("head".into(), ToolParam { name: "head".into(), param_type: "string".into(), description: Some("Head revision to diff against base. Requires base to be set.".into()), required: false, properties: None, items: None }),
("paths".into(), ToolParam { name: "paths".into(), param_type: "array".into(), description: Some("Filter diff to specific file paths".into()), required: false, properties: None, items: Some(Box::new(ToolParam { name: "".into(), param_type: "string".into(), description: None, required: false, properties: None, items: None })) }),
(
"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,
},
),
(
"base".into(),
ToolParam {
name: "base".into(),
param_type: "string".into(),
description: Some(
"Base revision (commit hash or branch). Defaults to HEAD.".into(),
),
required: false,
properties: None,
items: None,
},
),
(
"head".into(),
ToolParam {
name: "head".into(),
param_type: "string".into(),
description: Some(
"Head revision to diff against base. Requires base to be set.".into(),
),
required: false,
properties: None,
items: None,
},
),
(
"paths".into(),
ToolParam {
name: "paths".into(),
param_type: "array".into(),
description: Some("Filter diff to specific file paths".into()),
required: false,
properties: None,
items: Some(Box::new(ToolParam {
name: "".into(),
param_type: "string".into(),
description: None,
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()]) };
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["project_name".into(), "repo_name".into()]),
};
registry.register(
ToolDefinition::new("git_diff").description("Show file changes between two commits, or between a commit and the working directory.").parameters(schema),
ToolHandler::new(|ctx, args| {
@ -179,12 +329,61 @@ pub fn register_git_tools(registry: &mut ToolRegistry) {
// git_diff_stats
let p = 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 }),
("base".into(), ToolParam { name: "base".into(), param_type: "string".into(), description: Some("Base revision".into()), required: true, properties: None, items: None }),
("head".into(), ToolParam { name: "head".into(), param_type: "string".into(), description: Some("Head revision".into()), required: true, properties: None, items: None }),
(
"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,
},
),
(
"base".into(),
ToolParam {
name: "base".into(),
param_type: "string".into(),
description: Some("Base revision".into()),
required: true,
properties: None,
items: None,
},
),
(
"head".into(),
ToolParam {
name: "head".into(),
param_type: "string".into(),
description: Some("Head revision".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(), "base".into(), "head".into()]) };
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec![
"project_name".into(),
"repo_name".into(),
"base".into(),
"head".into(),
]),
};
registry.register(
ToolDefinition::new("git_diff_stats").description("Get aggregated diff statistics (files changed, insertions, deletions) between two revisions.").parameters(schema),
ToolHandler::new(|ctx, args| {
@ -197,21 +396,95 @@ pub fn register_git_tools(registry: &mut ToolRegistry) {
// git_blame
let p = 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 }),
("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path to blame".into()), required: true, properties: None, items: None }),
("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to blame from (default: HEAD)".into()), required: false, properties: None, items: None }),
("from_line".into(), ToolParam { name: "from_line".into(), param_type: "integer".into(), description: Some("Start line number for blame range".into()), required: false, properties: None, items: None }),
("to_line".into(), ToolParam { name: "to_line".into(), param_type: "integer".into(), description: Some("End line number for blame range".into()), required: false, properties: None, items: None }),
(
"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,
},
),
(
"path".into(),
ToolParam {
name: "path".into(),
param_type: "string".into(),
description: Some("File path to blame".into()),
required: true,
properties: None,
items: None,
},
),
(
"rev".into(),
ToolParam {
name: "rev".into(),
param_type: "string".into(),
description: Some("Revision to blame from (default: HEAD)".into()),
required: false,
properties: None,
items: None,
},
),
(
"from_line".into(),
ToolParam {
name: "from_line".into(),
param_type: "integer".into(),
description: Some("Start line number for blame range".into()),
required: false,
properties: None,
items: None,
},
),
(
"to_line".into(),
ToolParam {
name: "to_line".into(),
param_type: "integer".into(),
description: Some("End line number for blame range".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(), "path".into()]) };
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec![
"project_name".into(),
"repo_name".into(),
"path".into(),
]),
};
registry.register(
ToolDefinition::new("git_blame").description("Show what revision and author last modified each line of a file (git blame).").parameters(schema),
ToolDefinition::new("git_blame")
.description(
"Show what revision and author last modified each line of a file (git blame).",
)
.parameters(schema),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
git_blame_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
git_blame_exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
})
}),
);
}
}

View File

@ -54,10 +54,20 @@ fn head_tree(domain: &git::GitDomain) -> Result<git2::Tree<'_>, String> {
// ── Tool executors ─────────────────────────────────────────────────────────────
/// Tool: repo_doc_index — list all markdown docs with frontmatter
async fn repo_doc_index_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")?;
async fn repo_doc_index_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 domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
@ -79,14 +89,22 @@ async fn repo_doc_index_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result
};
match entry.kind() {
Some(git2::ObjectType::Tree) => {
if !name.starts_with('.') && !matches!(name, "node_modules" | "target" | ".git" | ".github" | ".next" | "dist") {
if !name.starts_with('.')
&& !matches!(
name,
"node_modules" | "target" | ".git" | ".github" | ".next" | "dist"
)
{
if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) {
stack.push((subtree, entry_path));
}
}
}
Some(git2::ObjectType::Blob) => {
if name.ends_with(".md") || name.ends_with(".mdx") || name.ends_with(".markdown") {
if name.ends_with(".md")
|| name.ends_with(".mdx")
|| name.ends_with(".markdown")
{
if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) {
let raw = String::from_utf8_lossy(blob.content());
let (fm_raw, body) = extract_frontmatter(&raw);
@ -122,7 +140,11 @@ async fn repo_doc_index_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result
let tags: Vec<String> = metadata
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let headings = extract_headings(body);
@ -145,7 +167,10 @@ async fn repo_doc_index_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result
// Sort by path for consistent ordering
docs.sort_by(|a, b| {
a["path"].as_str().unwrap_or("").cmp(b["path"].as_str().unwrap_or(""))
a["path"]
.as_str()
.unwrap_or("")
.cmp(b["path"].as_str().unwrap_or(""))
});
Ok(serde_json::json!({
@ -155,20 +180,36 @@ async fn repo_doc_index_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result
}
/// Tool: repo_doc_read — read a specific document with structure
async fn repo_doc_read_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 path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?;
async fn repo_doc_read_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 path = p
.get("path")
.and_then(|v| v.as_str())
.ok_or("missing path")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
// Navigate to the file using git2 path lookup
let entry = tree.get_path(std::path::Path::new(path))
let entry = tree
.get_path(std::path::Path::new(path))
.map_err(|e| format!("file '{}' not found: {e}", path))?;
let blob = entry.to_object(repo).and_then(|o| o.peel_to_blob())
let blob = entry
.to_object(repo)
.and_then(|o| o.peel_to_blob())
.map_err(|e| format!("not a blob: {e}"))?;
let raw = String::from_utf8_lossy(blob.content());
@ -200,11 +241,24 @@ async fn repo_doc_read_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<
}
/// Tool: repo_doc_search — search through docs content
async fn repo_doc_search_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 keyword = p.get("keyword").and_then(|v| v.as_str()).ok_or("missing keyword")?;
async fn repo_doc_search_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 keyword = p
.get("keyword")
.and_then(|v| v.as_str())
.ok_or("missing keyword")?;
let context_lines = p.get("context_lines").and_then(|v| v.as_u64()).unwrap_or(2) as usize;
let keyword_lower = keyword.to_lowercase();
@ -230,14 +284,23 @@ async fn repo_doc_search_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul
};
match entry.kind() {
Some(git2::ObjectType::Tree) => {
if !name.starts_with('.') && !matches!(name, "node_modules" | "target" | ".git" | ".github" | ".next" | "dist") {
if !name.starts_with('.')
&& !matches!(
name,
"node_modules" | "target" | ".git" | ".github" | ".next" | "dist"
)
{
if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) {
stack.push((subtree, entry_path));
}
}
}
Some(git2::ObjectType::Blob) => {
if name.ends_with(".md") || name.ends_with(".mdx") || name.ends_with(".markdown") || name.ends_with(".txt") {
if name.ends_with(".md")
|| name.ends_with(".mdx")
|| name.ends_with(".markdown")
|| name.ends_with(".txt")
{
if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) {
let content = String::from_utf8_lossy(blob.content());
let lines: Vec<&str> = content.lines().collect();
@ -267,10 +330,8 @@ async fn repo_doc_search_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul
}
for (start, end) in windows {
let snippet: Vec<String> = lines[start..end]
.iter()
.map(|l| l.to_string())
.collect();
let snippet: Vec<String> =
lines[start..end].iter().map(|l| l.to_string()).collect();
file_hits.push(serde_json::json!({
"line_start": start + 1,
"line_end": end,

View File

@ -77,12 +77,34 @@ fn ext_to_language(ext: &str) -> Option<&'static str> {
fn is_ignored_dir(name: &str) -> bool {
matches!(
name,
".git" | "node_modules" | "target" | "dist" | "build" | ".next"
| ".nuxt" | ".output" | ".cache" | "__pycache__" | ".tox"
| "vendor" | ".bundle" | ".gradle" | "bin" | "obj"
| ".svn" | ".hg" | ".idea" | ".vscode" | "coverage"
| ".terraform" | ".serverless" | "deps" | "_build"
| "elm-stuff" | ".stack-work" | ".pytest_cache"
".git"
| "node_modules"
| "target"
| "dist"
| "build"
| ".next"
| ".nuxt"
| ".output"
| ".cache"
| "__pycache__"
| ".tox"
| "vendor"
| ".bundle"
| ".gradle"
| "bin"
| "obj"
| ".svn"
| ".hg"
| ".idea"
| ".vscode"
| "coverage"
| ".terraform"
| ".serverless"
| "deps"
| "_build"
| "elm-stuff"
| ".stack-work"
| ".pytest_cache"
)
}
@ -114,9 +136,7 @@ fn collect_languages(
match entry.kind() {
Some(git2::ObjectType::Tree) => {
if !is_ignored_dir(name) && !name.starts_with('.') {
if let Ok(subtree) =
entry.to_object(repo).and_then(|o| o.peel_to_tree())
{
if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) {
stack.push((subtree, entry_path));
}
}
@ -126,9 +146,7 @@ fn collect_languages(
if let Some(ext) = name.rsplit('.').next() {
let ext = ext.to_lowercase();
if let Some(lang) = ext_to_language(&ext) {
let entry = stats
.entry(lang.to_string())
.or_insert_with(|| (ext, 0));
let entry = stats.entry(lang.to_string()).or_insert_with(|| (ext, 0));
entry.1 += 1;
}
}
@ -173,7 +191,15 @@ fn collect_file_tree(
"kind": "dir"
}));
if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) {
collect_file_tree(repo, &subtree, &entry_path, depth + 1, max_depth, max_files, files);
collect_file_tree(
repo,
&subtree,
&entry_path,
depth + 1,
max_depth,
max_files,
files,
);
}
}
}
@ -192,22 +218,55 @@ fn collect_file_tree(
fn detect_config_files(tree: &git2::Tree) -> Vec<String> {
let mut configs = Vec::new();
let known_configs = [
"Cargo.toml", "package.json", "go.mod", "Gemfile", "README.md",
"Dockerfile", "docker-compose.yml", "docker-compose.yaml",
".github/workflows", ".gitignore", ".gitattributes",
"Makefile", "CMakeLists.txt", "composer.json", "pyproject.toml",
"requirements.txt", "Pipfile", "pom.xml", "build.gradle",
"build.gradle.kts", "settings.gradle", "settings.gradle.kts",
"tsconfig.json", ".eslintrc.js", ".eslintrc.json",
"prettier.config.js", "prettierrc", "webpack.config.js",
"vite.config.ts", "vite.config.js", "next.config.js",
"nuxt.config.ts", "svelte.config.js",
"rust-toolchain", "rust-toolchain.toml",
"clippy.toml", ".rustfmt.toml", "rustfmt.toml",
"renovate.json", ".renovaterc", ".mergify.yml",
"docker-bake.hcl", ".dockerignore",
"Cargo.lock", "yarn.lock", "package-lock.json", "pnpm-lock.yaml",
"Gemfile.lock", "Cargo.lock",
"Cargo.toml",
"package.json",
"go.mod",
"Gemfile",
"README.md",
"Dockerfile",
"docker-compose.yml",
"docker-compose.yaml",
".github/workflows",
".gitignore",
".gitattributes",
"Makefile",
"CMakeLists.txt",
"composer.json",
"pyproject.toml",
"requirements.txt",
"Pipfile",
"pom.xml",
"build.gradle",
"build.gradle.kts",
"settings.gradle",
"settings.gradle.kts",
"tsconfig.json",
".eslintrc.js",
".eslintrc.json",
"prettier.config.js",
"prettierrc",
"webpack.config.js",
"vite.config.ts",
"vite.config.js",
"next.config.js",
"nuxt.config.ts",
"svelte.config.js",
"rust-toolchain",
"rust-toolchain.toml",
"clippy.toml",
".rustfmt.toml",
"rustfmt.toml",
"renovate.json",
".renovaterc",
".mergify.yml",
"docker-bake.hcl",
".dockerignore",
"Cargo.lock",
"yarn.lock",
"package-lock.json",
"pnpm-lock.yaml",
"Gemfile.lock",
"Cargo.lock",
];
for entry in tree.iter() {
let name = match entry.name() {
@ -243,7 +302,11 @@ fn parse_dependencies(content: &str, manifest_name: &str) -> serde_json::Value {
if in_deps {
if let Some(eq_pos) = trimmed.find('=') {
let name = trimmed[..eq_pos].trim().to_string();
let version = trimmed[eq_pos + 1..].trim().trim_matches('"').trim_matches('\'').to_string();
let version = trimmed[eq_pos + 1..]
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string();
if !name.is_empty() && !name.starts_with('#') {
deps.push(serde_json::json!({ "name": name, "version": version }));
}
@ -300,8 +363,17 @@ fn parse_dependencies(content: &str, manifest_name: &str) -> serde_json::Value {
let trimmed = line.trim();
if trimmed.starts_with("gem ") {
let rest = trimmed.trim_start_matches("gem ");
let name = rest.split(',').next().unwrap_or(rest).trim().trim_matches('"').trim_matches('\'');
let version = rest.split(',').nth(1).map(|v| v.trim().trim_matches('"').trim_matches('\''));
let name = rest
.split(',')
.next()
.unwrap_or(rest)
.trim()
.trim_matches('"')
.trim_matches('\'');
let version = rest
.split(',')
.nth(1)
.map(|v| v.trim().trim_matches('"').trim_matches('\''));
deps.push(serde_json::json!({ "name": name, "version": version }));
}
}
@ -311,7 +383,11 @@ fn parse_dependencies(content: &str, manifest_name: &str) -> serde_json::Value {
let mut deps = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("-r") && !trimmed.starts_with("--") {
if !trimmed.is_empty()
&& !trimmed.starts_with('#')
&& !trimmed.starts_with("-r")
&& !trimmed.starts_with("--")
{
if let Some(eq_eq) = trimmed.find("==") {
let name = trimmed[..eq_eq].trim().to_string();
let version = trimmed[eq_eq + 2..].trim().to_string();
@ -323,7 +399,9 @@ fn parse_dependencies(content: &str, manifest_name: &str) -> serde_json::Value {
}
serde_json::json!({ "manifest": "requirements.txt", "ecosystem": "python", "dependencies": deps })
}
_ => serde_json::json!({ "manifest": manifest_name, "ecosystem": "unknown", "dependencies": [] }),
_ => {
serde_json::json!({ "manifest": manifest_name, "ecosystem": "unknown", "dependencies": [] })
}
}
}
@ -346,10 +424,20 @@ fn head_oid(domain: &git::GitDomain) -> Result<String, String> {
}
/// Tool: repo_overview — quick project overview
async fn repo_overview_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")?;
async fn repo_overview_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 domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
@ -373,7 +461,9 @@ async fn repo_overview_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<
.map(|(lang, (_ext, count))| serde_json::json!({ "language": lang, "file_count": count }))
.collect();
languages.sort_by(|a, b| {
b["file_count"].as_u64().unwrap_or(0)
b["file_count"]
.as_u64()
.unwrap_or(0)
.cmp(&a["file_count"].as_u64().unwrap_or(0))
});
@ -413,10 +503,20 @@ async fn repo_overview_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<
}
/// Tool: repo_file_tree — recursive file tree with depth/ignore
async fn repo_file_tree_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")?;
async fn repo_file_tree_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 max_depth = p.get("max_depth").and_then(|v| v.as_u64()).unwrap_or(3) as usize;
let max_files = p.get("max_files").and_then(|v| v.as_u64()).unwrap_or(200);
@ -434,10 +534,20 @@ async fn repo_file_tree_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result
}
/// Tool: repo_languages — detailed language breakdown
async fn repo_languages_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")?;
async fn repo_languages_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 domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
@ -451,7 +561,9 @@ async fn repo_languages_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result
.map(|(lang, (_ext, count))| serde_json::json!({ "language": lang, "file_count": count }))
.collect();
languages.sort_by(|a, b| {
b["file_count"].as_u64().unwrap_or(0)
b["file_count"]
.as_u64()
.unwrap_or(0)
.cmp(&a["file_count"].as_u64().unwrap_or(0))
});
@ -462,10 +574,20 @@ async fn repo_languages_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result
}
/// Tool: repo_dependencies — parse dependency manifests
async fn repo_dependencies_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")?;
async fn repo_dependencies_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 domain = ctx.open_repo(project_name, repo_name).await?;
let tree = head_tree(&domain)?;

View File

@ -25,42 +25,77 @@ fn head_oid(domain: &git::GitDomain) -> Result<String, String> {
fn is_ignored_dir(name: &str) -> bool {
matches!(
name,
".git" | "node_modules" | "target" | "dist" | "build" | ".next"
| ".nuxt" | ".output" | ".cache" | "__pycache__" | ".tox"
| "vendor" | ".bundle" | ".gradle" | "bin" | "obj"
| ".svn" | ".hg" | ".idea" | ".vscode" | "coverage"
| ".terraform" | ".serverless" | "deps" | "_build"
| "elm-stuff" | ".stack-work" | ".pytest_cache"
".git"
| "node_modules"
| "target"
| "dist"
| "build"
| ".next"
| ".nuxt"
| ".output"
| ".cache"
| "__pycache__"
| ".tox"
| "vendor"
| ".bundle"
| ".gradle"
| "bin"
| "obj"
| ".svn"
| ".hg"
| ".idea"
| ".vscode"
| "coverage"
| ".terraform"
| ".serverless"
| "deps"
| "_build"
| "elm-stuff"
| ".stack-work"
| ".pytest_cache"
)
}
fn is_binary_ext(name: &str) -> bool {
match name.rsplit('.').next().unwrap_or("") {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "svg" | "bmp"
| "mp3" | "mp4" | "wav" | "avi" | "mov" | "mkv" | "webm"
| "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar"
| "exe" | "dll" | "so" | "dylib" | "o" | "a" | "lib"
| "woff" | "woff2" | "ttf" | "otf" | "eot"
| "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx"
| "sqlite" | "db" | "bin" | "dat" | "pyc" | "class"
| "wasm" | "node" => true,
"png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "svg" | "bmp" | "mp3" | "mp4" | "wav"
| "avi" | "mov" | "mkv" | "webm" | "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar"
| "exe" | "dll" | "so" | "dylib" | "o" | "a" | "lib" | "woff" | "woff2" | "ttf" | "otf"
| "eot" | "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" | "sqlite" | "db"
| "bin" | "dat" | "pyc" | "class" | "wasm" | "node" => true,
_ => false,
}
}
/// Resolve a rev string to a commit OID.
fn resolve_commit_oid(domain: &git::GitDomain, rev: &str) -> Result<git::commit::types::CommitOid, String> {
fn resolve_commit_oid(
domain: &git::GitDomain,
rev: &str,
) -> Result<git::commit::types::CommitOid, String> {
domain.resolve_rev(rev).map_err(|e| e.to_string())
}
// ── Tool executors ─────────────────────────────────────────────────────────────
/// Tool: repo_search — search code content across the repo
async fn repo_search_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 keyword = p.get("keyword").and_then(|v| v.as_str()).ok_or("missing keyword")?;
async fn repo_search_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 keyword = p
.get("keyword")
.and_then(|v| v.as_str())
.ok_or("missing keyword")?;
let context_lines = p.get("context_lines").and_then(|v| v.as_u64()).unwrap_or(2) as usize;
let max_results = p.get("max_results").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
@ -153,10 +188,8 @@ async fn repo_search_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<se
let snippets: Vec<serde_json::Value> = windows
.iter()
.map(|(start, end)| {
let snippet: Vec<String> = lines[*start..*end]
.iter()
.map(|l| l.to_string())
.collect();
let snippet: Vec<String> =
lines[*start..*end].iter().map(|l| l.to_string()).collect();
serde_json::json!({
"line_start": start + 1,
"line_end": end,
@ -187,17 +220,34 @@ async fn repo_search_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<se
}
/// Tool: repo_readme — get README content
async fn repo_readme_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")?;
async fn repo_readme_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 domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
// Try common README filenames
let candidates = ["README.md", "README.MD", "README.markdown", "README.rst", "README.txt", "README"];
let candidates = [
"README.md",
"README.MD",
"README.markdown",
"README.rst",
"README.txt",
"README",
];
let mut found = None;
for candidate in &candidates {
@ -225,10 +275,20 @@ async fn repo_readme_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<se
}
/// Tool: repo_commit_log — filtered commit history
async fn repo_commit_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")?;
async fn repo_commit_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 author = p.get("author").and_then(|v| v.as_str());
let keyword = p.get("keyword").and_then(|v| v.as_str());
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
@ -243,7 +303,9 @@ async fn repo_commit_log_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul
limit
};
let commits = domain.commit_log(Some(&head_oid), 0, fetch_limit).map_err(|e| e.to_string())?;
let commits = domain
.commit_log(Some(&head_oid), 0, fetch_limit)
.map_err(|e| e.to_string())?;
let keyword_lower = keyword.map(|k| k.to_lowercase());
let author_lower = author.map(|a| a.to_lowercase());
@ -284,17 +346,29 @@ async fn repo_commit_log_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul
}
/// Tool: repo_contributors — contributor statistics
async fn repo_contributors_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")?;
async fn repo_contributors_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 limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let domain = ctx.open_repo(project_name, repo_name).await?;
let head_oid = head_oid(&domain)?;
// Walk all commits (up to 10000)
let commits = domain.commit_log(Some(&head_oid), 0, 10000).map_err(|e| e.to_string())?;
let commits = domain
.commit_log(Some(&head_oid), 0, 10000)
.map_err(|e| e.to_string())?;
// Aggregate by author email (more reliable than name)
let mut authors: HashMap<String, serde_json::Value> = HashMap::new();
@ -309,9 +383,7 @@ async fn repo_contributors_exec(ctx: GitToolCtx, args: serde_json::Value) -> Res
"last_commit_time": c.author.time_secs,
})
});
entry["commit_count"] = serde_json::json!(
entry["commit_count"].as_u64().unwrap_or(0) + 1
);
entry["commit_count"] = serde_json::json!(entry["commit_count"].as_u64().unwrap_or(0) + 1);
let t = c.author.time_secs;
if t < entry["first_commit_time"].as_i64().unwrap_or(i64::MAX) {
entry["first_commit_time"] = serde_json::json!(t);
@ -323,7 +395,9 @@ async fn repo_contributors_exec(ctx: GitToolCtx, args: serde_json::Value) -> Res
let mut contributors: Vec<serde_json::Value> = authors.into_values().collect();
contributors.sort_by(|a, b| {
b["commit_count"].as_u64().unwrap_or(0)
b["commit_count"]
.as_u64()
.unwrap_or(0)
.cmp(&a["commit_count"].as_u64().unwrap_or(0))
});
@ -338,11 +412,24 @@ async fn repo_contributors_exec(ctx: GitToolCtx, args: serde_json::Value) -> Res
}
/// Tool: repo_diff_summary — change summary between two revisions
async fn repo_diff_summary_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 from_rev = p.get("from_rev").and_then(|v| v.as_str()).ok_or("missing from_rev")?;
async fn repo_diff_summary_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 from_rev = p
.get("from_rev")
.and_then(|v| v.as_str())
.ok_or("missing from_rev")?;
let to_rev = p.get("to_rev").and_then(|v| v.as_str()).unwrap_or("HEAD");
let domain = ctx.open_repo(project_name, repo_name).await?;
@ -351,15 +438,18 @@ async fn repo_diff_summary_exec(ctx: GitToolCtx, args: serde_json::Value) -> Res
let from_oid = resolve_commit_oid(&domain, from_rev)?;
let to_oid = resolve_commit_oid(&domain, to_rev)?;
let from_commit = repo.find_commit(from_oid.to_oid().map_err(|e| e.to_string())?)
let from_commit = repo
.find_commit(from_oid.to_oid().map_err(|e| e.to_string())?)
.map_err(|e| format!("from_rev not found: {e}"))?;
let to_commit = repo.find_commit(to_oid.to_oid().map_err(|e| e.to_string())?)
let to_commit = repo
.find_commit(to_oid.to_oid().map_err(|e| e.to_string())?)
.map_err(|e| format!("to_rev not found: {e}"))?;
let from_tree = from_commit.tree().map_err(|e| e.to_string())?;
let to_tree = to_commit.tree().map_err(|e| e.to_string())?;
let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)
let diff = repo
.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)
.map_err(|e| e.to_string())?;
let stats = diff.stats().map_err(|e| e.to_string())?;

View File

@ -5,11 +5,24 @@ use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use std::collections::HashMap;
async fn git_tag_list_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 pattern = p.get("pattern").and_then(|v| v.as_str()).map(|s| s.to_string());
async fn git_tag_list_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 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())?;
@ -24,9 +37,9 @@ async fn git_tag_list_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<s
.map(|s| regex::escape(s))
.collect::<Vec<_>>()
.join(".*");
let re = regex::Regex::new(&format!("^{}$", regex_pat))
.ok();
all_tags.iter()
let re = regex::Regex::new(&format!("^{}$", regex_pat)).ok();
all_tags
.iter()
.filter(|t| {
let n = t.name.to_lowercase();
re.as_ref().map(|r| r.is_match(&n)).unwrap_or(false)
@ -52,11 +65,24 @@ fn tag_to_json(t: &git::tags::types::TagInfo) -> serde_json::Value {
})
}
async fn git_tag_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 name = p.get("name").and_then(|v| v.as_str()).ok_or("missing name")?;
async fn git_tag_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 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())?;
@ -64,10 +90,20 @@ async fn git_tag_info_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<s
Ok(tag_to_json(&info))
}
async fn git_tag_search_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 query = p.get("query").and_then(|v| v.as_str()).ok_or("missing query")?;
async fn git_tag_search_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 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(5) as usize;
// Resolve project_id from project_name for project isolation
@ -81,35 +117,50 @@ async fn git_tag_search_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result
let project_id = project.id.to_string();
// Get embed_service from context
let embed = ctx.ctx.embed_service()
.ok_or_else(|| "EmbedService not available — Qdrant vector search is disabled".to_string())?;
let embed = ctx.ctx.embed_service().ok_or_else(|| {
"EmbedService not available — Qdrant vector search is disabled".to_string()
})?;
let results = embed.search_tags(query, &project_id, limit).await
let results = embed
.search_tags(query, &project_id, limit)
.await
.map_err(|e| format!("tag search failed: {}", e))?;
let json_results: Vec<serde_json::Value> = results.into_iter().map(|r| {
let repo_name = r.payload.extra.as_ref()
.and_then(|e| e.get("repo_name"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let tag_name = r.payload.extra.as_ref()
.and_then(|e| e.get("tag_name"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let description = r.payload.extra.as_ref()
.and_then(|e| e.get("description"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
serde_json::json!({
"tag_name": tag_name,
"repo_name": repo_name,
"description": description,
"score": r.score,
let json_results: Vec<serde_json::Value> = results
.into_iter()
.map(|r| {
let repo_name = r
.payload
.extra
.as_ref()
.and_then(|e| e.get("repo_name"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let tag_name = r
.payload
.extra
.as_ref()
.and_then(|e| e.get("tag_name"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let description = r
.payload
.extra
.as_ref()
.and_then(|e| e.get("description"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
serde_json::json!({
"tag_name": tag_name,
"repo_name": repo_name,
"description": description,
"score": r.score,
})
})
}).collect();
.collect();
Ok(serde_json::to_value(json_results).map_err(|e| e.to_string())?)
}
@ -117,28 +168,104 @@ async fn git_tag_search_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result
pub fn register_git_tools(registry: &mut ToolRegistry) {
// git_tag_list
let p = 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 }),
("pattern".into(), ToolParam { name: "pattern".into(), param_type: "string".into(), description: Some("Filter tags by name pattern (supports * wildcard)".into()), required: false, properties: None, items: None }),
(
"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,
},
),
(
"pattern".into(),
ToolParam {
name: "pattern".into(),
param_type: "string".into(),
description: Some("Filter tags by name pattern (supports * wildcard)".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()]) };
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["project_name".into(), "repo_name".into()]),
};
registry.register(
ToolDefinition::new("git_tag_list").description("List all tags in a repository, optionally filtered by a name pattern.").parameters(schema),
ToolDefinition::new("git_tag_list")
.description("List all tags in a repository, optionally filtered by a name pattern.")
.parameters(schema),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
git_tag_list_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
git_tag_list_exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
})
}),
);
// git_tag_info
let p = 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 }),
("name".into(), ToolParam { name: "name".into(), param_type: "string".into(), description: Some("Tag name to look up".into()), required: true, properties: None, items: None }),
(
"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,
},
),
(
"name".into(),
ToolParam {
name: "name".into(),
param_type: "string".into(),
description: Some("Tag name to look up".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(), "name".into()]) };
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec![
"project_name".into(),
"repo_name".into(),
"name".into(),
]),
};
registry.register(
ToolDefinition::new("git_tag_info").description("Get detailed information about a specific tag including target commit, annotation, and tagger.").parameters(schema),
ToolHandler::new(|ctx, args| {
@ -151,11 +278,45 @@ pub fn register_git_tools(registry: &mut ToolRegistry) {
// git_tag_search
let p = 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 }),
("query".into(), ToolParam { name: "query".into(), param_type: "string".into(), description: Some("Semantic search query to find relevant tags".into()), required: true, properties: None, items: None }),
("limit".into(), ToolParam { name: "limit".into(), param_type: "integer".into(), description: Some("Maximum number of results (default 5)".into()), required: false, properties: None, items: None }),
(
"project_name".into(),
ToolParam {
name: "project_name".into(),
param_type: "string".into(),
description: Some("Project name (slug)".into()),
required: true,
properties: None,
items: None,
},
),
(
"query".into(),
ToolParam {
name: "query".into(),
param_type: "string".into(),
description: Some("Semantic search query to find relevant tags".into()),
required: true,
properties: None,
items: None,
},
),
(
"limit".into(),
ToolParam {
name: "limit".into(),
param_type: "integer".into(),
description: Some("Maximum number of results (default 5)".into()),
required: false,
properties: None,
items: None,
},
),
]);
let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "query".into()]) };
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["project_name".into(), "query".into()]),
};
registry.register(
ToolDefinition::new("git_tag_search").description("Semantically search tags across repositories in a project. Uses vector search to find tags relevant to the query, with project-level isolation.").parameters(schema),
ToolHandler::new(|ctx, args| {

View File

@ -7,26 +7,51 @@ use std::collections::HashMap;
/// Resolve a rev string to a commit OID using the full rev-parse machinery
/// (branch names, tags, HEAD, hex prefixes, etc.).
fn resolve_commit_oid(domain: &git::GitDomain, rev: &str) -> Result<git::commit::types::CommitOid, String> {
fn resolve_commit_oid(
domain: &git::GitDomain,
rev: &str,
) -> Result<git::commit::types::CommitOid, String> {
domain.resolve_rev(rev).map_err(|e| e.to_string())
}
async fn git_file_content_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 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());
async fn git_file_content_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 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 = resolve_commit_oid(&domain, &rev)?;
let entry = domain.tree_entry_by_path_from_commit(&oid, path).map_err(|e| e.to_string())?;
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)
(
base64::engine::general_purpose::STANDARD.encode(&content.content),
true,
)
} else {
(String::from_utf8_lossy(&content.content).to_string(), false)
};
@ -40,12 +65,29 @@ async fn git_file_content_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resu
}))
}
async fn git_tree_ls_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 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());
async fn git_tree_ls_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 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 = resolve_commit_oid(&domain, &rev)?;
@ -56,38 +98,63 @@ async fn git_tree_ls_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<se
let entries = match dir_path {
Some(ref dp) => {
let entry = domain.tree_entry_by_path(tree_oid, dp).map_err(|e| e.to_string())?;
let entry = domain
.tree_entry_by_path(tree_oid, dp)
.map_err(|e| e.to_string())?;
domain.tree_list(&entry.oid).map_err(|e| e.to_string())?
}
None => domain.tree_list(tree_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
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();
.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<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 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(String::from).unwrap_or_else(|| "HEAD".to_string());
async fn git_file_history_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 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(String::from)
.unwrap_or_else(|| "HEAD".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?;
// Fetch extra commits to have enough candidates after filtering
let walk_limit = limit.saturating_mul(2).max(200);
let commits = domain.commit_log(Some(&rev), 0, walk_limit).map_err(|e| e.to_string())?;
let commits = domain
.commit_log(Some(&rev), 0, walk_limit)
.map_err(|e| e.to_string())?;
let result: Vec<_> = commits.iter()
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))
@ -96,17 +163,36 @@ async fn git_file_history_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resu
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
async fn git_blob_get_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 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(String::from).unwrap_or_else(|| "HEAD".to_string());
async fn git_blob_get_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 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(String::from)
.unwrap_or_else(|| "HEAD".to_string());
let domain = ctx.open_repo(project_name, repo_name).await?;
let oid = resolve_commit_oid(&domain, &rev).map_err(|e| e.to_string())?;
let entry = domain.tree_entry_by_path_from_commit(&oid, path).map_err(|e| e.to_string())?;
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())?;
if blob_info.is_binary {
@ -127,8 +213,11 @@ async fn git_blob_get_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<s
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 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(),
@ -144,12 +233,60 @@ fn flatten_commit(c: &git::commit::types::CommitMeta) -> serde_json::Value {
pub fn register_git_tools(registry: &mut ToolRegistry) {
// git_file_content
let p = 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 }),
("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path within the repository".into()), required: true, properties: None, items: None }),
("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to read file from (default: HEAD)".into()), required: false, properties: None, items: None }),
(
"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,
},
),
(
"path".into(),
ToolParam {
name: "path".into(),
param_type: "string".into(),
description: Some("File path within the repository".into()),
required: true,
properties: None,
items: None,
},
),
(
"rev".into(),
ToolParam {
name: "rev".into(),
param_type: "string".into(),
description: Some("Revision to read file from (default: HEAD)".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(), "path".into()]) };
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec![
"project_name".into(),
"repo_name".into(),
"path".into(),
]),
};
registry.register(
ToolDefinition::new("git_file_content").description("Read the full content of a file at a given revision. Handles both text and binary files.").parameters(schema),
ToolHandler::new(|ctx, args| {
@ -162,12 +299,56 @@ pub fn register_git_tools(registry: &mut ToolRegistry) {
// git_tree_ls
let p = 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 }),
("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("Directory path to list (root if omitted)".into()), required: false, properties: None, items: None }),
("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to list tree from (default: HEAD)".into()), required: false, properties: None, items: None }),
(
"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,
},
),
(
"path".into(),
ToolParam {
name: "path".into(),
param_type: "string".into(),
description: Some("Directory path to list (root if omitted)".into()),
required: false,
properties: None,
items: None,
},
),
(
"rev".into(),
ToolParam {
name: "rev".into(),
param_type: "string".into(),
description: Some("Revision to list tree from (default: HEAD)".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()]) };
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["project_name".into(), "repo_name".into()]),
};
registry.register(
ToolDefinition::new("git_tree_ls").description("List the contents of a directory (tree) at a given revision, showing files and subdirectories.").parameters(schema),
ToolHandler::new(|ctx, args| {
@ -180,13 +361,71 @@ pub fn register_git_tools(registry: &mut ToolRegistry) {
// git_file_history
let p = 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 }),
("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path to trace history for".into()), required: true, properties: None, items: None }),
("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to start history from (default: HEAD)".into()), required: false, properties: None, items: None }),
("limit".into(), ToolParam { name: "limit".into(), param_type: "integer".into(), description: Some("Maximum number of commits to return (default: 20)".into()), required: false, properties: None, items: None }),
(
"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,
},
),
(
"path".into(),
ToolParam {
name: "path".into(),
param_type: "string".into(),
description: Some("File path to trace history for".into()),
required: true,
properties: None,
items: None,
},
),
(
"rev".into(),
ToolParam {
name: "rev".into(),
param_type: "string".into(),
description: Some("Revision to start history from (default: HEAD)".into()),
required: false,
properties: None,
items: None,
},
),
(
"limit".into(),
ToolParam {
name: "limit".into(),
param_type: "integer".into(),
description: Some("Maximum number of commits to return (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(), "path".into()]) };
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec![
"project_name".into(),
"repo_name".into(),
"path".into(),
]),
};
registry.register(
ToolDefinition::new("git_file_history").description("Show the commit history for a specific file, listing all commits that modified it.").parameters(schema),
ToolHandler::new(|ctx, args| {
@ -199,12 +438,60 @@ pub fn register_git_tools(registry: &mut ToolRegistry) {
// git_blob_get
let p = 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 }),
("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path within the repository".into()), required: true, properties: None, items: None }),
("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to read file from (default: HEAD)".into()), required: false, properties: None, items: None }),
(
"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,
},
),
(
"path".into(),
ToolParam {
name: "path".into(),
param_type: "string".into(),
description: Some("File path within the repository".into()),
required: true,
properties: None,
items: None,
},
),
(
"rev".into(),
ToolParam {
name: "rev".into(),
param_type: "string".into(),
description: Some("Revision to read file from (default: HEAD)".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(), "path".into()]) };
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec![
"project_name".into(),
"repo_name".into(),
"path".into(),
]),
};
registry.register(
ToolDefinition::new("git_blob_get").description("Retrieve the raw content of a single file (blob) at a given revision. Returns error if the file is binary.").parameters(schema),
ToolHandler::new(|ctx, args| {
@ -214,4 +501,4 @@ pub fn register_git_tools(registry: &mut ToolRegistry) {
})
}),
);
}
}

View File

@ -11,54 +11,127 @@ use git::tree::types::TreeEntry;
use serde::{Deserialize, Serialize};
#[derive(serde::Deserialize)]
pub struct RevQuery { pub rev: String }
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 }
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<String>, #[serde(default = "dl")] pub limit: u32 }
pub struct GraphParams {
#[serde(default)]
pub rev: Option<String>,
#[serde(default = "dl")]
pub limit: u32,
}
#[derive(serde::Deserialize)]
pub struct ReflogParams { #[serde(default)] pub ref_name: Option<String>, #[serde(default = "reflog_def")] pub limit: u32 }
fn reflog_def() -> u32 { 50 }
pub struct ReflogParams {
#[serde(default)]
pub ref_name: Option<String>,
#[serde(default = "reflog_def")]
pub limit: u32,
}
fn reflog_def() -> u32 {
50
}
#[derive(serde::Deserialize)]
pub struct SingleBranch { pub name: String }
pub struct SingleBranch {
pub name: String,
}
#[derive(serde::Deserialize)]
pub struct BranchesMerged { pub branch: String, #[serde(default)] pub into: Option<String> }
pub struct BranchesMerged {
pub branch: String,
#[serde(default)]
pub into: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct BranchDiffP { pub local: String, #[serde(default)] pub remote: Option<String> }
pub struct BranchDiffP {
pub local: String,
#[serde(default)]
pub remote: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct DiffP { #[serde(default)] pub base: Option<String>, #[serde(default)] pub head: Option<String>, #[serde(default)] pub paths: Option<Vec<String>> }
pub struct DiffP {
#[serde(default)]
pub base: Option<String>,
#[serde(default)]
pub head: Option<String>,
#[serde(default)]
pub paths: Option<Vec<String>>,
}
#[derive(serde::Deserialize)]
pub struct DiffStatsP { pub base: String, pub head: String }
pub struct DiffStatsP {
pub base: String,
pub head: String,
}
#[derive(serde::Deserialize)]
pub struct BlameP { pub path: String, #[serde(default)] pub rev: Option<String>, #[serde(default)] pub from_line: Option<u32>, #[serde(default)] pub to_line: Option<u32> }
pub struct BlameP {
pub path: String,
#[serde(default)]
pub rev: Option<String>,
#[serde(default)]
pub from_line: Option<u32>,
#[serde(default)]
pub to_line: Option<u32>,
}
#[derive(serde::Deserialize)]
pub struct FileContentP { pub path: String, #[serde(default)] pub rev: Option<String> }
pub struct FileContentP {
pub path: String,
#[serde(default)]
pub rev: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct TreeLsP { #[serde(default)] pub path: Option<String>, #[serde(default)] pub rev: Option<String> }
pub struct TreeLsP {
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub rev: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct FileHistoryP { pub path: String, #[serde(default = "dl")] pub limit: u32 }
pub struct FileHistoryP {
pub path: String,
#[serde(default = "dl")]
pub limit: u32,
}
#[derive(serde::Deserialize)]
pub struct TagListP { #[serde(default)] pub pattern: Option<String> }
pub struct TagListP {
#[serde(default)]
pub pattern: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct SingleTagP { pub name: String }
pub struct SingleTagP {
pub name: String,
}
#[derive(serde::Deserialize)]
pub struct GitLogP { #[serde(default)] pub rev: Option<String>, #[serde(default = "dl")] pub limit: u32, #[serde(default)] pub skip: u32 }
pub struct GitLogP {
#[serde(default)]
pub rev: Option<String>,
#[serde(default = "dl")]
pub limit: u32,
#[serde(default)]
pub skip: u32,
}
/// Flat commit information for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -84,7 +157,12 @@ impl CommitInfo {
Self {
oid: meta.oid.to_string(),
short_oid: meta.oid.to_string().get(..7).unwrap_or(&meta.oid.to_string()).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(),

View File

@ -1,6 +1,6 @@
//! AI agent function-call tools: git operations, file parsing/search, project management, and chat tools.
pub mod git_tools;
pub mod file_tools;
pub mod project_tools;
pub mod chat_tools;
pub mod file_tools;
pub mod git_tools;
pub mod project_tools;

View File

@ -72,10 +72,7 @@ pub async fn arxiv_search_exec(
.unwrap_or(DEFAULT_MAX_RESULTS as u64)
.min(MAX_MAX_RESULTS as u64) as usize;
let start = args
.get("start")
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let start = args.get("start").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
// Build arXiv API query URL
// Encode query for URL
@ -138,7 +135,12 @@ pub async fn arxiv_search_exec(
let author_str = if entry.author.is_empty() {
"Unknown".to_string()
} else {
entry.author.iter().map(|a| a.name.as_str()).collect::<Vec<_>>().join(", ")
entry
.author
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(", ")
};
serde_json::json!({
@ -204,16 +206,30 @@ pub fn tool_definition() -> ToolDefinition {
description: Some("Search query (required). Supports arXiv search syntax, e.g. 'ti:transformer AND au:bengio'.".into()),
required: true, properties: None, items: None,
});
p.insert("max_results".into(), ToolParam {
name: "max_results".into(), param_type: "integer".into(),
description: Some("Maximum number of results to return (default 10, max 50). Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("start".into(), ToolParam {
name: "start".into(), param_type: "integer".into(),
description: Some("Offset for pagination. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert(
"max_results".into(),
ToolParam {
name: "max_results".into(),
param_type: "integer".into(),
description: Some(
"Maximum number of results to return (default 10, max 50). Optional.".into(),
),
required: false,
properties: None,
items: None,
},
);
p.insert(
"start".into(),
ToolParam {
name: "start".into(),
param_type: "integer".into(),
description: Some("Offset for pagination. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
ToolDefinition::new("project_arxiv_search")
.description(
"Search arXiv papers by keyword or phrase. \

View File

@ -3,10 +3,8 @@
use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
use chrono::Utc;
use models::projects::{
project_board, project_board_card, project_board_column, project_members,
};
use models::projects::{MemberRole, ProjectBoard, ProjectBoardCard, ProjectBoardColumn};
use models::projects::{project_board, project_board_card, project_board_column, project_members};
use models::users::user::Model as UserModel;
use sea_orm::*;
use std::collections::HashMap;
@ -305,9 +303,7 @@ pub async fn create_board_card_exec(
.get("assignee_id")
.and_then(|v| Uuid::parse_str(v.as_str()?).ok());
let issue_id = args
.get("issue_id")
.and_then(|v| v.as_i64());
let issue_id = args.get("issue_id").and_then(|v| v.as_i64());
// Verify board belongs to project
let board = ProjectBoard::find_by_id(board_id)
@ -575,16 +571,28 @@ pub fn list_tool_definition() -> ToolDefinition {
pub fn create_board_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("name".into(), ToolParam {
name: "name".into(), param_type: "string".into(),
description: Some("Board name (required).".into()),
required: true, properties: None, items: None,
});
p.insert("description".into(), ToolParam {
name: "description".into(), param_type: "string".into(),
description: Some("Board description. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert(
"name".into(),
ToolParam {
name: "name".into(),
param_type: "string".into(),
description: Some("Board name (required).".into()),
required: true,
properties: None,
items: None,
},
);
p.insert(
"description".into(),
ToolParam {
name: "description".into(),
param_type: "string".into(),
description: Some("Board description. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
ToolDefinition::new("project_create_board")
.description(
"Create a new Kanban board in the current project. Requires admin or owner role.",
@ -598,25 +606,41 @@ pub fn create_board_tool_definition() -> ToolDefinition {
pub fn update_board_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("board_id".into(), ToolParam {
name: "board_id".into(), param_type: "string".into(),
description: Some("Board UUID (required).".into()),
required: true, properties: None, items: None,
});
p.insert("name".into(), ToolParam {
name: "name".into(), param_type: "string".into(),
description: Some("New board name. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("description".into(), ToolParam {
name: "description".into(), param_type: "string".into(),
description: Some("New board description. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert(
"board_id".into(),
ToolParam {
name: "board_id".into(),
param_type: "string".into(),
description: Some("Board UUID (required).".into()),
required: true,
properties: None,
items: None,
},
);
p.insert(
"name".into(),
ToolParam {
name: "name".into(),
param_type: "string".into(),
description: Some("New board name. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"description".into(),
ToolParam {
name: "description".into(),
param_type: "string".into(),
description: Some("New board description. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
ToolDefinition::new("project_update_board")
.description(
"Update a Kanban board (name or description). Requires admin or owner role.",
)
.description("Update a Kanban board (name or description). Requires admin or owner role.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
@ -626,41 +650,83 @@ pub fn update_board_tool_definition() -> ToolDefinition {
pub fn create_card_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("board_id".into(), ToolParam {
name: "board_id".into(), param_type: "string".into(),
description: Some("Board UUID (required).".into()),
required: true, properties: None, items: None,
});
p.insert("column_id".into(), ToolParam {
name: "column_id".into(), param_type: "string".into(),
description: Some("Column UUID. Optional — defaults to first column.".into()),
required: false, properties: None, items: None,
});
p.insert("title".into(), ToolParam {
name: "title".into(), param_type: "string".into(),
description: Some("Card title (required).".into()),
required: true, properties: None, items: None,
});
p.insert("description".into(), ToolParam {
name: "description".into(), param_type: "string".into(),
description: Some("Card description. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("priority".into(), ToolParam {
name: "priority".into(), param_type: "string".into(),
description: Some("Card priority (e.g. 'low', 'medium', 'high'). Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("assignee_id".into(), ToolParam {
name: "assignee_id".into(), param_type: "string".into(),
description: Some("Card assignee user UUID. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("issue_id".into(), ToolParam {
name: "issue_id".into(), param_type: "integer".into(),
description: Some("Link a project issue NUMBER to this card. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert(
"board_id".into(),
ToolParam {
name: "board_id".into(),
param_type: "string".into(),
description: Some("Board UUID (required).".into()),
required: true,
properties: None,
items: None,
},
);
p.insert(
"column_id".into(),
ToolParam {
name: "column_id".into(),
param_type: "string".into(),
description: Some("Column UUID. Optional — defaults to first column.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"title".into(),
ToolParam {
name: "title".into(),
param_type: "string".into(),
description: Some("Card title (required).".into()),
required: true,
properties: None,
items: None,
},
);
p.insert(
"description".into(),
ToolParam {
name: "description".into(),
param_type: "string".into(),
description: Some("Card description. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"priority".into(),
ToolParam {
name: "priority".into(),
param_type: "string".into(),
description: Some("Card priority (e.g. 'low', 'medium', 'high'). Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"assignee_id".into(),
ToolParam {
name: "assignee_id".into(),
param_type: "string".into(),
description: Some("Card assignee user UUID. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"issue_id".into(),
ToolParam {
name: "issue_id".into(),
param_type: "integer".into(),
description: Some("Link a project issue NUMBER to this card. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
ToolDefinition::new("project_create_board_card")
.description(
"Create a card on a Kanban board. If column_id is not provided, \
@ -675,46 +741,96 @@ pub fn create_card_tool_definition() -> ToolDefinition {
pub fn update_card_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("card_id".into(), ToolParam {
name: "card_id".into(), param_type: "string".into(),
description: Some("Card UUID (required).".into()),
required: true, properties: None, items: None,
});
p.insert("title".into(), ToolParam {
name: "title".into(), param_type: "string".into(),
description: Some("New card title. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("description".into(), ToolParam {
name: "description".into(), param_type: "string".into(),
description: Some("New card description. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("column_id".into(), ToolParam {
name: "column_id".into(), param_type: "string".into(),
description: Some("Move card to a different column. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("position".into(), ToolParam {
name: "position".into(), param_type: "integer".into(),
description: Some("New position within column. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("priority".into(), ToolParam {
name: "priority".into(), param_type: "string".into(),
description: Some("New priority. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("assignee_id".into(), ToolParam {
name: "assignee_id".into(), param_type: "string".into(),
description: Some("New assignee UUID. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("issue_id".into(), ToolParam {
name: "issue_id".into(), param_type: "integer".into(),
description: Some("Link to a project issue number. Set to 0 to unlink. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert(
"card_id".into(),
ToolParam {
name: "card_id".into(),
param_type: "string".into(),
description: Some("Card UUID (required).".into()),
required: true,
properties: None,
items: None,
},
);
p.insert(
"title".into(),
ToolParam {
name: "title".into(),
param_type: "string".into(),
description: Some("New card title. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"description".into(),
ToolParam {
name: "description".into(),
param_type: "string".into(),
description: Some("New card description. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"column_id".into(),
ToolParam {
name: "column_id".into(),
param_type: "string".into(),
description: Some("Move card to a different column. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"position".into(),
ToolParam {
name: "position".into(),
param_type: "integer".into(),
description: Some("New position within column. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"priority".into(),
ToolParam {
name: "priority".into(),
param_type: "string".into(),
description: Some("New priority. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"assignee_id".into(),
ToolParam {
name: "assignee_id".into(),
param_type: "string".into(),
description: Some("New assignee UUID. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"issue_id".into(),
ToolParam {
name: "issue_id".into(),
param_type: "integer".into(),
description: Some(
"Link to a project issue number. Set to 0 to unlink. Optional.".into(),
),
required: false,
properties: None,
items: None,
},
);
ToolDefinition::new("project_update_board_card")
.description(
"Update a board card (title, description, column, position, assignee, priority). \
@ -729,11 +845,17 @@ pub fn update_card_tool_definition() -> ToolDefinition {
pub fn delete_card_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("card_id".into(), ToolParam {
name: "card_id".into(), param_type: "string".into(),
description: Some("Card UUID (required).".into()),
required: true, properties: None, items: None,
});
p.insert(
"card_id".into(),
ToolParam {
name: "card_id".into(),
param_type: "string".into(),
description: Some("Card UUID (required).".into()),
required: true,
properties: None,
items: None,
},
);
ToolDefinition::new("project_delete_board_card")
.description("Delete a board card. Requires admin or owner role.")
.parameters(ToolSchema {
@ -750,20 +872,28 @@ pub async fn create_board_column_exec(
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let sender_id = ctx.sender_id().ok_or_else(|| ToolError::ExecutionError("No sender".into()))?;
let sender_id = ctx
.sender_id()
.ok_or_else(|| ToolError::ExecutionError("No sender".into()))?;
let db = ctx.db();
require_admin(db, project_id, sender_id).await?;
let board_id = args.get("board_id")
let board_id = args
.get("board_id")
.and_then(|v| Uuid::parse_str(v.as_str()?).ok())
.ok_or_else(|| ToolError::ExecutionError("board_id is required".into()))?;
let name = args.get("name").and_then(|v| v.as_str())
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("name is required".into()))?
.to_string();
let color = args.get("color").and_then(|v| v.as_str()).map(|s| s.to_string());
let color = args
.get("color")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let board = ProjectBoard::find_by_id(board_id)
.one(db)
@ -771,7 +901,9 @@ pub async fn create_board_column_exec(
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Board not found".into()))?;
if board.project != project_id {
return Err(ToolError::ExecutionError("Board does not belong to this project".into()));
return Err(ToolError::ExecutionError(
"Board does not belong to this project".into(),
));
}
let max_pos: Option<Option<i32>> = ProjectBoardColumn::find()
@ -794,7 +926,8 @@ pub async fn create_board_column_exec(
color: Set(color.clone()),
};
let model = active.insert(db)
let model = active
.insert(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
@ -810,21 +943,39 @@ pub async fn create_board_column_exec(
pub fn create_column_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("board_id".into(), ToolParam {
name: "board_id".into(), param_type: "string".into(),
description: Some("Board UUID (required).".into()),
required: true, properties: None, items: None,
});
p.insert("name".into(), ToolParam {
name: "name".into(), param_type: "string".into(),
description: Some("Column name (required).".into()),
required: true, properties: None, items: None,
});
p.insert("color".into(), ToolParam {
name: "color".into(), param_type: "string".into(),
description: Some("Column color (e.g. '#ff0000'). Optional.".into()),
required: false, properties: None, items: None,
});
p.insert(
"board_id".into(),
ToolParam {
name: "board_id".into(),
param_type: "string".into(),
description: Some("Board UUID (required).".into()),
required: true,
properties: None,
items: None,
},
);
p.insert(
"name".into(),
ToolParam {
name: "name".into(),
param_type: "string".into(),
description: Some("Column name (required).".into()),
required: true,
properties: None,
items: None,
},
);
p.insert(
"color".into(),
ToolParam {
name: "color".into(),
param_type: "string".into(),
description: Some("Column color (e.g. '#ff0000'). Optional.".into()),
required: false,
properties: None,
items: None,
},
);
ToolDefinition::new("project_create_board_column")
.description(
"Create a new column on a Kanban board. \

View File

@ -14,8 +14,12 @@ const MAX_BODY_BYTES: usize = 1 << 20;
/// Headers that are blocked from user-supplied values to prevent injection attacks.
const BLOCKED_HEADERS: &[&str] = &[
"host", "authorization", "cookie", "proxy-authorization",
"proxy-connection", "proxy-authenticate",
"host",
"authorization",
"cookie",
"proxy-authorization",
"proxy-connection",
"proxy-authenticate",
];
/// Shared reqwest::Client for connection pooling.
@ -111,9 +115,10 @@ pub async fn curl_exec(
// Block sensitive headers that could be used for injection attacks
for (key, _) in &headers {
if BLOCKED_HEADERS.contains(&key.to_lowercase().as_str()) {
return Err(ToolError::ExecutionError(
format!("Header '{}' is not allowed for security reasons", key),
));
return Err(ToolError::ExecutionError(format!(
"Header '{}' is not allowed for security reasons",
key
)));
}
}
@ -145,7 +150,7 @@ pub async fn curl_exec(
return Err(ToolError::ExecutionError(format!(
"Unsupported HTTP method: {}. Use GET, POST, PUT, DELETE, PATCH, or HEAD.",
method
)))
)));
}
};
@ -156,7 +161,11 @@ pub async fn curl_exec(
}
// Set default Content-Type for POST/PUT/PATCH if not provided and body exists
if body.is_some() && !headers.iter().any(|(k, _)| k.to_lowercase() == "content-type") {
if body.is_some()
&& !headers
.iter()
.any(|(k, _)| k.to_lowercase() == "content-type")
{
request = request.header("Content-Type", "application/json");
}
@ -175,22 +184,29 @@ pub async fn curl_exec(
if status >= 300 && status < 400 {
redirect_count += 1;
if redirect_count > MAX_REDIRECTS {
return Err(ToolError::ExecutionError(
format!("Too many redirects (max {})", MAX_REDIRECTS),
));
return Err(ToolError::ExecutionError(format!(
"Too many redirects (max {})",
MAX_REDIRECTS
)));
}
let location = response.headers()
let location = response
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let location = match location {
Some(l) => l,
None => return Err(ToolError::ExecutionError("Redirect with no Location header".into())),
None => {
return Err(ToolError::ExecutionError(
"Redirect with no Location header".into(),
));
}
};
// Resolve relative redirect against current URL
let base = reqwest::Url::parse(&current_url)
.map_err(|e| ToolError::ExecutionError(format!("Invalid current URL: {}", e)))?;
let next_url = base.join(&location)
let next_url = base
.join(&location)
.map_err(|e| ToolError::ExecutionError(format!("Invalid redirect URL: {}", e)))?;
// Validate redirect target against SSRF rules
if let Some(host) = next_url.host_str() {
@ -209,12 +225,7 @@ pub async fn curl_exec(
let response_headers: std::collections::HashMap<String, String> = response
.headers()
.iter()
.map(|(k, v)| {
(
k.to_string(),
v.to_str().unwrap_or("<binary>").to_string(),
)
})
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("<binary>").to_string()))
.collect();
let content_type = response
@ -229,10 +240,9 @@ pub async fn curl_exec(
|| content_type.contains("xml")
|| content_type.contains("javascript");
let body_bytes = response
.bytes()
.await
.map_err(|e| ToolError::ExecutionError(format!("Failed to read response body: {}", e)))?;
let body_bytes = response.bytes().await.map_err(|e| {
ToolError::ExecutionError(format!("Failed to read response body: {}", e))
})?;
let body_len = body_bytes.len();
let truncated = body_len > MAX_BODY_BYTES;
@ -264,31 +274,64 @@ pub async fn curl_exec(
pub fn tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("url".into(), ToolParam {
name: "url".into(), param_type: "string".into(),
description: Some("Full URL to request (required).".into()),
required: true, properties: None, items: None,
});
p.insert("method".into(), ToolParam {
name: "method".into(), param_type: "string".into(),
description: Some("HTTP method: GET (default), POST, PUT, DELETE, PATCH, HEAD.".into()),
required: false, properties: None, items: None,
});
p.insert("body".into(), ToolParam {
name: "body".into(), param_type: "string".into(),
description: Some("Request body. Defaults to 'application/json' Content-Type if provided. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("headers".into(), ToolParam {
name: "headers".into(), param_type: "object".into(),
description: Some("HTTP headers as key-value pairs. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("timeout".into(), ToolParam {
name: "timeout".into(), param_type: "integer".into(),
description: Some("Request timeout in seconds (default 30, max 120). Optional.".into()),
required: false, properties: None, items: None,
});
p.insert(
"url".into(),
ToolParam {
name: "url".into(),
param_type: "string".into(),
description: Some("Full URL to request (required).".into()),
required: true,
properties: None,
items: None,
},
);
p.insert(
"method".into(),
ToolParam {
name: "method".into(),
param_type: "string".into(),
description: Some("HTTP method: GET (default), POST, PUT, DELETE, PATCH, HEAD.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"body".into(),
ToolParam {
name: "body".into(),
param_type: "string".into(),
description: Some(
"Request body. Defaults to 'application/json' Content-Type if provided. Optional."
.into(),
),
required: false,
properties: None,
items: None,
},
);
p.insert(
"headers".into(),
ToolParam {
name: "headers".into(),
param_type: "object".into(),
description: Some("HTTP headers as key-value pairs. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"timeout".into(),
ToolParam {
name: "timeout".into(),
param_type: "integer".into(),
description: Some("Request timeout in seconds (default 30, max 120). Optional.".into()),
required: false,
properties: None,
items: None,
},
);
ToolDefinition::new("project_curl")
.description(
"Perform an HTTP request to any URL. Supports GET, POST, PUT, DELETE, PATCH, HEAD. \

View File

@ -2,9 +2,11 @@
use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
use chrono::Utc;
use models::issues::{issue, issue_assignee, issue_label, Issue, IssueAssignee, IssueLabel, IssueState};
use models::projects::{MemberRole, ProjectMember};
use models::issues::{
Issue, IssueAssignee, IssueLabel, IssueState, issue, issue_assignee, issue_label,
};
use models::projects::project_members;
use models::projects::{MemberRole, ProjectMember};
use models::system::{Label, label};
use models::users::User;
use sea_orm::*;
@ -232,7 +234,9 @@ pub async fn create_issue_exec(
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
if member.is_none() {
return Err(ToolError::ExecutionError("You are not a member of this project".into()));
return Err(ToolError::ExecutionError(
"You are not a member of this project".into(),
));
}
let number = next_issue_number(db, project_id).await?;
@ -452,11 +456,17 @@ pub async fn update_issue_exec(
pub fn list_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("state".into(), ToolParam {
name: "state".into(), param_type: "string".into(),
description: Some("Filter by issue state: 'open' or 'closed'. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert(
"state".into(),
ToolParam {
name: "state".into(),
param_type: "string".into(),
description: Some("Filter by issue state: 'open' or 'closed'. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
ToolDefinition::new("project_list_issues")
.description(
"List all issues in the current project. \
@ -471,39 +481,75 @@ pub fn list_tool_definition() -> ToolDefinition {
pub fn create_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("title".into(), ToolParam {
name: "title".into(), param_type: "string".into(),
description: Some("Issue title (required).".into()),
required: true, properties: None, items: None,
});
p.insert("body".into(), ToolParam {
name: "body".into(), param_type: "string".into(),
description: Some("Issue body / description. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("milestone".into(), ToolParam {
name: "milestone".into(), param_type: "string".into(),
description: Some("Milestone name. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("assignee_ids".into(), ToolParam {
name: "assignee_ids".into(), param_type: "array".into(),
description: Some("Array of user UUIDs to assign. Optional.".into()),
required: false, properties: None,
items: Some(Box::new(ToolParam {
name: "".into(), param_type: "string".into(),
description: None, required: false, properties: None, items: None,
})),
});
p.insert("label_ids".into(), ToolParam {
name: "label_ids".into(), param_type: "array".into(),
description: Some("Array of label IDs to apply. Optional.".into()),
required: false, properties: None,
items: Some(Box::new(ToolParam {
name: "".into(), param_type: "integer".into(),
description: None, required: false, properties: None, items: None,
})),
});
p.insert(
"title".into(),
ToolParam {
name: "title".into(),
param_type: "string".into(),
description: Some("Issue title (required).".into()),
required: true,
properties: None,
items: None,
},
);
p.insert(
"body".into(),
ToolParam {
name: "body".into(),
param_type: "string".into(),
description: Some("Issue body / description. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"milestone".into(),
ToolParam {
name: "milestone".into(),
param_type: "string".into(),
description: Some("Milestone name. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"assignee_ids".into(),
ToolParam {
name: "assignee_ids".into(),
param_type: "array".into(),
description: Some("Array of user UUIDs to assign. Optional.".into()),
required: false,
properties: None,
items: Some(Box::new(ToolParam {
name: "".into(),
param_type: "string".into(),
description: None,
required: false,
properties: None,
items: None,
})),
},
);
p.insert(
"label_ids".into(),
ToolParam {
name: "label_ids".into(),
param_type: "array".into(),
description: Some("Array of label IDs to apply. Optional.".into()),
required: false,
properties: None,
items: Some(Box::new(ToolParam {
name: "".into(),
param_type: "integer".into(),
description: None,
required: false,
properties: None,
items: None,
})),
},
);
ToolDefinition::new("project_create_issue")
.description(
"Create a new issue in the current project. \
@ -518,31 +564,61 @@ pub fn create_tool_definition() -> ToolDefinition {
pub fn update_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("number".into(), ToolParam {
name: "number".into(), param_type: "integer".into(),
description: Some("Issue number (required).".into()),
required: true, properties: None, items: None,
});
p.insert("title".into(), ToolParam {
name: "title".into(), param_type: "string".into(),
description: Some("New issue title. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("body".into(), ToolParam {
name: "body".into(), param_type: "string".into(),
description: Some("New issue body. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("state".into(), ToolParam {
name: "state".into(), param_type: "string".into(),
description: Some("New issue state: 'open' or 'closed'. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("milestone".into(), ToolParam {
name: "milestone".into(), param_type: "string".into(),
description: Some("New milestone name. Set to null to remove. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert(
"number".into(),
ToolParam {
name: "number".into(),
param_type: "integer".into(),
description: Some("Issue number (required).".into()),
required: true,
properties: None,
items: None,
},
);
p.insert(
"title".into(),
ToolParam {
name: "title".into(),
param_type: "string".into(),
description: Some("New issue title. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"body".into(),
ToolParam {
name: "body".into(),
param_type: "string".into(),
description: Some("New issue body. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"state".into(),
ToolParam {
name: "state".into(),
param_type: "string".into(),
description: Some("New issue state: 'open' or 'closed'. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"milestone".into(),
ToolParam {
name: "milestone".into(),
param_type: "string".into(),
description: Some("New milestone name. Set to null to remove. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
ToolDefinition::new("project_update_issue")
.description(
"Update an existing issue in the current project by its number. \
@ -564,10 +640,14 @@ pub async fn assign_issue_exec(
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let sender_id = ctx.sender_id().ok_or_else(|| ToolError::ExecutionError("No sender".into()))?;
let sender_id = ctx
.sender_id()
.ok_or_else(|| ToolError::ExecutionError("No sender".into()))?;
let db = ctx.db();
let number = args.get("number").and_then(|v| v.as_i64())
let number = args
.get("number")
.and_then(|v| v.as_i64())
.ok_or_else(|| ToolError::ExecutionError("number is required".into()))?;
let issue = Issue::find()
@ -580,14 +660,24 @@ pub async fn assign_issue_exec(
require_issue_modifier(db, project_id, sender_id, issue.author).await?;
let add_ids: Vec<Uuid> = args.get("add_user_ids")
let add_ids: Vec<Uuid> = args
.get("add_user_ids")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| Uuid::parse_str(v.as_str()?).ok()).collect())
.map(|a| {
a.iter()
.filter_map(|v| Uuid::parse_str(v.as_str()?).ok())
.collect()
})
.unwrap_or_default();
let remove_ids: Vec<Uuid> = args.get("remove_user_ids")
let remove_ids: Vec<Uuid> = args
.get("remove_user_ids")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| Uuid::parse_str(v.as_str()?).ok()).collect())
.map(|a| {
a.iter()
.filter_map(|v| Uuid::parse_str(v.as_str()?).ok())
.collect()
})
.unwrap_or_default();
let now = Utc::now();
@ -608,7 +698,9 @@ pub async fn assign_issue_exec(
assigned_at: Set(now),
..Default::default()
};
am.insert(db).await.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
am.insert(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
}
for uid in &remove_ids {
@ -653,29 +745,53 @@ pub async fn assign_issue_exec(
pub fn assign_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("number".into(), ToolParam {
name: "number".into(), param_type: "integer".into(),
description: Some("Issue number (required).".into()),
required: true, properties: None, items: None,
});
p.insert("add_user_ids".into(), ToolParam {
name: "add_user_ids".into(), param_type: "array".into(),
description: Some("Array of user UUIDs to add as assignees. Optional.".into()),
required: false, properties: None,
items: Some(Box::new(ToolParam {
name: "".into(), param_type: "string".into(),
description: Some("User UUID".into()), required: false, properties: None, items: None,
})),
});
p.insert("remove_user_ids".into(), ToolParam {
name: "remove_user_ids".into(), param_type: "array".into(),
description: Some("Array of user UUIDs to remove from assignees. Optional.".into()),
required: false, properties: None,
items: Some(Box::new(ToolParam {
name: "".into(), param_type: "string".into(),
description: Some("User UUID".into()), required: false, properties: None, items: None,
})),
});
p.insert(
"number".into(),
ToolParam {
name: "number".into(),
param_type: "integer".into(),
description: Some("Issue number (required).".into()),
required: true,
properties: None,
items: None,
},
);
p.insert(
"add_user_ids".into(),
ToolParam {
name: "add_user_ids".into(),
param_type: "array".into(),
description: Some("Array of user UUIDs to add as assignees. Optional.".into()),
required: false,
properties: None,
items: Some(Box::new(ToolParam {
name: "".into(),
param_type: "string".into(),
description: Some("User UUID".into()),
required: false,
properties: None,
items: None,
})),
},
);
p.insert(
"remove_user_ids".into(),
ToolParam {
name: "remove_user_ids".into(),
param_type: "array".into(),
description: Some("Array of user UUIDs to remove from assignees. Optional.".into()),
required: false,
properties: None,
items: Some(Box::new(ToolParam {
name: "".into(),
param_type: "string".into(),
description: Some("User UUID".into()),
required: false,
properties: None,
items: None,
})),
},
);
ToolDefinition::new("project_assign_issue")
.description(
"Add or remove assignees on an issue by its number. \
@ -696,13 +812,19 @@ pub async fn add_comment_exec(
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let sender_id = ctx.sender_id().ok_or_else(|| ToolError::ExecutionError("No sender".into()))?;
let sender_id = ctx
.sender_id()
.ok_or_else(|| ToolError::ExecutionError("No sender".into()))?;
let db = ctx.db();
let number = args.get("number").and_then(|v| v.as_i64())
let number = args
.get("number")
.and_then(|v| v.as_i64())
.ok_or_else(|| ToolError::ExecutionError("number is required".into()))?;
let body = args.get("body").and_then(|v| v.as_str())
let body = args
.get("body")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("body is required".into()))?
.to_string();
@ -722,7 +844,9 @@ pub async fn add_comment_exec(
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
if member.is_none() {
return Err(ToolError::ExecutionError("You are not a member of this project".into()));
return Err(ToolError::ExecutionError(
"You are not a member of this project".into(),
));
}
let now = Utc::now();
@ -734,16 +858,23 @@ pub async fn add_comment_exec(
created_at: Set(now),
updated_at: Set(now),
};
let model = comment.insert(db).await
let model = comment
.insert(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
// Update issue updated_at
let mut i_active: issue::ActiveModel = issue.into();
i_active.updated_at = Set(now);
i_active.update(db).await.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
i_active
.update(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
// Look up author name
let author_name = User::find_by_id(sender_id).one(db).await
let author_name = User::find_by_id(sender_id)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.map(|u| u.display_name.unwrap_or(u.username));
@ -759,16 +890,28 @@ pub async fn add_comment_exec(
pub fn add_comment_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("number".into(), ToolParam {
name: "number".into(), param_type: "integer".into(),
description: Some("Issue number (required).".into()),
required: true, properties: None, items: None,
});
p.insert("body".into(), ToolParam {
name: "body".into(), param_type: "string".into(),
description: Some("Comment body text (required).".into()),
required: true, properties: None, items: None,
});
p.insert(
"number".into(),
ToolParam {
name: "number".into(),
param_type: "integer".into(),
description: Some("Issue number (required).".into()),
required: true,
properties: None,
items: None,
},
);
p.insert(
"body".into(),
ToolParam {
name: "body".into(),
param_type: "string".into(),
description: Some("Comment body text (required).".into()),
required: true,
properties: None,
items: None,
},
);
ToolDefinition::new("project_add_comment")
.description(
"Add a comment to an issue in the current project by its number. \
@ -797,22 +940,24 @@ pub async fn list_labels_exec(
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let result: Vec<serde_json::Value> = labels.into_iter().map(|l| {
serde_json::json!({
"id": l.id,
"name": l.name,
"color": l.color,
let result: Vec<serde_json::Value> = labels
.into_iter()
.map(|l| {
serde_json::json!({
"id": l.id,
"name": l.name,
"color": l.color,
})
})
}).collect();
.collect();
Ok(serde_json::to_value(result).map_err(|e| ToolError::ExecutionError(e.to_string()))?)
}
pub fn list_labels_tool_definition() -> ToolDefinition {
ToolDefinition::new("project_list_labels")
.description(
"List all labels available in the current project. \
ToolDefinition::new("project_list_labels").description(
"List all labels available in the current project. \
Returns label id, name, color, and description. \
Use label IDs when creating or updating issues.",
)
)
}

View File

@ -25,8 +25,7 @@ pub async fn list_members_exec(
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let user_map: std::collections::HashMap<_, _> =
users.into_iter().map(|u| (u.uid, u)).collect();
let user_map: std::collections::HashMap<_, _> = users.into_iter().map(|u| (u.uid, u)).collect();
let result: Vec<_> = members
.into_iter()

View File

@ -17,20 +17,18 @@ use agent::{ToolHandler, ToolRegistry};
pub use arxiv::arxiv_search_exec;
pub use boards::{
create_board_card_exec, create_board_exec, create_board_column_exec,
delete_board_card_exec, list_boards_exec,
update_board_card_exec, update_board_exec,
create_board_card_exec, create_board_column_exec, create_board_exec, delete_board_card_exec,
list_boards_exec, update_board_card_exec, update_board_exec,
};
pub use curl::curl_exec;
pub use issues::{
add_comment_exec, assign_issue_exec, create_issue_exec, list_issues_exec,
list_labels_exec, update_issue_exec,
add_comment_exec, assign_issue_exec, create_issue_exec, list_issues_exec, list_labels_exec,
update_issue_exec,
};
pub use members::list_members_exec;
pub use repos::{create_commit_exec, create_repo_exec, list_repos_exec, update_repo_exec};
pub fn register_all(registry: &mut ToolRegistry) {
registry.register(
arxiv::tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(arxiv_search_exec(ctx, args))),

View File

@ -5,8 +5,8 @@ use chrono::Utc;
use git::commit::types::CommitOid;
use git::commit::types::CommitSignature;
use git2;
use models::projects::{MemberRole, ProjectMember};
use models::projects::project_members;
use models::projects::{MemberRole, ProjectMember};
use models::repos::repo;
use models::users::user_email;
use sea_orm::*;
@ -96,9 +96,15 @@ pub async fn create_repo_exec(
.to_string();
// Validate repo name: no path traversal, no special chars
if repo_name.contains("..") || repo_name.contains('/') || repo_name.contains('\\')
|| repo_name.is_empty() || repo_name.len() > 100
|| !repo_name.chars().next().map_or(false, |c| c.is_alphanumeric())
if repo_name.contains("..")
|| repo_name.contains('/')
|| repo_name.contains('\\')
|| repo_name.is_empty()
|| repo_name.len() > 100
|| !repo_name
.chars()
.next()
.map_or(false, |c| c.is_alphanumeric())
{
return Err(ToolError::ExecutionError(
"Invalid repository name: must start with alphanumeric, contain no path separators or '..', max 100 chars".into(),
@ -176,7 +182,10 @@ pub async fn create_repo_exec(
let repo_name = model.repo_name.clone();
let repo_desc = model.description.clone();
tokio::spawn(async move {
if let Err(e) = es.embed_repo(&repo_id, &repo_name, repo_desc.as_deref()).await {
if let Err(e) = es
.embed_repo(&repo_id, &repo_name, repo_desc.as_deref())
.await
{
tracing::warn!(error = %e, repo_id = %repo_id, "failed to embed repo");
}
});
@ -276,7 +285,10 @@ pub async fn update_repo_exec(
let repo_name = model.repo_name.clone();
let repo_desc = model.description.clone();
tokio::spawn(async move {
if let Err(e) = es.embed_repo(&repo_id, &repo_name, repo_desc.as_deref()).await {
if let Err(e) = es
.embed_repo(&repo_id, &repo_name, repo_desc.as_deref())
.await
{
tracing::warn!(error = %e, repo_id = %repo_id, "failed to re-embed repo on update");
}
});
@ -345,11 +357,15 @@ pub async fn create_commit_exec(
.to_string();
// Validate branch: no path traversal, no backslashes, not empty, no lock files
if branch.contains("..") || branch.contains('\\') || branch.is_empty()
|| branch.ends_with(".lock") || branch.starts_with('-')
if branch.contains("..")
|| branch.contains('\\')
|| branch.is_empty()
|| branch.ends_with(".lock")
|| branch.starts_with('-')
{
return Err(ToolError::ExecutionError(
"Invalid branch name: must not contain '..' or backslashes, and must not be empty".into(),
"Invalid branch name: must not contain '..' or backslashes, and must not be empty"
.into(),
));
}
@ -415,9 +431,10 @@ pub async fn create_commit_exec(
let parent_oid = repo.refname_to_id(&format!("refs/heads/{}", branch)).ok();
if has_head && parent_oid.is_none() {
return Err(ToolError::ExecutionError(
format!("Branch '{}' does not exist in this repository", branch),
));
return Err(ToolError::ExecutionError(format!(
"Branch '{}' does not exist in this repository",
branch
)));
}
let parent_ids: Vec<CommitOid> = parent_oid
@ -434,12 +451,15 @@ pub async fn create_commit_exec(
// If repo has a parent commit, read its tree into the index so we don't
// lose existing files when write_tree() is called.
if let Some(oid) = &parent_oid {
let parent_commit = repo.find_commit(*oid)
.map_err(|e| ToolError::ExecutionError(format!("Failed to find parent commit: {}", e)))?;
let parent_tree = parent_commit.tree()
.map_err(|e| ToolError::ExecutionError(format!("Failed to get parent tree: {}", e)))?;
index.read_tree(&parent_tree)
.map_err(|e| ToolError::ExecutionError(format!("Failed to read parent tree into index: {}", e)))?;
let parent_commit = repo.find_commit(*oid).map_err(|e| {
ToolError::ExecutionError(format!("Failed to find parent commit: {}", e))
})?;
let parent_tree = parent_commit.tree().map_err(|e| {
ToolError::ExecutionError(format!("Failed to get parent tree: {}", e))
})?;
index.read_tree(&parent_tree).map_err(|e| {
ToolError::ExecutionError(format!("Failed to read parent tree into index: {}", e))
})?;
}
for file in files_data {
@ -449,12 +469,17 @@ pub async fn create_commit_exec(
.ok_or_else(|| ToolError::ExecutionError("Each file must have a 'path'".into()))?;
// Validate path: no traversal, no absolute paths, no .git/ prefix
if path.contains("..") || path.starts_with('/') || path.starts_with('\\')
|| path.is_empty() || path.starts_with(".git/") || path == ".git"
if path.contains("..")
|| path.starts_with('/')
|| path.starts_with('\\')
|| path.is_empty()
|| path.starts_with(".git/")
|| path == ".git"
{
return Err(ToolError::ExecutionError(
format!("Invalid file path '{}': must be relative, no '..' or absolute path components", path)
));
return Err(ToolError::ExecutionError(format!(
"Invalid file path '{}': must be relative, no '..' or absolute path components",
path
)));
}
let content = file
.get("content")
@ -477,9 +502,11 @@ pub async fn create_commit_exec(
flags_extended: 0,
path: path.as_bytes().to_vec(),
};
index.add_frombuffer(&mut entry, content.as_bytes()).map_err(|e| {
ToolError::ExecutionError(format!("Failed to add '{}' to index: {}", path, e))
})?;
index
.add_frombuffer(&mut entry, content.as_bytes())
.map_err(|e| {
ToolError::ExecutionError(format!("Failed to add '{}' to index: {}", path, e))
})?;
}
let tree_oid = index
@ -546,21 +573,41 @@ pub fn list_tool_definition() -> ToolDefinition {
pub fn create_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("name".into(), ToolParam {
name: "name".into(), param_type: "string".into(),
description: Some("Repository name (required). Must be unique within the project.".into()),
required: true, properties: None, items: None,
});
p.insert("description".into(), ToolParam {
name: "description".into(), param_type: "string".into(),
description: Some("Repository description. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("is_private".into(), ToolParam {
name: "is_private".into(), param_type: "boolean".into(),
description: Some("Whether the repo is private. Defaults to false. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert(
"name".into(),
ToolParam {
name: "name".into(),
param_type: "string".into(),
description: Some(
"Repository name (required). Must be unique within the project.".into(),
),
required: true,
properties: None,
items: None,
},
);
p.insert(
"description".into(),
ToolParam {
name: "description".into(),
param_type: "string".into(),
description: Some("Repository description. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"is_private".into(),
ToolParam {
name: "is_private".into(),
param_type: "boolean".into(),
description: Some("Whether the repo is private. Defaults to false. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
ToolDefinition::new("project_create_repo")
.description(
"Create a new repository in the current project. \
@ -576,26 +623,50 @@ pub fn create_tool_definition() -> ToolDefinition {
pub fn update_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("name".into(), ToolParam {
name: "name".into(), param_type: "string".into(),
description: Some("Repository name (required).".into()),
required: true, properties: None, items: None,
});
p.insert("description".into(), ToolParam {
name: "description".into(), param_type: "string".into(),
description: Some("New repository description. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("is_private".into(), ToolParam {
name: "is_private".into(), param_type: "boolean".into(),
description: Some("New privacy setting. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("default_branch".into(), ToolParam {
name: "default_branch".into(), param_type: "string".into(),
description: Some("New default branch name. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert(
"name".into(),
ToolParam {
name: "name".into(),
param_type: "string".into(),
description: Some("Repository name (required).".into()),
required: true,
properties: None,
items: None,
},
);
p.insert(
"description".into(),
ToolParam {
name: "description".into(),
param_type: "string".into(),
description: Some("New repository description. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"is_private".into(),
ToolParam {
name: "is_private".into(),
param_type: "boolean".into(),
description: Some("New privacy setting. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"default_branch".into(),
ToolParam {
name: "default_branch".into(),
param_type: "string".into(),
description: Some("New default branch name. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
ToolDefinition::new("project_update_repo")
.description(
"Update a repository's description, privacy, or default branch. \
@ -610,47 +681,94 @@ pub fn update_tool_definition() -> ToolDefinition {
pub fn create_commit_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("repo_name".into(), ToolParam {
name: "repo_name".into(), param_type: "string".into(),
description: Some("Repository name. Can also use 'name' as alias. Required.".into()),
required: true, properties: None, items: None,
});
p.insert("name".into(), ToolParam {
name: "name".into(), param_type: "string".into(),
description: Some("Alias for repo_name. Use the same value as returned by project_list_repos.".into()),
required: false, properties: None, items: None,
});
p.insert("branch".into(), ToolParam {
name: "branch".into(), param_type: "string".into(),
description: Some("Branch to commit to. Defaults to 'main'. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("message".into(), ToolParam {
name: "message".into(), param_type: "string".into(),
description: Some("Commit message (required).".into()),
required: true, properties: None, items: None,
});
p.insert(
"repo_name".into(),
ToolParam {
name: "repo_name".into(),
param_type: "string".into(),
description: Some("Repository name. Can also use 'name' as alias. Required.".into()),
required: true,
properties: None,
items: None,
},
);
p.insert(
"name".into(),
ToolParam {
name: "name".into(),
param_type: "string".into(),
description: Some(
"Alias for repo_name. Use the same value as returned by project_list_repos.".into(),
),
required: false,
properties: None,
items: None,
},
);
p.insert(
"branch".into(),
ToolParam {
name: "branch".into(),
param_type: "string".into(),
description: Some("Branch to commit to. Defaults to 'main'. Optional.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"message".into(),
ToolParam {
name: "message".into(),
param_type: "string".into(),
description: Some("Commit message (required).".into()),
required: true,
properties: None,
items: None,
},
);
// files items
let mut file_item = HashMap::new();
file_item.insert("path".into(), ToolParam {
name: "path".into(), param_type: "string".into(),
description: Some("File path in the repo (required).".into()),
required: true, properties: None, items: None,
});
file_item.insert("content".into(), ToolParam {
name: "content".into(), param_type: "string".into(),
description: Some("Full file content as string (required).".into()),
required: true, properties: None, items: None,
});
p.insert("files".into(), ToolParam {
name: "files".into(), param_type: "array".into(),
description: Some("Array of files to commit (required, non-empty).".into()),
required: true, properties: None,
items: Some(Box::new(ToolParam {
name: "".into(), param_type: "object".into(),
description: None, required: true, properties: Some(file_item), items: None,
})),
});
file_item.insert(
"path".into(),
ToolParam {
name: "path".into(),
param_type: "string".into(),
description: Some("File path in the repo (required).".into()),
required: true,
properties: None,
items: None,
},
);
file_item.insert(
"content".into(),
ToolParam {
name: "content".into(),
param_type: "string".into(),
description: Some("Full file content as string (required).".into()),
required: true,
properties: None,
items: None,
},
);
p.insert(
"files".into(),
ToolParam {
name: "files".into(),
param_type: "array".into(),
description: Some("Array of files to commit (required, non-empty).".into()),
required: true,
properties: None,
items: Some(Box::new(ToolParam {
name: "".into(),
param_type: "object".into(),
description: None,
required: true,
properties: Some(file_item),
items: None,
})),
},
);
ToolDefinition::new("project_create_commit")
.description(
"Create a new commit in a repository. Commits the given files to the specified branch. \