//! Tools: project_list_issues, project_create_issue, project_update_issue use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema}; use chrono::Utc; use models::issues::{issue, issue_assignee, issue_label, Issue, IssueAssignee, IssueLabel, IssueState}; use models::projects::{MemberRole, ProjectMember}; use models::projects::project_members; use models::system::{Label, label}; use models::users::User; use sea_orm::*; use std::collections::HashMap; use uuid::Uuid; // ─── list ───────────────────────────────────────────────────────────────────── pub async fn list_issues_exec( ctx: ToolContext, args: serde_json::Value, ) -> Result { let project_id = ctx.project_id(); let db = ctx.db(); let state_filter = args .get("state") .and_then(|v| v.as_str()) .map(|s| s.to_lowercase()); let mut query = issue::Entity::find().filter(issue::Column::Project.eq(project_id)); if let Some(ref state) = state_filter { query = query.filter(issue::Column::State.eq(state)); } let issues = query .order_by_desc(issue::Column::CreatedAt) .all(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; let issue_ids: Vec<_> = issues.iter().map(|i| i.id).collect(); let assignees = IssueAssignee::find() .filter(issue_assignee::Column::Issue.is_in(issue_ids.clone())) .all(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; let assignee_user_ids: Vec<_> = assignees.iter().map(|a| a.user).collect(); let assignee_users = User::find() .filter(models::users::user::Column::Uid.is_in(assignee_user_ids)) .all(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; let user_map: std::collections::HashMap<_, _> = assignee_users.into_iter().map(|u| (u.uid, u)).collect(); let issue_labels = IssueLabel::find() .filter(issue_label::Column::Issue.is_in(issue_ids.clone())) .all(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; let label_ids: Vec<_> = issue_labels.iter().map(|l| l.label).collect(); let labels = Label::find() .filter(label::Column::Id.is_in(label_ids)) .all(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; let label_map: std::collections::HashMap<_, _> = labels.into_iter().map(|l| (l.id, l)).collect(); let assignee_map: std::collections::HashMap<_, Vec<_>> = assignees .into_iter() .filter_map(|a| { let user = user_map.get(&a.user)?; Some(( a.issue, serde_json::json!({ "id": a.user.to_string(), "username": user.username, "display_name": user.display_name, }), )) }) .fold( std::collections::HashMap::new(), |mut acc, (issue_id, user)| { acc.entry(issue_id).or_default().push(user); acc }, ); let issue_label_map: std::collections::HashMap<_, Vec<_>> = issue_labels .into_iter() .filter_map(|il| { let label = label_map.get(&il.label)?; Some(( il.issue, serde_json::json!({ "id": il.label, "name": label.name, "color": label.color, }), )) }) .fold( std::collections::HashMap::new(), |mut acc, (issue_id, label)| { acc.entry(issue_id).or_default().push(label); acc }, ); let result: Vec<_> = issues .into_iter() .map(|i| { serde_json::json!({ "id": i.id.to_string(), "number": i.number, "title": i.title, "body": i.body, "state": i.state, "author_id": i.author.to_string(), "milestone": i.milestone, "created_at": i.created_at.to_rfc3339(), "updated_at": i.updated_at.to_rfc3339(), "closed_at": i.closed_at.map(|t| t.to_rfc3339()), "assignees": assignee_map.get(&i.id).unwrap_or(&vec![]), "labels": issue_label_map.get(&i.id).unwrap_or(&vec![]), }) }) .collect(); Ok(serde_json::to_value(result).map_err(|e| ToolError::ExecutionError(e.to_string()))?) } // ─── helpers ─────────────────────────────────────────────────────────────────── /// Check if the user is the issue author OR an admin/owner of the project. async fn require_issue_modifier( db: &impl ConnectionTrait, project_id: Uuid, sender_id: Uuid, author_id: Uuid, ) -> Result<(), ToolError> { // Author can always modify their own issue if sender_id == author_id { return Ok(()); } // Otherwise require admin or owner 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 => Ok(()), MemberRole::Member => Err(ToolError::ExecutionError( "Only the issue author or admin/owner can modify this issue".into(), )), } } // ─── create ─────────────────────────────────────────────────────────────────── async fn next_issue_number(db: &impl ConnectionTrait, project_id: Uuid) -> Result { let max_num: Option> = Issue::find() .filter(issue::Column::Project.eq(project_id)) .select_only() .column_as(issue::Column::Number.max(), "max_num") .into_tuple::>() .one(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; Ok(max_num.flatten().unwrap_or(0) + 1) } pub async fn create_issue_exec( ctx: ToolContext, args: serde_json::Value, ) -> Result { let project_id = ctx.project_id(); let db = ctx.db(); let title = args .get("title") .and_then(|v| v.as_str()) .ok_or_else(|| ToolError::ExecutionError("title is required".into()))? .to_string(); let body = args .get("body") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let milestone = args .get("milestone") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let assignee_ids: Vec = args .get("assignee_ids") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| Uuid::parse_str(v.as_str()?).ok()) .collect() }) .unwrap_or_default(); let label_ids: Vec = args .get("label_ids") .and_then(|v| v.as_array()) .map(|arr| arr.iter().filter_map(|v| v.as_i64()).collect()) .unwrap_or_default(); let author_id = ctx .sender_id() .ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?; let number = next_issue_number(db, project_id).await?; let now = Utc::now(); let active = issue::ActiveModel { id: Set(Uuid::now_v7()), project: Set(project_id), number: Set(number), title: Set(title.clone()), body: Set(body), state: Set(IssueState::Open.to_string()), author: Set(author_id), milestone: Set(milestone), created_at: Set(now), updated_at: Set(now), closed_at: Set(None), created_by_ai: Set(true), ..Default::default() }; let model = active .insert(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; // Add assignees for uid in &assignee_ids { let a = issue_assignee::ActiveModel { issue: Set(model.id), user: Set(*uid), assigned_at: Set(now), ..Default::default() }; let _ = a.insert(db).await; } // Add labels for lid in &label_ids { let l = issue_label::ActiveModel { issue: Set(model.id), label: Set(*lid), relation_at: Set(now), ..Default::default() }; let _ = l.insert(db).await; } // Build assignee/label maps for response let assignee_map: std::collections::HashMap = if !assignee_ids.is_empty() { let users = User::find() .filter(models::users::user::Column::Uid.is_in(assignee_ids.clone())) .all(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; users .into_iter() .map(|u| { ( u.uid, serde_json::json!({ "id": u.uid.to_string(), "username": u.username, "display_name": u.display_name, }), ) }) .collect() } else { std::collections::HashMap::new() }; let label_map: std::collections::HashMap = if !label_ids.is_empty() { let labels = Label::find() .filter(label::Column::Id.is_in(label_ids.clone())) .all(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; labels .into_iter() .map(|l| { ( l.id, serde_json::json!({ "id": l.id, "name": l.name, "color": l.color, }), ) }) .collect() } else { std::collections::HashMap::new() }; Ok(serde_json::json!({ "id": model.id.to_string(), "number": model.number, "title": model.title, "body": model.body, "state": model.state, "author_id": model.author.to_string(), "milestone": model.milestone, "created_at": model.created_at.to_rfc3339(), "updated_at": model.updated_at.to_rfc3339(), "assignees": assignee_ids.iter().filter_map(|uid| assignee_map.get(uid)).collect::>(), "labels": label_ids.iter().filter_map(|lid| label_map.get(lid)).collect::>(), })) } // ─── update ─────────────────────────────────────────────────────────────────── pub async fn update_issue_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(); let number = args .get("number") .and_then(|v| v.as_i64()) .ok_or_else(|| ToolError::ExecutionError("number is required".into()))?; // Find the issue let issue = Issue::find() .filter(issue::Column::Project.eq(project_id)) .filter(issue::Column::Number.eq(number)) .one(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))? .ok_or_else(|| ToolError::ExecutionError(format!("Issue #{} not found", number)))?; // Permission check: author OR admin/owner require_issue_modifier(db, project_id, sender_id, issue.author).await?; let mut active: issue::ActiveModel = issue.clone().into(); let mut updated = false; let now = Utc::now(); if let Some(title) = args.get("title").and_then(|v| v.as_str()) { active.title = Set(title.to_string()); updated = true; } if let Some(body) = args.get("body").and_then(|v| v.as_str()) { active.body = Set(Some(body.to_string())); updated = true; } if let Some(state) = args.get("state").and_then(|v| v.as_str()) { let s = state.to_lowercase(); if s == "open" || s == "closed" { active.state = Set(s.clone()); active.updated_at = Set(now); if s == "closed" { active.closed_at = Set(Some(now)); } else { active.closed_at = Set(None); } updated = true; } } if let Some(milestone) = args.get("milestone") { if milestone.is_null() { active.milestone = Set(None); } else if let Some(m) = milestone.as_str() { active.milestone = Set(Some(m.to_string())); } updated = true; } if updated { active.updated_at = Set(now); active .update(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; } // Reload for response let updated_issue = Issue::find() .filter(issue::Column::Id.eq(issue.id)) .one(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))? .ok_or_else(|| ToolError::ExecutionError("Issue not found after update".into()))?; Ok(serde_json::json!({ "id": updated_issue.id.to_string(), "number": updated_issue.number, "title": updated_issue.title, "body": updated_issue.body, "state": updated_issue.state, "author_id": updated_issue.author.to_string(), "milestone": updated_issue.milestone, "created_at": updated_issue.created_at.to_rfc3339(), "updated_at": updated_issue.updated_at.to_rfc3339(), "closed_at": updated_issue.closed_at.map(|t| t.to_rfc3339()), })) } // ─── tool definitions ───────────────────────────────────────────────────────── pub fn list_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); p.insert("state".into(), ToolParam { name: "state".into(), param_type: "string".into(), description: Some("Filter by issue state: 'open' or 'closed'. Optional.".into()), required: false, properties: None, items: None, }); ToolDefinition::new("project_list_issues") .description( "List all issues in the current project. \ Returns issue number, title, body, state, author, assignees, labels, and timestamps.", ) .parameters(ToolSchema { schema_type: "object".into(), properties: Some(p), required: None, }) } pub fn create_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); p.insert("title".into(), ToolParam { name: "title".into(), param_type: "string".into(), description: Some("Issue title (required).".into()), required: true, properties: None, items: None, }); p.insert("body".into(), ToolParam { name: "body".into(), param_type: "string".into(), description: Some("Issue body / description. Optional.".into()), required: false, properties: None, items: None, }); p.insert("milestone".into(), ToolParam { name: "milestone".into(), param_type: "string".into(), description: Some("Milestone name. Optional.".into()), required: false, properties: None, items: None, }); p.insert("assignee_ids".into(), ToolParam { name: "assignee_ids".into(), param_type: "array".into(), description: Some("Array of user UUIDs to assign. Optional.".into()), required: false, properties: None, items: Some(Box::new(ToolParam { name: "".into(), param_type: "string".into(), description: None, required: false, properties: None, items: None, })), }); p.insert("label_ids".into(), ToolParam { name: "label_ids".into(), param_type: "array".into(), description: Some("Array of label IDs to apply. Optional.".into()), required: false, properties: None, items: Some(Box::new(ToolParam { name: "".into(), param_type: "integer".into(), description: None, required: false, properties: None, items: None, })), }); ToolDefinition::new("project_create_issue") .description( "Create a new issue in the current project. \ Returns the created issue with its number, id, and full details.", ) .parameters(ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["title".into()]), }) } pub fn update_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); p.insert("number".into(), ToolParam { name: "number".into(), param_type: "integer".into(), description: Some("Issue number (required).".into()), required: true, properties: None, items: None, }); p.insert("title".into(), ToolParam { name: "title".into(), param_type: "string".into(), description: Some("New issue title. Optional.".into()), required: false, properties: None, items: None, }); p.insert("body".into(), ToolParam { name: "body".into(), param_type: "string".into(), description: Some("New issue body. Optional.".into()), required: false, properties: None, items: None, }); p.insert("state".into(), ToolParam { name: "state".into(), param_type: "string".into(), description: Some("New issue state: 'open' or 'closed'. Optional.".into()), required: false, properties: None, items: None, }); p.insert("milestone".into(), ToolParam { name: "milestone".into(), param_type: "string".into(), description: Some("New milestone name. Set to null to remove. Optional.".into()), required: false, properties: None, items: None, }); ToolDefinition::new("project_update_issue") .description( "Update an existing issue in the current project by its number. \ Requires the issue author or a project admin/owner. \ Returns the updated issue. At least one field must be provided.", ) .parameters(ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["number".into()]), }) }