- 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
624 lines
23 KiB
Rust
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()]),
|
|
})
|
|
}
|