Compare commits
7 Commits
c2b4553537
...
bba35f1b2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bba35f1b2c | ||
|
|
03f97c9221 | ||
|
|
30822bbd7d | ||
|
|
b673c31485 | ||
|
|
a5704c9730 | ||
|
|
4ef0d5b570 | ||
|
|
5f12b07120 |
@ -685,17 +685,30 @@ impl ChatService {
|
||||
|
||||
for call in &calls {
|
||||
let start = std::time::Instant::now();
|
||||
let executor = crate::tool::ToolExecutor::new();
|
||||
|
||||
// Use select! loop to send heartbeat chunks at 30s intervals
|
||||
// during long tool execution, resetting the frontend streaming timer.
|
||||
let fut = executor.execute_batch(vec![call.clone()], &mut ctx);
|
||||
tokio::pin!(fut);
|
||||
// Spawn tool execution in a separate task to avoid blocking the
|
||||
// tokio worker thread (git2 operations are synchronous).
|
||||
// This allows the heartbeat timer to fire independently.
|
||||
let call_clone = call.clone();
|
||||
let mut ctx_clone = ctx.clone();
|
||||
let (result_tx, mut result_rx) = tokio::sync::oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
let executor = crate::tool::ToolExecutor::new();
|
||||
let res = executor.execute_batch(vec![call_clone], &mut ctx_clone).await;
|
||||
let _ = result_tx.send(res);
|
||||
});
|
||||
|
||||
// Send heartbeats every 10s until tool execution completes
|
||||
let heartbeat_dur = std::time::Duration::from_secs(10);
|
||||
let results = loop {
|
||||
tokio::select! {
|
||||
result = fut.as_mut() => break result,
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(30)) => {
|
||||
res = &mut result_rx => {
|
||||
match res {
|
||||
Ok(inner) => break inner,
|
||||
Err(_) => break Err(crate::tool::ToolError::ExecutionError("tool task cancelled".into())),
|
||||
}
|
||||
},
|
||||
_ = tokio::time::sleep(heartbeat_dur) => {
|
||||
on_chunk(AiStreamChunk {
|
||||
content: String::new(),
|
||||
done: false,
|
||||
@ -908,6 +921,17 @@ impl ChatService {
|
||||
async fn build_messages(&self, request: &AiRequest) -> Result<Vec<ChatRequestMessage>> {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
// Core reasoning instruction — prioritize analysis before answering.
|
||||
messages.push(ChatRequestMessage::system(
|
||||
"When receiving a question or problem, follow this reasoning process:\n\
|
||||
1. ANALYZE: Break down the question. Identify what is being asked, what context is available, and what information is missing.\n\
|
||||
2. GATHER: Use available tools (repository search, file reading, etc.) to collect relevant information before answering.\n\
|
||||
3. REASON: Synthesize the gathered information. Consider edge cases and potential issues.\n\
|
||||
4. ANSWER: Provide a clear, actionable answer based on your analysis.\n\
|
||||
\n\
|
||||
Do NOT guess or assume when tools can provide concrete answers. Always verify claims against actual code or documentation.".to_string()
|
||||
));
|
||||
|
||||
let mut processed_history = Vec::new();
|
||||
if let Some(compact_service) = &self.compact_service {
|
||||
let config = CompactConfig::default();
|
||||
|
||||
384
libs/fctool/src/git_tools/kb.rs
Normal file
384
libs/fctool/src/git_tools/kb.rs
Normal file
@ -0,0 +1,384 @@
|
||||
//! Knowledge-base (documentation) repository tools for AI.
|
||||
//!
|
||||
//! Provides tools for AI to quickly index, read, and search
|
||||
//! through documentation / knowledge-base repositories.
|
||||
|
||||
use super::ctx::GitToolCtx;
|
||||
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract frontmatter (--- ... ---) from markdown content.
|
||||
fn extract_frontmatter(raw: &str) -> (Option<&str>, &str) {
|
||||
let trimmed = raw.trim_start();
|
||||
if !trimmed.starts_with("---") {
|
||||
return (None, trimmed);
|
||||
}
|
||||
if let Some(end) = trimmed[3..].find("---") {
|
||||
let fm = &trimmed[3..end + 3];
|
||||
let rest = trimmed[3 + end + 3..].trim_start();
|
||||
(Some(fm), rest)
|
||||
} else {
|
||||
(None, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract all headings (lines starting with #) from markdown body.
|
||||
fn extract_headings(body: &str) -> Vec<serde_json::Value> {
|
||||
body.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("# ") {
|
||||
Some(serde_json::json!({ "level": 1, "text": trimmed[2..].trim() }))
|
||||
} else if trimmed.starts_with("## ") {
|
||||
Some(serde_json::json!({ "level": 2, "text": trimmed[3..].trim() }))
|
||||
} else if trimmed.starts_with("### ") {
|
||||
Some(serde_json::json!({ "level": 3, "text": trimmed[4..].trim() }))
|
||||
} else if trimmed.starts_with("#### ") {
|
||||
Some(serde_json::json!({ "level": 4, "text": trimmed[5..].trim() }))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve HEAD to a tree for traversal.
|
||||
fn head_tree(domain: &git::GitDomain) -> Result<git2::Tree<'_>, String> {
|
||||
let repo = domain.repo();
|
||||
let head = repo.head().map_err(|e| format!("no HEAD: {e}"))?;
|
||||
head.peel_to_tree().map_err(|e| format!("no tree: {e}"))
|
||||
}
|
||||
|
||||
// ── 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")?;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let repo = domain.repo();
|
||||
let tree = head_tree(&domain)?;
|
||||
|
||||
let mut docs = Vec::new();
|
||||
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())];
|
||||
|
||||
while let Some((current_tree, prefix)) = stack.pop() {
|
||||
for entry in current_tree.iter() {
|
||||
let name = match entry.name() {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
let entry_path = if prefix.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", prefix, name)
|
||||
};
|
||||
match entry.kind() {
|
||||
Some(git2::ObjectType::Tree) => {
|
||||
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 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);
|
||||
let metadata: serde_json::Value = fm_raw
|
||||
.and_then(|fm| serde_json::from_str(fm).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let title = metadata
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.or_else(|| {
|
||||
// Fall back to first # heading
|
||||
body.lines()
|
||||
.find(|l| l.trim().starts_with("# "))
|
||||
.map(|l| l.trim()[2..].trim().to_string())
|
||||
});
|
||||
|
||||
let description = metadata
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.or_else(|| {
|
||||
// Fall back to first non-heading non-empty line
|
||||
body.lines()
|
||||
.find(|l| {
|
||||
let t = l.trim();
|
||||
!t.is_empty() && !t.starts_with('#')
|
||||
})
|
||||
.map(|l| l.trim().chars().take(200).collect::<String>())
|
||||
});
|
||||
|
||||
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())
|
||||
.unwrap_or_default();
|
||||
|
||||
let headings = extract_headings(body);
|
||||
|
||||
docs.push(serde_json::json!({
|
||||
"path": entry_path,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"tags": tags,
|
||||
"headings": headings,
|
||||
"size": raw.len(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by path for consistent ordering
|
||||
docs.sort_by(|a, b| {
|
||||
a["path"].as_str().unwrap_or("").cmp(b["path"].as_str().unwrap_or(""))
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"total": docs.len(),
|
||||
"docs": docs
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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")?;
|
||||
|
||||
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))
|
||||
.map_err(|e| format!("file '{}' not found: {e}", path))?;
|
||||
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());
|
||||
let (fm_raw, body) = extract_frontmatter(&raw);
|
||||
let metadata: serde_json::Value = fm_raw
|
||||
.and_then(|fm| serde_json::from_str(fm).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let title = metadata
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.or_else(|| {
|
||||
body.lines()
|
||||
.find(|l| l.trim().starts_with("# "))
|
||||
.map(|l| l.trim()[2..].trim().to_string())
|
||||
});
|
||||
|
||||
let headings = extract_headings(body);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"path": path,
|
||||
"title": title,
|
||||
"metadata": metadata,
|
||||
"headings": headings,
|
||||
"content": body.to_string(),
|
||||
"size": raw.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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")?;
|
||||
let context_lines = p.get("context_lines").and_then(|v| v.as_u64()).unwrap_or(2) as usize;
|
||||
|
||||
let keyword_lower = keyword.to_lowercase();
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let repo = domain.repo();
|
||||
let tree = head_tree(&domain)?;
|
||||
|
||||
let mut matches: Vec<serde_json::Value> = Vec::new();
|
||||
let mut matched_files = 0u64;
|
||||
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())];
|
||||
|
||||
while let Some((current_tree, prefix)) = stack.pop() {
|
||||
for entry in current_tree.iter() {
|
||||
let name = match entry.name() {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
let entry_path = if prefix.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", prefix, name)
|
||||
};
|
||||
match entry.kind() {
|
||||
Some(git2::ObjectType::Tree) => {
|
||||
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 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();
|
||||
let mut file_hits: Vec<serde_json::Value> = Vec::new();
|
||||
let mut hit_lines = Vec::new();
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if line.to_lowercase().contains(&keyword_lower) {
|
||||
hit_lines.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if !hit_lines.is_empty() {
|
||||
matched_files += 1;
|
||||
// Merge overlapping context windows
|
||||
let mut windows: Vec<(usize, usize)> = Vec::new();
|
||||
for &line_idx in &hit_lines {
|
||||
let start = line_idx.saturating_sub(context_lines);
|
||||
let end = (line_idx + context_lines + 1).min(lines.len());
|
||||
if let Some(last) = windows.last_mut() {
|
||||
if start <= last.1 {
|
||||
last.1 = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
windows.push((start, end));
|
||||
}
|
||||
|
||||
for (start, end) in windows {
|
||||
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,
|
||||
"snippet": snippet.join("\n"),
|
||||
}));
|
||||
}
|
||||
|
||||
matches.push(serde_json::json!({
|
||||
"path": entry_path,
|
||||
"hit_count": hit_lines.len(),
|
||||
"snippets": file_hits,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"keyword": keyword,
|
||||
"matched_files": matched_files,
|
||||
"total_hits": matches.iter().map(|m| m["hit_count"].as_u64().unwrap_or(0)).sum::<u64>(),
|
||||
"matches": matches,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Registration ───────────────────────────────────────────────────────────────
|
||||
|
||||
macro_rules! param {
|
||||
($name:expr, $type:expr, $desc:expr, $required:expr) => {
|
||||
(
|
||||
$name.into(),
|
||||
ToolParam {
|
||||
name: $name.into(),
|
||||
param_type: $type.into(),
|
||||
description: Some($desc.into()),
|
||||
required: $required,
|
||||
properties: None,
|
||||
items: None,
|
||||
},
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
pub fn register_git_tools(registry: &mut ToolRegistry) {
|
||||
// repo_doc_index
|
||||
registry.register(
|
||||
ToolDefinition::new("repo_doc_index")
|
||||
.description("Index all documentation files in a knowledge-base repository. Lists every .md/.mdx file with its title, description, tags, and heading structure. Use this first to understand what documents are available.")
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(HashMap::from([
|
||||
param!("project_name", "string", "Project name (slug)", true),
|
||||
param!("repo_name", "string", "Repository name", true),
|
||||
])),
|
||||
required: Some(vec!["project_name".into(), "repo_name".into()]),
|
||||
}),
|
||||
ToolHandler::new(|ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
Box::pin(async move {
|
||||
repo_doc_index_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// repo_doc_read
|
||||
registry.register(
|
||||
ToolDefinition::new("repo_doc_read")
|
||||
.description("Read a specific document from a knowledge-base repository. Returns the full markdown content plus extracted frontmatter metadata and heading structure. Use this after repo_doc_index to read the documents you need.")
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(HashMap::from([
|
||||
param!("project_name", "string", "Project name (slug)", true),
|
||||
param!("repo_name", "string", "Repository name", true),
|
||||
param!("path", "string", "Document file path within the repository", true),
|
||||
])),
|
||||
required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]),
|
||||
}),
|
||||
ToolHandler::new(|ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
Box::pin(async move {
|
||||
repo_doc_read_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// repo_doc_search
|
||||
registry.register(
|
||||
ToolDefinition::new("repo_doc_search")
|
||||
.description("Search through all documentation files in a knowledge-base repository for a keyword. Returns matching file paths, hit counts, and context snippets. Use this to find which documents discuss a specific topic.")
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(HashMap::from([
|
||||
param!("project_name", "string", "Project name (slug)", true),
|
||||
param!("repo_name", "string", "Repository name", true),
|
||||
param!("keyword", "string", "Search keyword (case-insensitive)", true),
|
||||
param!("context_lines", "integer", "Number of context lines around each match (default: 2)", false),
|
||||
])),
|
||||
required: Some(vec!["project_name".into(), "repo_name".into(), "keyword".into()]),
|
||||
}),
|
||||
ToolHandler::new(|ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
Box::pin(async move {
|
||||
repo_doc_search_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,9 @@ pub mod branch;
|
||||
pub mod commit;
|
||||
pub mod ctx;
|
||||
pub mod diff;
|
||||
pub mod kb;
|
||||
pub mod repo_analysis;
|
||||
pub mod repo_util;
|
||||
pub mod tag;
|
||||
pub mod tree;
|
||||
pub mod types;
|
||||
@ -20,4 +23,7 @@ pub fn register_all(registry: &mut agent::ToolRegistry) {
|
||||
blob::register_git_tools(registry);
|
||||
tree::register_git_tools(registry);
|
||||
tag::register_git_tools(registry);
|
||||
repo_analysis::register_git_tools(registry);
|
||||
kb::register_git_tools(registry);
|
||||
repo_util::register_git_tools(registry);
|
||||
}
|
||||
|
||||
627
libs/fctool/src/git_tools/repo_analysis.rs
Normal file
627
libs/fctool/src/git_tools/repo_analysis.rs
Normal file
@ -0,0 +1,627 @@
|
||||
//! Repository analysis tools for AI.
|
||||
//!
|
||||
//! Provides function-calling tools that let AI quickly understand
|
||||
//! repository structure, languages, dependencies, and overview.
|
||||
|
||||
use super::ctx::GitToolCtx;
|
||||
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Recognised dependency manifest file names and their parser labels.
|
||||
const DEPENDENCY_MANIFESTS: &[(&str, &str)] = &[
|
||||
("Cargo.toml", "rust"),
|
||||
("package.json", "node"),
|
||||
("go.mod", "go"),
|
||||
("go.sum", "go"),
|
||||
("Gemfile", "ruby"),
|
||||
("requirements.txt", "python"),
|
||||
("Pipfile", "python"),
|
||||
("pyproject.toml", "python"),
|
||||
("pom.xml", "java"),
|
||||
("build.gradle", "java"),
|
||||
("build.gradle.kts", "java"),
|
||||
("composer.json", "php"),
|
||||
("CMakeLists.txt", "cmake"),
|
||||
("Makefile", "make"),
|
||||
];
|
||||
|
||||
/// Language detection by file extension (lowercase).
|
||||
fn ext_to_language(ext: &str) -> Option<&'static str> {
|
||||
match ext {
|
||||
"rs" => Some("Rust"),
|
||||
"go" => Some("Go"),
|
||||
"py" => Some("Python"),
|
||||
"js" => Some("JavaScript"),
|
||||
"jsx" => Some("JSX"),
|
||||
"ts" => Some("TypeScript"),
|
||||
"tsx" => Some("TSX"),
|
||||
"java" => Some("Java"),
|
||||
"kt" | "kts" => Some("Kotlin"),
|
||||
"rb" => Some("Ruby"),
|
||||
"php" => Some("PHP"),
|
||||
"c" => Some("C"),
|
||||
"h" => Some("C/C++ Header"),
|
||||
"cpp" | "cc" | "cxx" => Some("C++"),
|
||||
"hpp" | "hh" => Some("C++ Header"),
|
||||
"cs" => Some("C#"),
|
||||
"swift" => Some("Swift"),
|
||||
"scala" => Some("Scala"),
|
||||
"zig" => Some("Zig"),
|
||||
"sh" | "bash" | "zsh" => Some("Shell"),
|
||||
"ps1" => Some("PowerShell"),
|
||||
"sql" => Some("SQL"),
|
||||
"html" | "htm" => Some("HTML"),
|
||||
"css" | "scss" | "sass" | "less" => Some("CSS"),
|
||||
"json" => Some("JSON"),
|
||||
"yaml" | "yml" => Some("YAML"),
|
||||
"toml" => Some("TOML"),
|
||||
"md" => Some("Markdown"),
|
||||
"dockerfile" | "containerfile" => Some("Dockerfile"),
|
||||
"proto" => Some("Protobuf"),
|
||||
"vue" => Some("Vue"),
|
||||
"svelte" => Some("Svelte"),
|
||||
"lua" => Some("Lua"),
|
||||
"dart" => Some("Dart"),
|
||||
"r" | "R" => Some("R"),
|
||||
"clj" | "cljs" | "cljc" => Some("Clojure"),
|
||||
"ex" | "exs" => Some("Elixir"),
|
||||
"erl" => Some("Erlang"),
|
||||
"hs" => Some("Haskell"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Directories that should be ignored in file-tree scans.
|
||||
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"
|
||||
)
|
||||
}
|
||||
|
||||
/// Recursively collect file extensions and counts from a git tree.
|
||||
/// Skips ignored directories and binary-looking files.
|
||||
fn collect_languages(
|
||||
repo: &git2::Repository,
|
||||
tree: &git2::Tree,
|
||||
prefix: &str,
|
||||
stats: &mut HashMap<String, (String, u64)>,
|
||||
max_files: u64,
|
||||
) {
|
||||
let mut count = 0u64;
|
||||
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree.clone(), prefix.to_string())];
|
||||
while let Some((current_tree, path)) = stack.pop() {
|
||||
for entry in current_tree.iter() {
|
||||
if max_files > 0 && count >= max_files {
|
||||
return;
|
||||
}
|
||||
let name = match entry.name() {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
let entry_path = if path.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", path, name)
|
||||
};
|
||||
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())
|
||||
{
|
||||
stack.push((subtree, entry_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(git2::ObjectType::Blob) => {
|
||||
count += 1;
|
||||
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));
|
||||
entry.1 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect a recursive file tree (path + kind) up to a given depth and file limit.
|
||||
fn collect_file_tree(
|
||||
repo: &git2::Repository,
|
||||
tree: &git2::Tree,
|
||||
prefix: &str,
|
||||
depth: usize,
|
||||
max_depth: usize,
|
||||
max_files: u64,
|
||||
files: &mut Vec<serde_json::Value>,
|
||||
) {
|
||||
if depth > max_depth {
|
||||
return;
|
||||
}
|
||||
for entry in tree.iter() {
|
||||
if max_files > 0 && files.len() as u64 >= max_files {
|
||||
return;
|
||||
}
|
||||
let name = match entry.name() {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
let entry_path = if prefix.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", prefix, name)
|
||||
};
|
||||
match entry.kind() {
|
||||
Some(git2::ObjectType::Tree) => {
|
||||
if !is_ignored_dir(name) && !name.starts_with('.') {
|
||||
files.push(serde_json::json!({
|
||||
"path": entry_path,
|
||||
"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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(git2::ObjectType::Blob) => {
|
||||
files.push(serde_json::json!({
|
||||
"path": entry_path,
|
||||
"kind": "file"
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect config/manifest files in the root tree and return their names.
|
||||
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",
|
||||
];
|
||||
for entry in tree.iter() {
|
||||
let name = match entry.name() {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
if known_configs.contains(&name) || name.starts_with('.') && !name.starts_with(".git") {
|
||||
configs.push(name.to_string());
|
||||
}
|
||||
}
|
||||
configs.sort();
|
||||
configs.dedup();
|
||||
configs
|
||||
}
|
||||
|
||||
/// Parse a dependency manifest file content and return a structured summary.
|
||||
fn parse_dependencies(content: &str, manifest_name: &str) -> serde_json::Value {
|
||||
match manifest_name {
|
||||
"Cargo.toml" => {
|
||||
// Simple TOML-ish parsing for [dependencies] section
|
||||
let mut deps = Vec::new();
|
||||
let mut in_deps = false;
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("[dependencies]") {
|
||||
in_deps = true;
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with('[') {
|
||||
in_deps = false;
|
||||
continue;
|
||||
}
|
||||
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();
|
||||
if !name.is_empty() && !name.starts_with('#') {
|
||||
deps.push(serde_json::json!({ "name": name, "version": version }));
|
||||
}
|
||||
} else if !trimmed.is_empty() && !trimmed.starts_with('#') {
|
||||
// bare dependency name (path/git dep without explicit version)
|
||||
deps.push(serde_json::json!({ "name": trimmed, "version": null }));
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::json!({ "manifest": "Cargo.toml", "ecosystem": "rust", "dependencies": deps })
|
||||
}
|
||||
"package.json" => {
|
||||
let mut deps = Vec::new();
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) {
|
||||
for section in &["dependencies", "devDependencies", "peerDependencies"] {
|
||||
if let Some(map) = parsed.get(*section).and_then(|v| v.as_object()) {
|
||||
for (name, version) in map {
|
||||
deps.push(serde_json::json!({
|
||||
"name": name,
|
||||
"version": version.as_str().unwrap_or("*"),
|
||||
"scope": section
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::json!({ "manifest": "package.json", "ecosystem": "node", "dependencies": deps })
|
||||
}
|
||||
"go.mod" => {
|
||||
let mut deps = Vec::new();
|
||||
let mut in_require = false;
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("require (") || trimmed == "require (" {
|
||||
in_require = true;
|
||||
continue;
|
||||
}
|
||||
if trimmed == ")" {
|
||||
in_require = false;
|
||||
continue;
|
||||
}
|
||||
if in_require {
|
||||
let parts: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
deps.push(serde_json::json!({ "name": parts[0], "version": parts[1] }));
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::json!({ "manifest": "go.mod", "ecosystem": "go", "dependencies": deps })
|
||||
}
|
||||
"Gemfile" => {
|
||||
let mut deps = Vec::new();
|
||||
for line in content.lines() {
|
||||
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('\''));
|
||||
deps.push(serde_json::json!({ "name": name, "version": version }));
|
||||
}
|
||||
}
|
||||
serde_json::json!({ "manifest": "Gemfile", "ecosystem": "ruby", "dependencies": deps })
|
||||
}
|
||||
"requirements.txt" => {
|
||||
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 let Some(eq_eq) = trimmed.find("==") {
|
||||
let name = trimmed[..eq_eq].trim().to_string();
|
||||
let version = trimmed[eq_eq + 2..].trim().to_string();
|
||||
deps.push(serde_json::json!({ "name": name, "version": version }));
|
||||
} else {
|
||||
deps.push(serde_json::json!({ "name": trimmed, "version": null }));
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::json!({ "manifest": "requirements.txt", "ecosystem": "python", "dependencies": deps })
|
||||
}
|
||||
_ => serde_json::json!({ "manifest": manifest_name, "ecosystem": "unknown", "dependencies": [] }),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tool executors ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve HEAD to a tree for traversal.
|
||||
fn head_tree(domain: &git::GitDomain) -> Result<git2::Tree<'_>, String> {
|
||||
let repo = domain.repo();
|
||||
let head = repo.head().map_err(|e| format!("no HEAD: {e}"))?;
|
||||
head.peel_to_tree().map_err(|e| format!("no tree: {e}"))
|
||||
}
|
||||
|
||||
/// Resolve HEAD to a commit OID.
|
||||
fn head_oid(domain: &git::GitDomain) -> Result<String, String> {
|
||||
let repo = domain.repo();
|
||||
let head = repo.head().map_err(|e| format!("no HEAD: {e}"))?;
|
||||
head.target()
|
||||
.map(|o| o.to_string())
|
||||
.ok_or_else(|| "HEAD has no target".to_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")?;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let repo = domain.repo();
|
||||
let tree = head_tree(&domain)?;
|
||||
|
||||
// Default branch
|
||||
let default_branch = repo
|
||||
.head()
|
||||
.ok()
|
||||
.and_then(|h| h.shorthand().map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// Config files in root
|
||||
let config_files = detect_config_files(&tree);
|
||||
|
||||
// Language stats (up to 5000 files)
|
||||
let mut lang_stats: HashMap<String, (String, u64)> = HashMap::new();
|
||||
collect_languages(repo, &tree, "", &mut lang_stats, 5000);
|
||||
let mut languages: Vec<serde_json::Value> = lang_stats
|
||||
.into_iter()
|
||||
.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)
|
||||
.cmp(&a["file_count"].as_u64().unwrap_or(0))
|
||||
});
|
||||
|
||||
// Top-level file tree
|
||||
let mut root_files: Vec<serde_json::Value> = Vec::new();
|
||||
collect_file_tree(repo, &tree, "", 0, 1, 100, &mut root_files);
|
||||
|
||||
// Recent commits (last 10)
|
||||
let head_oid = head_oid(&domain)?;
|
||||
let recent_commits = domain
|
||||
.commit_log(Some(&head_oid), 0, 10)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let commits: Vec<serde_json::Value> = recent_commits
|
||||
.iter()
|
||||
.map(|c| {
|
||||
serde_json::json!({
|
||||
"oid": c.oid.to_string(),
|
||||
"summary": c.summary,
|
||||
"author": c.author.name,
|
||||
"time": c.author.time_secs,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Total commit count
|
||||
let total_commits = domain.commit_total(Some(&head_oid)).unwrap_or(0);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"default_branch": default_branch,
|
||||
"head_oid": head_oid,
|
||||
"total_commits": total_commits,
|
||||
"config_files": config_files,
|
||||
"languages": languages,
|
||||
"top_level_entries": root_files,
|
||||
"recent_commits": commits,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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")?;
|
||||
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);
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let repo = domain.repo();
|
||||
let tree = head_tree(&domain)?;
|
||||
|
||||
let mut files = Vec::new();
|
||||
collect_file_tree(repo, &tree, "", 0, max_depth, max_files, &mut files);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"total": files.len(),
|
||||
"entries": files
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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")?;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let repo = domain.repo();
|
||||
let tree = head_tree(&domain)?;
|
||||
|
||||
let mut lang_stats: HashMap<String, (String, u64)> = HashMap::new();
|
||||
collect_languages(repo, &tree, "", &mut lang_stats, 100_000);
|
||||
|
||||
let mut languages: Vec<serde_json::Value> = lang_stats
|
||||
.into_iter()
|
||||
.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)
|
||||
.cmp(&a["file_count"].as_u64().unwrap_or(0))
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"total_languages": languages.len(),
|
||||
"languages": languages
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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")?;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let tree = head_tree(&domain)?;
|
||||
|
||||
// Walk the tree looking for dependency manifests at any depth
|
||||
let mut manifests: Vec<serde_json::Value> = Vec::new();
|
||||
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())];
|
||||
let repo = domain.repo();
|
||||
|
||||
while let Some((current_tree, prefix)) = stack.pop() {
|
||||
for entry in current_tree.iter() {
|
||||
let name = match entry.name() {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
let entry_path = if prefix.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", prefix, name)
|
||||
};
|
||||
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()) {
|
||||
stack.push((subtree, entry_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(git2::ObjectType::Blob) => {
|
||||
if DEPENDENCY_MANIFESTS.iter().any(|(fname, _)| *fname == name) {
|
||||
if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) {
|
||||
let content = String::from_utf8_lossy(blob.content());
|
||||
let manifest_type = DEPENDENCY_MANIFESTS
|
||||
.iter()
|
||||
.find(|(fname, _)| *fname == name)
|
||||
.map(|(_, eco)| eco)
|
||||
.unwrap_or(&"unknown");
|
||||
|
||||
let parsed = parse_dependencies(&content, name);
|
||||
manifests.push(serde_json::json!({
|
||||
"path": entry_path,
|
||||
"ecosystem": manifest_type,
|
||||
"details": parsed
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"manifest_count": manifests.len(),
|
||||
"manifests": manifests
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Registration ───────────────────────────────────────────────────────────────
|
||||
|
||||
macro_rules! param {
|
||||
($name:expr, $type:expr, $desc:expr, $required:expr) => {
|
||||
(
|
||||
$name.into(),
|
||||
ToolParam {
|
||||
name: $name.into(),
|
||||
param_type: $type.into(),
|
||||
description: Some($desc.into()),
|
||||
required: $required,
|
||||
properties: None,
|
||||
items: None,
|
||||
},
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
pub fn register_git_tools(registry: &mut ToolRegistry) {
|
||||
// repo_overview
|
||||
registry.register(
|
||||
ToolDefinition::new("repo_overview")
|
||||
.description("Get a quick overview of a repository: default branch, detected config files, language breakdown by file count, top-level directory entries, and recent commits. Ideal for first contact with a repo.")
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(HashMap::from([
|
||||
param!("project_name", "string", "Project name (slug)", true),
|
||||
param!("repo_name", "string", "Repository name", true),
|
||||
])),
|
||||
required: Some(vec!["project_name".into(), "repo_name".into()]),
|
||||
}),
|
||||
ToolHandler::new(|ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
Box::pin(async move {
|
||||
repo_overview_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// repo_file_tree
|
||||
registry.register(
|
||||
ToolDefinition::new("repo_file_tree")
|
||||
.description("List files and directories in a repository recursively with configurable depth. Ignores common generated/artifact directories (node_modules, target, .git, etc.). Useful for understanding project layout.")
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(HashMap::from([
|
||||
param!("project_name", "string", "Project name (slug)", true),
|
||||
param!("repo_name", "string", "Repository name", true),
|
||||
param!("max_depth", "integer", "Maximum directory depth to traverse (default: 3)", false),
|
||||
param!("max_files", "integer", "Maximum number of entries to return (default: 200)", false),
|
||||
])),
|
||||
required: Some(vec!["project_name".into(), "repo_name".into()]),
|
||||
}),
|
||||
ToolHandler::new(|ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
Box::pin(async move {
|
||||
repo_file_tree_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// repo_languages
|
||||
registry.register(
|
||||
ToolDefinition::new("repo_languages")
|
||||
.description("Get a detailed breakdown of programming languages used in a repository, sorted by file count. Scans all files in the repo (up to 100K files) and maps extensions to language names.")
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(HashMap::from([
|
||||
param!("project_name", "string", "Project name (slug)", true),
|
||||
param!("repo_name", "string", "Repository name", true),
|
||||
])),
|
||||
required: Some(vec!["project_name".into(), "repo_name".into()]),
|
||||
}),
|
||||
ToolHandler::new(|ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
Box::pin(async move {
|
||||
repo_languages_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// repo_dependencies
|
||||
registry.register(
|
||||
ToolDefinition::new("repo_dependencies")
|
||||
.description("Discover and parse dependency manifests (Cargo.toml, package.json, go.mod, Gemfile, requirements.txt, etc.) in a repository. Returns structured dependency lists per manifest.")
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(HashMap::from([
|
||||
param!("project_name", "string", "Project name (slug)", true),
|
||||
param!("repo_name", "string", "Repository name", true),
|
||||
])),
|
||||
required: Some(vec!["project_name".into(), "repo_name".into()]),
|
||||
}),
|
||||
ToolHandler::new(|ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
Box::pin(async move {
|
||||
repo_dependencies_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
512
libs/fctool/src/git_tools/repo_util.rs
Normal file
512
libs/fctool/src/git_tools/repo_util.rs
Normal file
@ -0,0 +1,512 @@
|
||||
//! General-purpose repository utility tools for AI.
|
||||
//!
|
||||
//! Code search, README reading, commit history, contributors, and diff summaries.
|
||||
|
||||
use super::ctx::GitToolCtx;
|
||||
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn head_tree(domain: &git::GitDomain) -> Result<git2::Tree<'_>, String> {
|
||||
let repo = domain.repo();
|
||||
let head = repo.head().map_err(|e| format!("no HEAD: {e}"))?;
|
||||
head.peel_to_tree().map_err(|e| format!("no tree: {e}"))
|
||||
}
|
||||
|
||||
fn head_oid(domain: &git::GitDomain) -> Result<String, String> {
|
||||
let repo = domain.repo();
|
||||
let head = repo.head().map_err(|e| format!("no HEAD: {e}"))?;
|
||||
head.target()
|
||||
.map(|o| o.to_string())
|
||||
.ok_or_else(|| "HEAD has no target".to_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"
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a rev string to a commit OID.
|
||||
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")?;
|
||||
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;
|
||||
|
||||
let keyword_lower = keyword.to_lowercase();
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let repo = domain.repo();
|
||||
let tree = head_tree(&domain)?;
|
||||
|
||||
let mut matches: Vec<serde_json::Value> = Vec::new();
|
||||
let mut matched_files = 0usize;
|
||||
let mut total_hits = 0usize;
|
||||
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())];
|
||||
|
||||
'outer: while let Some((current_tree, prefix)) = stack.pop() {
|
||||
for entry in current_tree.iter() {
|
||||
if matches.len() >= max_results {
|
||||
break 'outer;
|
||||
}
|
||||
let name = match entry.name() {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
let entry_path = if prefix.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", prefix, name)
|
||||
};
|
||||
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()) {
|
||||
stack.push((subtree, entry_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(git2::ObjectType::Blob) => {
|
||||
if is_binary_ext(name) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) {
|
||||
if blob.is_binary() {
|
||||
continue;
|
||||
}
|
||||
let content = String::from_utf8_lossy(blob.content());
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let mut hit_lines = Vec::new();
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if line.to_lowercase().contains(&keyword_lower) {
|
||||
hit_lines.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if !hit_lines.is_empty() {
|
||||
matched_files += 1;
|
||||
total_hits += hit_lines.len();
|
||||
|
||||
// Merge overlapping context windows
|
||||
let mut windows: Vec<(usize, usize)> = Vec::new();
|
||||
for &line_idx in &hit_lines {
|
||||
let start = line_idx.saturating_sub(context_lines);
|
||||
let end = (line_idx + context_lines + 1).min(lines.len());
|
||||
if let Some(last) = windows.last_mut() {
|
||||
if start <= last.1 {
|
||||
last.1 = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
windows.push((start, end));
|
||||
}
|
||||
|
||||
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();
|
||||
serde_json::json!({
|
||||
"line_start": start + 1,
|
||||
"line_end": end,
|
||||
"snippet": snippet.join("\n"),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
matches.push(serde_json::json!({
|
||||
"path": entry_path,
|
||||
"hit_count": hit_lines.len(),
|
||||
"snippets": snippets,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"keyword": keyword,
|
||||
"matched_files": matched_files,
|
||||
"total_hits": total_hits,
|
||||
"results": matches,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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")?;
|
||||
|
||||
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 mut found = None;
|
||||
|
||||
for candidate in &candidates {
|
||||
if let Ok(entry) = tree.get_path(std::path::Path::new(candidate)) {
|
||||
if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) {
|
||||
let content = String::from_utf8_lossy(blob.content()).to_string();
|
||||
found = Some((candidate.to_string(), content));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match found {
|
||||
Some((filename, content)) => Ok(serde_json::json!({
|
||||
"filename": filename,
|
||||
"content": content,
|
||||
"size": content.len(),
|
||||
})),
|
||||
None => Ok(serde_json::json!({
|
||||
"filename": null,
|
||||
"content": null,
|
||||
"error": "No README file found in repository root",
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")?;
|
||||
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;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let head_oid = head_oid(&domain)?;
|
||||
|
||||
// Fetch extra to allow for filtering
|
||||
let fetch_limit = if author.is_some() || keyword.is_some() {
|
||||
limit.saturating_mul(5).max(200)
|
||||
} else {
|
||||
limit
|
||||
};
|
||||
|
||||
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());
|
||||
|
||||
let result: Vec<serde_json::Value> = commits
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
if let Some(ref al) = author_lower {
|
||||
if !c.author.name.to_lowercase().contains(al) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(ref kl) = keyword_lower {
|
||||
if !c.message.to_lowercase().contains(kl) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.take(limit)
|
||||
.map(|c| {
|
||||
let oid = c.oid.to_string();
|
||||
serde_json::json!({
|
||||
"oid": oid,
|
||||
"short_oid": oid.get(..7).unwrap_or(&oid),
|
||||
"summary": c.summary,
|
||||
"author": c.author.name,
|
||||
"author_email": c.author.email,
|
||||
"time": c.author.time_secs,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"total": result.len(),
|
||||
"commits": result,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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")?;
|
||||
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())?;
|
||||
|
||||
// Aggregate by author email (more reliable than name)
|
||||
let mut authors: HashMap<String, serde_json::Value> = HashMap::new();
|
||||
for c in &commits {
|
||||
let key = c.author.email.clone();
|
||||
let entry = authors.entry(key).or_insert_with(|| {
|
||||
serde_json::json!({
|
||||
"name": c.author.name,
|
||||
"email": c.author.email,
|
||||
"commit_count": 0u64,
|
||||
"first_commit_time": c.author.time_secs,
|
||||
"last_commit_time": c.author.time_secs,
|
||||
})
|
||||
});
|
||||
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);
|
||||
}
|
||||
if t > entry["last_commit_time"].as_i64().unwrap_or(0) {
|
||||
entry["last_commit_time"] = serde_json::json!(t);
|
||||
}
|
||||
}
|
||||
|
||||
let mut contributors: Vec<serde_json::Value> = authors.into_values().collect();
|
||||
contributors.sort_by(|a, b| {
|
||||
b["commit_count"].as_u64().unwrap_or(0)
|
||||
.cmp(&a["commit_count"].as_u64().unwrap_or(0))
|
||||
});
|
||||
|
||||
if limit > 0 {
|
||||
contributors.truncate(limit);
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"total_contributors": contributors.len(),
|
||||
"contributors": contributors,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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")?;
|
||||
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?;
|
||||
let repo = domain.repo();
|
||||
|
||||
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())?)
|
||||
.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())?)
|
||||
.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)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let stats = diff.stats().map_err(|e| e.to_string())?;
|
||||
let files_changed = stats.files_changed();
|
||||
let insertions = stats.insertions();
|
||||
let deletions = stats.deletions();
|
||||
|
||||
// Collect per-file stats
|
||||
let mut files: Vec<serde_json::Value> = Vec::new();
|
||||
for i in 0..diff.deltas().len() {
|
||||
let delta = diff.deltas().nth(i);
|
||||
if let Some(d) = delta {
|
||||
let old_path = d.old_file().path().map(|p| p.to_string_lossy().to_string());
|
||||
let new_path = d.new_file().path().map(|p| p.to_string_lossy().to_string());
|
||||
let status = match d.status() {
|
||||
git2::Delta::Added => "added",
|
||||
git2::Delta::Deleted => "deleted",
|
||||
git2::Delta::Modified => "modified",
|
||||
git2::Delta::Renamed => "renamed",
|
||||
git2::Delta::Copied => "copied",
|
||||
_ => "other",
|
||||
};
|
||||
files.push(serde_json::json!({
|
||||
"old_path": old_path,
|
||||
"new_path": new_path,
|
||||
"status": status,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"from": from_rev,
|
||||
"to": to_rev,
|
||||
"files_changed": files_changed,
|
||||
"insertions": insertions,
|
||||
"deletions": deletions,
|
||||
"files": files,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Registration ───────────────────────────────────────────────────────────────
|
||||
|
||||
macro_rules! param {
|
||||
($name:expr, $type:expr, $desc:expr, $required:expr) => {
|
||||
(
|
||||
$name.into(),
|
||||
ToolParam {
|
||||
name: $name.into(),
|
||||
param_type: $type.into(),
|
||||
description: Some($desc.into()),
|
||||
required: $required,
|
||||
properties: None,
|
||||
items: None,
|
||||
},
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
pub fn register_git_tools(registry: &mut ToolRegistry) {
|
||||
// repo_search
|
||||
registry.register(
|
||||
ToolDefinition::new("repo_search")
|
||||
.description("Search all files in a repository for a keyword (case-insensitive). Returns matching file paths, hit counts, and context snippets. Skips binary files and generated directories. Use this to find where a function, variable, or concept is defined or used.")
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(HashMap::from([
|
||||
param!("project_name", "string", "Project name (slug)", true),
|
||||
param!("repo_name", "string", "Repository name", true),
|
||||
param!("keyword", "string", "Search keyword (case-insensitive)", true),
|
||||
param!("context_lines", "integer", "Number of context lines around each match (default: 2)", false),
|
||||
param!("max_results", "integer", "Maximum number of matching files to return (default: 50)", false),
|
||||
])),
|
||||
required: Some(vec!["project_name".into(), "repo_name".into(), "keyword".into()]),
|
||||
}),
|
||||
ToolHandler::new(|ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
Box::pin(async move {
|
||||
repo_search_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// repo_readme
|
||||
registry.register(
|
||||
ToolDefinition::new("repo_readme")
|
||||
.description("Read the README file from a repository. Automatically finds README.md, README.markdown, README.rst, README.txt, or README. Returns the full content. Use this as the first step to understand what a project is about.")
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(HashMap::from([
|
||||
param!("project_name", "string", "Project name (slug)", true),
|
||||
param!("repo_name", "string", "Repository name", true),
|
||||
])),
|
||||
required: Some(vec!["project_name".into(), "repo_name".into()]),
|
||||
}),
|
||||
ToolHandler::new(|ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
Box::pin(async move {
|
||||
repo_readme_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// repo_commit_log
|
||||
registry.register(
|
||||
ToolDefinition::new("repo_commit_log")
|
||||
.description("Get commit history with optional filters. Filter by author name (partial match), keyword in commit message, or limit the number of results. Use this to understand recent activity, find who made specific changes, or trace feature development.")
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(HashMap::from([
|
||||
param!("project_name", "string", "Project name (slug)", true),
|
||||
param!("repo_name", "string", "Repository name", true),
|
||||
param!("author", "string", "Filter by author name (partial match, case-insensitive)", false),
|
||||
param!("keyword", "string", "Filter by keyword in commit message (case-insensitive)", false),
|
||||
param!("limit", "integer", "Maximum number of commits to return (default: 20)", false),
|
||||
])),
|
||||
required: Some(vec!["project_name".into(), "repo_name".into()]),
|
||||
}),
|
||||
ToolHandler::new(|ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
Box::pin(async move {
|
||||
repo_commit_log_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// repo_contributors
|
||||
registry.register(
|
||||
ToolDefinition::new("repo_contributors")
|
||||
.description("List repository contributors sorted by commit count. Shows each contributor's name, email, commit count, and first/last commit timestamps. Use this to understand who is involved in a project and their contribution levels.")
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(HashMap::from([
|
||||
param!("project_name", "string", "Project name (slug)", true),
|
||||
param!("repo_name", "string", "Repository name", true),
|
||||
param!("limit", "integer", "Maximum number of contributors to return (0 = all, default: 0)", false),
|
||||
])),
|
||||
required: Some(vec!["project_name".into(), "repo_name".into()]),
|
||||
}),
|
||||
ToolHandler::new(|ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
Box::pin(async move {
|
||||
repo_contributors_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// repo_diff_summary
|
||||
registry.register(
|
||||
ToolDefinition::new("repo_diff_summary")
|
||||
.description("Get a summary of changes between two revisions (commits, branches, or tags). Shows files changed, insertions, deletions, and per-file status (added/modified/deleted/renamed). Use this to understand what changed between versions.")
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(HashMap::from([
|
||||
param!("project_name", "string", "Project name (slug)", true),
|
||||
param!("repo_name", "string", "Repository name", true),
|
||||
param!("from_rev", "string", "Source revision (commit SHA, branch name, or tag)", true),
|
||||
param!("to_rev", "string", "Target revision (default: HEAD)", false),
|
||||
])),
|
||||
required: Some(vec!["project_name".into(), "repo_name".into(), "from_rev".into()]),
|
||||
}),
|
||||
ToolHandler::new(|ctx, args| {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
Box::pin(async move {
|
||||
repo_diff_summary_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
@ -132,6 +132,65 @@ fn extract_frontmatter(raw: &str) -> (Option<&str>, &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan git tree objects for `SKILL.md` files (works for bare repos).
|
||||
fn scan_skills_from_tree(
|
||||
git_repo: &git2::Repository,
|
||||
repo_id: &RepoId,
|
||||
commit_sha: &str,
|
||||
) -> Result<Vec<DiscoveredSkill>, String> {
|
||||
let repo_id_prefix = &repo_id.to_string()[..8];
|
||||
let head = git_repo.head().map_err(|e| format!("no HEAD: {e}"))?;
|
||||
let tree = head.peel_to_tree().map_err(|e| format!("no tree: {e}"))?;
|
||||
|
||||
let mut discovered = Vec::new();
|
||||
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())];
|
||||
|
||||
while let Some((current_tree, prefix)) = stack.pop() {
|
||||
for entry in current_tree.iter() {
|
||||
let name = match entry.name() {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
let entry_path = if prefix.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", prefix, name)
|
||||
};
|
||||
|
||||
match entry.kind() {
|
||||
Some(git2::ObjectType::Tree) => {
|
||||
if !name.starts_with('.') {
|
||||
if let Ok(subtree) = entry.to_object(git_repo).and_then(|o| o.peel_to_tree()) {
|
||||
stack.push((subtree, entry_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(git2::ObjectType::Blob) if name.to_lowercase() == "skill.md" => {
|
||||
let dir_name = std::path::Path::new(&entry_path)
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
.filter(|s| !s.starts_with('.'));
|
||||
let Some(dir_name) = dir_name else { continue };
|
||||
|
||||
let slug = format!("{}/{}", repo_id_prefix, dir_name);
|
||||
if let Ok(blob) = entry.to_object(git_repo).and_then(|o| o.peel_to_blob()) {
|
||||
let raw = blob.content();
|
||||
let blob_hash = git_blob_hash(raw);
|
||||
let mut skill = parse_skill_content(&slug, raw);
|
||||
skill.commit_sha = Some(commit_sha.to_string());
|
||||
skill.blob_hash = Some(blob_hash);
|
||||
discovered.push(skill);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(discovered)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HookMetaDataSync {
|
||||
pub db: AppDatabase,
|
||||
@ -275,15 +334,9 @@ impl HookMetaDataSync {
|
||||
/// Best-effort — failures are logged but do not fail the sync.
|
||||
pub async fn sync_skills(&self) {
|
||||
let project_uid = self.repo.project;
|
||||
let git_repo = self.domain.repo();
|
||||
|
||||
let repo_root = match self.domain.repo().workdir() {
|
||||
Some(p) => p.to_path_buf(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
let commit_sha = self
|
||||
.domain
|
||||
.repo()
|
||||
let commit_sha = git_repo
|
||||
.head()
|
||||
.ok()
|
||||
.and_then(|h| h.target())
|
||||
@ -291,19 +344,35 @@ impl HookMetaDataSync {
|
||||
.unwrap_or_default();
|
||||
|
||||
let repo_id = self.repo.id;
|
||||
let discovered = match tokio::task::spawn_blocking(move || {
|
||||
scan_skills_from_dir(&repo_root, &repo_id, &commit_sha)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(d)) => d,
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("failed to scan skills directory error={}", e);
|
||||
return;
|
||||
let is_bare = git_repo.is_bare() || git_repo.workdir().is_none();
|
||||
|
||||
let discovered = if is_bare {
|
||||
// Bare repo: scan git tree objects directly
|
||||
let git_repo_ref = self.domain.repo();
|
||||
match scan_skills_from_tree(git_repo_ref, &repo_id, &commit_sha) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to scan skills from tree error={}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("spawn_blocking join error error={}", e);
|
||||
return;
|
||||
} else {
|
||||
// Normal repo: walk filesystem
|
||||
let repo_root = git_repo.workdir().unwrap().to_path_buf();
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
scan_skills_from_dir(&repo_root, &repo_id, &commit_sha)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(d)) => d,
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("failed to scan skills directory error={}", e);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("spawn_blocking join error error={}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -90,7 +90,7 @@ fn extract_frontmatter(raw: &str) -> (Option<&str>, &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively scan `repo_path` for `SKILL.md` files.
|
||||
/// Recursively scan `repo_path` for `SKILL.md` files (filesystem walk, non-bare repos).
|
||||
/// The skill slug is `{short_repo_id}/{parent_dir_name}` to ensure uniqueness across repos.
|
||||
pub fn scan_repo_for_skills(
|
||||
repo_path: &Path,
|
||||
@ -135,8 +135,72 @@ pub fn scan_repo_for_skills(
|
||||
Ok(discovered)
|
||||
}
|
||||
|
||||
/// Scan git tree objects for `SKILL.md` files (works for bare repos).
|
||||
/// Traverses the HEAD commit tree using libgit2, reading blob content from objects.
|
||||
pub fn scan_repo_tree_for_skills(
|
||||
git_repo: &Repository,
|
||||
repo_id: Uuid,
|
||||
) -> Result<Vec<DiscoveredSkill>, AppError> {
|
||||
let repo_id_prefix = &repo_id.to_string()[..8];
|
||||
let head = git_repo
|
||||
.head()
|
||||
.map_err(|e| AppError::InternalServerError(format!("no HEAD: {e}")))?;
|
||||
let tree = head
|
||||
.peel_to_tree()
|
||||
.map_err(|e| AppError::InternalServerError(format!("no tree: {e}")))?;
|
||||
|
||||
let mut discovered = Vec::new();
|
||||
// Stack: (tree, path_prefix relative to root)
|
||||
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())];
|
||||
|
||||
while let Some((current_tree, prefix)) = stack.pop() {
|
||||
for entry in current_tree.iter() {
|
||||
let name = match entry.name() {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
let entry_path = if prefix.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", prefix, name)
|
||||
};
|
||||
|
||||
match entry.kind() {
|
||||
Some(git2::ObjectType::Tree) => {
|
||||
if !name.starts_with('.') {
|
||||
if let Ok(subtree) = entry.to_object(git_repo).and_then(|o| o.peel_to_tree()) {
|
||||
stack.push((subtree, entry_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(git2::ObjectType::Blob) if name.to_lowercase() == "skill.md" => {
|
||||
// Derive skill name from parent directory
|
||||
let dir_name = std::path::Path::new(&entry_path)
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
.filter(|s| !s.starts_with('.'));
|
||||
let Some(dir_name) = dir_name else { continue };
|
||||
|
||||
let slug = format!("{}/{}", repo_id_prefix, dir_name);
|
||||
if let Ok(blob) = entry.to_object(git_repo).and_then(|o| o.peel_to_blob()) {
|
||||
let raw = blob.content();
|
||||
let blob_hash = git_blob_hash(raw);
|
||||
let mut skill = parse_skill_file(&slug, &String::from_utf8_lossy(raw));
|
||||
skill.blob_hash = Some(blob_hash);
|
||||
discovered.push(skill);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(discovered)
|
||||
}
|
||||
|
||||
/// Scan a git2::Repository for skills and upsert them into the database.
|
||||
/// Called from the git hook sync path.
|
||||
/// Uses filesystem walk for normal repos, git tree traversal for bare repos.
|
||||
pub async fn scan_and_sync_skills(
|
||||
db: &db::database::AppDatabase,
|
||||
project_uuid: Uuid,
|
||||
@ -156,10 +220,21 @@ pub async fn scan_and_sync_skills(
|
||||
}
|
||||
};
|
||||
|
||||
let workdir = git_repo.workdir().map(|p| p.to_path_buf()).unwrap_or_else(|| Path::new(&repo.storage_path).to_path_buf());
|
||||
let commit_sha = git_repo.head().ok().and_then(|h| h.target()).map(|oid| oid.to_string());
|
||||
|
||||
let mut discovered = scan_repo_for_skills(&workdir, repo.id)?;
|
||||
// For bare repos (no workdir), scan git tree objects directly
|
||||
let mut discovered = if git_repo.is_bare() || git_repo.workdir().is_none() {
|
||||
match scan_repo_tree_for_skills(&git_repo, repo.id) {
|
||||
Ok(skills) => skills,
|
||||
Err(e) => {
|
||||
tracing::warn!("tree scan failed for repo {}: {:?}", repo.storage_path, e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let workdir = git_repo.workdir().unwrap();
|
||||
scan_repo_for_skills(workdir, repo.id)?
|
||||
};
|
||||
|
||||
// Fill in commit_sha for discovered skills
|
||||
for skill in &mut discovered {
|
||||
|
||||
@ -498,7 +498,7 @@ export function RoomProvider({
|
||||
),
|
||||
);
|
||||
streamingTimersRef.current.delete(msgId);
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
streamingTimersRef.current.set(msgId, timer);
|
||||
}, []);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user