gitdataai/libs/service/project_tools/repos.rs
ZhenYi f7e087e066 fix(agent/service): retry jitter, tool executor ordering, curl SSRF, grep/JSON
- agent/client: full jitter backoff (random(0, base_ms)) instead of equal jitter
- agent/tool/executor: fix buffer_unordered ordering mismatch with
  HashMap-by-index approach for concurrent tool execution
- agent/chat: AiChunkType emit fixes, is_retryable_tool_error refinements,
  process_react uses request.max_tool_depth
- agent/chat/context: fix Function message sender_name field
- file_tools/curl: shared reqwest::Client via OnceLock, manual redirect
  following with per-hop SSRF validation, blocked sensitive headers
- file_tools/grep: fix case-insensitive glob matching, segment consumption
- file_tools/json: bracket notation support, remove .vscodeignore from JSONC
- git_tools: git_diff_stats resolve base/head independently,
  DiffFileOut old_file.path for Deleted, reflog offset_minutes
- git/repo: create_commit read parent tree into index, bare repo init
- project_tools/repos: branch/path validation, .git/ prefix check
- service/agent: tokent integration, billing, pr_summary, code_review fixes
2026-04-25 09:53:31 +08:00

624 lines
23 KiB
Rust

//! Tool: project_list_repos, project_create_repo, project_create_commit
use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
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::repos::repo;
use models::users::user_email;
use sea_orm::*;
use std::collections::HashMap;
use std::path::PathBuf;
use uuid::Uuid;
// ─── list ─────────────────────────────────────────────────────────────────────
pub async fn list_repos_exec(
ctx: ToolContext,
_args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let db = ctx.db();
let repos = repo::Entity::find()
.filter(repo::Column::Project.eq(project_id))
.order_by_asc(repo::Column::RepoName)
.all(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let result: Vec<_> = repos
.into_iter()
.map(|r| {
serde_json::json!({
"id": r.id.to_string(),
"name": r.repo_name,
"description": r.description,
"default_branch": r.default_branch,
"is_private": r.is_private,
"created_at": r.created_at.to_rfc3339(),
})
})
.collect();
Ok(serde_json::to_value(result).map_err(|e| ToolError::ExecutionError(e.to_string()))?)
}
// ─── create ───────────────────────────────────────────────────────────────────
pub async fn create_repo_exec(
ctx: ToolContext,
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 context".into()))?;
let db = ctx.db();
// Admin/owner check
let member = ProjectMember::find()
.filter(project_members::Column::Project.eq(project_id))
.filter(project_members::Column::User.eq(sender_id))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let member = member
.ok_or_else(|| ToolError::ExecutionError("You are not a member of this project".into()))?;
let role = member
.scope_role()
.map_err(|_| ToolError::ExecutionError("Unknown member role".into()))?;
match role {
MemberRole::Admin | MemberRole::Owner => {}
MemberRole::Member => {
return Err(ToolError::ExecutionError(
"Only admin or owner can create repositories".into(),
));
}
}
let repo_name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("name is required".into()))?
.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())
{
return Err(ToolError::ExecutionError(
"Invalid repository name: must start with alphanumeric, contain no path separators or '..', max 100 chars".into(),
));
}
let description = args
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let is_private = args
.get("is_private")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Check name uniqueness within project
let existing = repo::Entity::find()
.filter(repo::Column::Project.eq(project_id))
.filter(repo::Column::RepoName.eq(&repo_name))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
if existing.is_some() {
return Err(ToolError::ExecutionError(format!(
"Repository '{}' already exists in this project",
repo_name
)));
}
// Look up project name for storage_path
let project = models::projects::project::Entity::find_by_id(project_id)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Project not found".into()))?;
let repos_root = ctx
.config()
.repos_root()
.map_err(|e| ToolError::ExecutionError(format!("repos_root config error: {}", e)))?;
let repo_dir: PathBuf = [&repos_root, &project.name, &format!("{}.git", repo_name)]
.iter()
.collect();
let now = Utc::now();
let active = repo::ActiveModel {
id: Set(Uuid::now_v7()),
repo_name: Set(repo_name.clone()),
project: Set(project_id),
description: Set(description),
default_branch: Set("main".to_string()),
is_private: Set(is_private),
storage_path: Set(repo_dir.to_string_lossy().to_string()),
created_by: Set(sender_id),
created_at: Set(now),
updated_at: Set(now),
ai_code_review_enabled: Set(false),
};
let model = active
.insert(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
// Initialize the bare git repository on disk
git2::Repository::init_bare(&repo_dir)
.map_err(|e| ToolError::ExecutionError(format!("Failed to init bare repo: {}", e)))?;
Ok(serde_json::json!({
"id": model.id.to_string(),
"name": model.repo_name,
"description": model.description,
"default_branch": model.default_branch,
"is_private": model.is_private,
"created_at": model.created_at.to_rfc3339(),
}))
}
// ─── update ───────────────────────────────────────────────────────────────────
pub async fn update_repo_exec(
ctx: ToolContext,
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 context".into()))?;
let db = ctx.db();
// Admin/owner check
let member = ProjectMember::find()
.filter(project_members::Column::Project.eq(project_id))
.filter(project_members::Column::User.eq(sender_id))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let member = member
.ok_or_else(|| ToolError::ExecutionError("You are not a member of this project".into()))?;
let role = member
.scope_role()
.map_err(|_| ToolError::ExecutionError("Unknown member role".into()))?;
match role {
MemberRole::Admin | MemberRole::Owner => {}
MemberRole::Member => {
return Err(ToolError::ExecutionError(
"Only admin or owner can update repositories".into(),
));
}
}
let repo_name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("name is required".into()))?
.to_string();
let repo = repo::Entity::find()
.filter(repo::Column::Project.eq(project_id))
.filter(repo::Column::RepoName.eq(&repo_name))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| {
ToolError::ExecutionError(format!("Repository '{}' not found", repo_name))
})?;
let mut active: repo::ActiveModel = repo.clone().into();
let mut updated = false;
if let Some(description) = args.get("description") {
active.description = Set(description.as_str().map(|s| s.to_string()));
updated = true;
}
if let Some(is_private) = args.get("is_private").and_then(|v| v.as_bool()) {
active.is_private = Set(is_private);
updated = true;
}
if let Some(default_branch) = args.get("default_branch").and_then(|v| v.as_str()) {
active.default_branch = Set(default_branch.to_string());
updated = true;
}
if !updated {
return Err(ToolError::ExecutionError(
"At least one field must be provided".into(),
));
}
active.updated_at = Set(Utc::now());
let model = active
.update(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
Ok(serde_json::json!({
"id": model.id.to_string(),
"name": model.repo_name,
"description": model.description,
"default_branch": model.default_branch,
"is_private": model.is_private,
"created_at": model.created_at.to_rfc3339(),
"updated_at": model.updated_at.to_rfc3339(),
}))
}
// ─── create commit ────────────────────────────────────────────────────────────
pub async fn create_commit_exec(
ctx: ToolContext,
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 context".into()))?;
let db = ctx.db();
// Admin/owner check
let member = ProjectMember::find()
.filter(project_members::Column::Project.eq(project_id))
.filter(project_members::Column::User.eq(sender_id))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let member = member
.ok_or_else(|| ToolError::ExecutionError("You are not a member of this project".into()))?;
let role = member
.scope_role()
.map_err(|_| ToolError::ExecutionError("Unknown member role".into()))?;
match role {
MemberRole::Admin | MemberRole::Owner => {}
MemberRole::Member => {
return Err(ToolError::ExecutionError(
"Only admin or owner can create commits".into(),
));
}
}
let repo_name = args
.get("repo_name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("repo_name is required".into()))?;
let message = args
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("message is required".into()))?
.to_string();
let branch = args
.get("branch")
.and_then(|v| v.as_str())
.unwrap_or("main")
.to_string();
// Validate branch: no path traversal, no slashes
if branch.contains("..") || branch.contains('/') || branch.contains('\\') || branch.is_empty() {
return Err(ToolError::ExecutionError(
"Invalid branch name: must not contain path separators or '..'".into(),
));
}
let files = args
.get("files")
.and_then(|v| v.as_array())
.ok_or_else(|| {
ToolError::ExecutionError("files is required and must be an array".into())
})?;
if files.is_empty() {
return Err(ToolError::ExecutionError(
"files array cannot be empty".into(),
));
}
// Clone files data for spawn_blocking
let files_data: Vec<serde_json::Value> = files.iter().cloned().collect();
// Look up sender username and email
let sender = models::users::user::Entity::find_by_id(sender_id)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Sender user not found".into()))?;
let sender_email = user_email::Entity::find_by_id(sender_id)
.one(db)
.await
.ok()
.flatten()
.map(|e| e.email)
.unwrap_or_else(|| format!("{}@gitdata.ai", sender.username));
let author_name = sender
.display_name
.unwrap_or_else(|| sender.username.clone());
// Find repo
let repo_model = repo::Entity::find()
.filter(repo::Column::Project.eq(project_id))
.filter(repo::Column::RepoName.eq(repo_name))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| {
ToolError::ExecutionError(format!("Repository '{}' not found", repo_name))
})?;
let storage_path = repo_model.storage_path.clone();
// Run git operations in a blocking thread
let result = tokio::task::spawn_blocking(move || {
let domain = git::GitDomain::open(&storage_path)
.map_err(|e| ToolError::ExecutionError(format!("Failed to open repo: {}", e)))?;
let repo = domain.repo();
// Get current head commit (parent)
// If the repo already has commits (has HEAD), the branch must exist.
// Only allow root commits on truly empty repos (no HEAD at all).
let has_head = repo.head().is_ok();
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),
));
}
let parent_ids: Vec<CommitOid> = parent_oid
.map(|oid| CommitOid::from_git2(oid))
.into_iter()
.collect();
// Build index from existing tree first (preserves all previous files),
// then add/overwrite with the new files.
let mut index = repo
.index()
.map_err(|e| ToolError::ExecutionError(format!("Failed to get index: {}", e)))?;
// 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)))?;
}
for file in files_data {
let path = file
.get("path")
.and_then(|v| v.as_str())
.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"
{
return Err(ToolError::ExecutionError(
format!("Invalid file path '{}': must be relative, no '..' or absolute path components", path)
));
}
let content = file
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("Each file must have 'content'".into()))?;
// add_frombuffer requires an IndexEntry with at minimum a path field set.
// It works for both bare and non-bare repos (add_path requires a working tree).
let mut entry = git2::IndexEntry {
ctime: git2::IndexTime::new(0, 0),
mtime: git2::IndexTime::new(0, 0),
dev: 0,
ino: 0,
mode: 0o100644,
uid: 0,
gid: 0,
file_size: 0,
id: git2::Oid::zero(),
flags: 0,
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))
})?;
}
let tree_oid = index
.write_tree()
.map_err(|e| ToolError::ExecutionError(format!("Failed to write tree: {}", e)))?;
let tree_id = CommitOid::from_git2(tree_oid);
// Author signature
let author = CommitSignature {
name: author_name.clone(),
email: sender_email.clone(),
time_secs: chrono::Utc::now().timestamp(),
offset_minutes: 0,
};
// Committer signature: gitpanda <info@gitdata.ai>
let committer = CommitSignature {
name: "gitpanda".to_string(),
email: "info@gitdata.ai".to_string(),
time_secs: chrono::Utc::now().timestamp(),
offset_minutes: 0,
};
let commit_oid = domain
.commit_create(
Some(&format!("refs/heads/{}", branch)),
&author,
&committer,
&message,
&tree_id,
&parent_ids,
)
.map_err(|e| ToolError::ExecutionError(format!("Failed to create commit: {}", e)))?;
Ok::<_, ToolError>(serde_json::json!({
"commit_oid": commit_oid.to_string(),
"branch": branch,
"message": message,
"author_name": author_name,
"author_email": sender_email,
}))
})
.await
.map_err(|e| ToolError::ExecutionError(format!("Task join error: {}", e)))?;
result
}
// ─── tool definitions ─────────────────────────────────────────────────────────
pub fn list_tool_definition() -> ToolDefinition {
ToolDefinition::new("project_list_repos")
.description(
"List all repositories in the current project. \
Returns repo name, description, default branch, privacy status, and creation time.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: None,
required: None,
})
}
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,
});
ToolDefinition::new("project_create_repo")
.description(
"Create a new repository in the current project. \
Requires admin or owner role. \
The repo is initialized with a bare git structure.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["name".into()]),
})
}
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,
});
ToolDefinition::new("project_update_repo")
.description(
"Update a repository's description, privacy, or default branch. \
Requires admin or owner role.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["name".into()]),
})
}
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 (required).".into()),
required: true, 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,
})),
});
ToolDefinition::new("project_create_commit")
.description(
"Create a new commit in a repository. Commits the given files to the specified branch. \
Requires admin or owner role. \
Committer is always 'gitpanda <info@gitdata.ai>'. \
Author is the sender's display name and email.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["repo_name".into(), "message".into(), "files".into()]),
})
}