560 lines
20 KiB
Rust
560 lines
20 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 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();
|
|
|
|
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()))?;
|
|
|
|
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,
|
|
"storage_path": model.storage_path,
|
|
"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();
|
|
|
|
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)
|
|
let parent_oid = repo.refname_to_id(&format!("refs/heads/{}", branch)).ok();
|
|
let parent_ids: Vec<CommitOid> = parent_oid
|
|
.map(|oid| CommitOid::from_git2(oid))
|
|
.into_iter()
|
|
.collect();
|
|
|
|
// Build index with new files
|
|
let mut index = repo
|
|
.index()
|
|
.map_err(|e| ToolError::ExecutionError(format!("Failed to get 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()))?;
|
|
let content = file
|
|
.get("content")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| ToolError::ExecutionError("Each file must have 'content'".into()))?;
|
|
|
|
let _oid = repo.blob(content.as_bytes()).map_err(|e| {
|
|
ToolError::ExecutionError(format!("Failed to write blob for '{}': {}", path, e))
|
|
})?;
|
|
|
|
index.add_path(path.as_ref()).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()]),
|
|
})
|
|
}
|