//! 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 { 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 { 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 { 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 { 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 = 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 = 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 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 '. \ 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()]), }) }