gitdataai/libs/service/project_tools/repos.rs

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()]),
})
}