//! Tools: project_list_boards, project_create_board, project_update_board, //! project_create_board_card, project_update_board_card, project_delete_board_card use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema}; use chrono::Utc; use models::projects::{ project_board, project_board_card, project_board_column, project_members, }; use models::projects::{MemberRole, ProjectBoard, ProjectBoardCard, ProjectBoardColumn}; use models::users::user::Model as UserModel; use sea_orm::*; use std::collections::HashMap; use uuid::Uuid; // ─── helpers ────────────────────────────────────────────────────────────────── /// Check if the sender is an admin or owner of the project. async fn require_admin( db: &impl ConnectionTrait, project_id: Uuid, sender_id: Uuid, ) -> Result<(), ToolError> { let member = project_members::Entity::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 admin or owner can perform this action".into(), )), } } #[allow(dead_code)] fn serde_user(u: &UserModel) -> serde_json::Value { serde_json::json!({ "id": u.uid.to_string(), "username": u.username, "display_name": u.display_name, }) } // ─── list boards ────────────────────────────────────────────────────────────── pub async fn list_boards_exec( ctx: ToolContext, _args: serde_json::Value, ) -> Result { let project_id = ctx.project_id(); let db = ctx.db(); let boards = project_board::Entity::find() .filter(project_board::Column::Project.eq(project_id)) .order_by_asc(project_board::Column::CreatedAt) .all(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; let board_ids: Vec<_> = boards.iter().map(|b| b.id).collect(); // Batch-load columns and cards let columns = project_board_column::Entity::find() .filter(project_board_column::Column::Board.is_in(board_ids.clone())) .order_by_asc(project_board_column::Column::Position) .all(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; let column_ids: Vec<_> = columns.iter().map(|c| c.id).collect(); let cards = project_board_card::Entity::find() .filter(project_board_card::Column::Column.is_in(column_ids.clone())) .order_by_asc(project_board_card::Column::Position) .all(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; // Build column map let col_map: std::collections::HashMap> = columns .clone() .into_iter() .map(|c| { let cards_in_col: Vec = cards .iter() .filter(|card| card.column == c.id) .map(|card| { serde_json::json!({ "id": card.id.to_string(), "issue_id": card.issue_id, "title": card.title, "description": card.description, "position": card.position, "assignee_id": card.assignee_id.map(|id| id.to_string()), "due_date": card.due_date.map(|t| t.to_rfc3339()), "priority": card.priority, "created_at": card.created_at.to_rfc3339(), }) }) .collect(); (c.id, cards_in_col) }) .collect(); // Build column list per board let mut board_col_map: std::collections::HashMap> = columns .into_iter() .fold(std::collections::HashMap::new(), |mut acc, c| { let cards = col_map.get(&c.id).cloned().unwrap_or_default(); acc.entry(c.board).or_default().push(serde_json::json!({ "id": c.id.to_string(), "name": c.name, "position": c.position, "wip_limit": c.wip_limit, "color": c.color, "cards": cards, })); acc }); let result: Vec<_> = boards .into_iter() .map(|b| { let cols = board_col_map.remove(&b.id).unwrap_or_default(); serde_json::json!({ "id": b.id.to_string(), "name": b.name, "description": b.description, "created_by": b.created_by.to_string(), "created_at": b.created_at.to_rfc3339(), "updated_at": b.updated_at.to_rfc3339(), "columns": cols, }) }) .collect(); Ok(serde_json::to_value(result).map_err(|e| ToolError::ExecutionError(e.to_string()))?) } // ─── create board ───────────────────────────────────────────────────────────── pub async fn create_board_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(); require_admin(db, project_id, sender_id).await?; let 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 now = Utc::now(); let active = project_board::ActiveModel { id: Set(Uuid::now_v7()), project: Set(project_id), name: Set(name.clone()), description: Set(description), created_by: Set(sender_id), created_at: Set(now), updated_at: Set(now), }; 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.name, "description": model.description, "created_by": model.created_by.to_string(), "created_at": model.created_at.to_rfc3339(), "updated_at": model.updated_at.to_rfc3339(), "columns": Vec::::new(), })) } // ─── update board ───────────────────────────────────────────────────────────── pub async fn update_board_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(); require_admin(db, project_id, sender_id).await?; let board_id = args .get("board_id") .and_then(|v| Uuid::parse_str(v.as_str()?).ok()) .ok_or_else(|| ToolError::ExecutionError("board_id is required".into()))?; let board = ProjectBoard::find_by_id(board_id) .one(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))? .ok_or_else(|| ToolError::ExecutionError("Board not found".into()))?; if board.project != project_id { return Err(ToolError::ExecutionError( "Board does not belong to this project".into(), )); } let mut active: project_board::ActiveModel = board.clone().into(); let mut updated = false; if let Some(name) = args.get("name").and_then(|v| v.as_str()) { active.name = Set(name.to_string()); updated = true; } if let Some(description) = args.get("description") { active.description = Set(description.as_str().map(|s| s.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.name, "description": model.description, "created_by": model.created_by.to_string(), "created_at": model.created_at.to_rfc3339(), "updated_at": model.updated_at.to_rfc3339(), })) } // ─── create board card ─────────────────────────────────────────────────────── pub async fn create_board_card_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 board_id = args .get("board_id") .and_then(|v| Uuid::parse_str(v.as_str()?).ok()) .ok_or_else(|| ToolError::ExecutionError("board_id is required".into()))?; let title = args .get("title") .and_then(|v| v.as_str()) .ok_or_else(|| ToolError::ExecutionError("title is required".into()))? .to_string(); let column_id = args .get("column_id") .and_then(|v| Uuid::parse_str(v.as_str()?).ok()); let description = args .get("description") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let priority = args .get("priority") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let assignee_id = args .get("assignee_id") .and_then(|v| Uuid::parse_str(v.as_str()?).ok()); // Verify board belongs to project let board = ProjectBoard::find_by_id(board_id) .one(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))? .ok_or_else(|| ToolError::ExecutionError("Board not found".into()))?; if board.project != project_id { return Err(ToolError::ExecutionError( "Board does not belong to this project".into(), )); } // Get target column (first column if not specified) let target_column = if let Some(col_id) = column_id { let col = ProjectBoardColumn::find_by_id(col_id) .one(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))? .ok_or_else(|| ToolError::ExecutionError("Column not found".into()))?; if col.board != board_id { return Err(ToolError::ExecutionError( "Column does not belong to this board".into(), )); } col } else { ProjectBoardColumn::find() .filter(project_board_column::Column::Board.eq(board_id)) .order_by_asc(project_board_column::Column::Position) .one(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))? .ok_or_else(|| ToolError::ExecutionError("No columns found in this board".into()))? }; // Next position let max_pos: Option> = ProjectBoardCard::find() .filter(project_board_card::Column::Column.eq(target_column.id)) .select_only() .column_as(project_board_card::Column::Position.max(), "max_pos") .into_tuple::>() .one(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; let position = max_pos.flatten().unwrap_or(0) + 1; let now = Utc::now(); let active = project_board_card::ActiveModel { id: Set(Uuid::now_v7()), column: Set(target_column.id), issue_id: Set(None), project: Set(Some(project_id)), title: Set(title), description: Set(description), position: Set(position), assignee_id: Set(assignee_id), due_date: Set(None), priority: Set(priority), created_by: Set(sender_id), created_at: Set(now), updated_at: Set(now), }; let model = active .insert(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; Ok(serde_json::json!({ "id": model.id.to_string(), "column_id": model.column.to_string(), "title": model.title, "description": model.description, "position": model.position, "assignee_id": model.assignee_id.map(|id| id.to_string()), "priority": model.priority, "created_at": model.created_at.to_rfc3339(), "updated_at": model.updated_at.to_rfc3339(), })) } // ─── update board card ──────────────────────────────────────────────────────── pub async fn update_board_card_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(); require_admin(db, project_id, sender_id).await?; let card_id = args .get("card_id") .and_then(|v| Uuid::parse_str(v.as_str()?).ok()) .ok_or_else(|| ToolError::ExecutionError("card_id is required".into()))?; let card = ProjectBoardCard::find_by_id(card_id) .one(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))? .ok_or_else(|| ToolError::ExecutionError("Card not found".into()))?; // Verify card belongs to a column in this project's board let col = ProjectBoardColumn::find_by_id(card.column) .one(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))? .ok_or_else(|| ToolError::ExecutionError("Column not found".into()))?; let board = ProjectBoard::find_by_id(col.board) .one(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))? .ok_or_else(|| ToolError::ExecutionError("Board not found".into()))?; if board.project != project_id { return Err(ToolError::ExecutionError( "Card does not belong to this project".into(), )); } let mut active: project_board_card::ActiveModel = card.clone().into(); let mut updated = false; if let Some(title) = args.get("title").and_then(|v| v.as_str()) { active.title = Set(title.to_string()); updated = true; } if let Some(description) = args.get("description") { active.description = Set(description.as_str().map(|s| s.to_string())); updated = true; } if let Some(column_id) = args .get("column_id") .and_then(|v| Uuid::parse_str(v.as_str()?).ok()) { // Verify column belongs to the same board let new_col = ProjectBoardColumn::find_by_id(column_id) .one(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))? .ok_or_else(|| ToolError::ExecutionError("Column not found".into()))?; if new_col.board != col.board { return Err(ToolError::ExecutionError( "Column does not belong to this board".into(), )); } active.column = Set(column_id); updated = true; } if let Some(position) = args.get("position").and_then(|v| v.as_i64()) { active.position = Set(position as i32); updated = true; } if let Some(assignee_id) = args.get("assignee_id") { active.assignee_id = Set(assignee_id.as_str().and_then(|s| Uuid::parse_str(s).ok())); updated = true; } if let Some(priority) = args.get("priority") { active.priority = Set(priority.as_str().map(|s| s.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(), "column_id": model.column.to_string(), "title": model.title, "description": model.description, "position": model.position, "assignee_id": model.assignee_id.map(|id| id.to_string()), "priority": model.priority, "created_at": model.created_at.to_rfc3339(), "updated_at": model.updated_at.to_rfc3339(), })) } // ─── delete board card ───────────────────────────────────────────────────────── pub async fn delete_board_card_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(); require_admin(db, project_id, sender_id).await?; let card_id = args .get("card_id") .and_then(|v| Uuid::parse_str(v.as_str()?).ok()) .ok_or_else(|| ToolError::ExecutionError("card_id is required".into()))?; let card = ProjectBoardCard::find_by_id(card_id) .one(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))? .ok_or_else(|| ToolError::ExecutionError("Card not found".into()))?; let col = ProjectBoardColumn::find_by_id(card.column) .one(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))? .ok_or_else(|| ToolError::ExecutionError("Column not found".into()))?; let board = ProjectBoard::find_by_id(col.board) .one(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))? .ok_or_else(|| ToolError::ExecutionError("Board not found".into()))?; if board.project != project_id { return Err(ToolError::ExecutionError( "Card does not belong to this project".into(), )); } ProjectBoardCard::delete_by_id(card_id) .exec(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; Ok(serde_json::json!({ "deleted": true })) } // ─── tool definitions ───────────────────────────────────────────────────────── pub fn list_tool_definition() -> ToolDefinition { ToolDefinition::new("project_list_boards") .description( "List all Kanban boards in the current project. \ Returns boards with their columns and cards, including positions and priorities.", ) .parameters(ToolSchema { schema_type: "object".into(), properties: None, required: None, }) } pub fn create_board_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); p.insert("name".into(), ToolParam { name: "name".into(), param_type: "string".into(), description: Some("Board name (required).".into()), required: true, properties: None, items: None, }); p.insert("description".into(), ToolParam { name: "description".into(), param_type: "string".into(), description: Some("Board description. Optional.".into()), required: false, properties: None, items: None, }); ToolDefinition::new("project_create_board") .description( "Create a new Kanban board in the current project. Requires admin or owner role.", ) .parameters(ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["name".into()]), }) } pub fn update_board_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); p.insert("board_id".into(), ToolParam { name: "board_id".into(), param_type: "string".into(), description: Some("Board UUID (required).".into()), required: true, properties: None, items: None, }); p.insert("name".into(), ToolParam { name: "name".into(), param_type: "string".into(), description: Some("New board name. Optional.".into()), required: false, properties: None, items: None, }); p.insert("description".into(), ToolParam { name: "description".into(), param_type: "string".into(), description: Some("New board description. Optional.".into()), required: false, properties: None, items: None, }); ToolDefinition::new("project_update_board") .description( "Update a Kanban board (name or description). Requires admin or owner role.", ) .parameters(ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["board_id".into()]), }) } pub fn create_card_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); p.insert("board_id".into(), ToolParam { name: "board_id".into(), param_type: "string".into(), description: Some("Board UUID (required).".into()), required: true, properties: None, items: None, }); p.insert("column_id".into(), ToolParam { name: "column_id".into(), param_type: "string".into(), description: Some("Column UUID. Optional — defaults to first column.".into()), required: false, properties: None, items: None, }); p.insert("title".into(), ToolParam { name: "title".into(), param_type: "string".into(), description: Some("Card title (required).".into()), required: true, properties: None, items: None, }); p.insert("description".into(), ToolParam { name: "description".into(), param_type: "string".into(), description: Some("Card description. Optional.".into()), required: false, properties: None, items: None, }); p.insert("priority".into(), ToolParam { name: "priority".into(), param_type: "string".into(), description: Some("Card priority (e.g. 'low', 'medium', 'high'). Optional.".into()), required: false, properties: None, items: None, }); p.insert("assignee_id".into(), ToolParam { name: "assignee_id".into(), param_type: "string".into(), description: Some("Assignee user UUID. Optional.".into()), required: false, properties: None, items: None, }); ToolDefinition::new("project_create_board_card") .description( "Create a card on a Kanban board. If column_id is not provided, \ the card is added to the first column.", ) .parameters(ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["board_id".into(), "title".into()]), }) } pub fn update_card_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); p.insert("card_id".into(), ToolParam { name: "card_id".into(), param_type: "string".into(), description: Some("Card UUID (required).".into()), required: true, properties: None, items: None, }); p.insert("title".into(), ToolParam { name: "title".into(), param_type: "string".into(), description: Some("New card title. Optional.".into()), required: false, properties: None, items: None, }); p.insert("description".into(), ToolParam { name: "description".into(), param_type: "string".into(), description: Some("New card description. Optional.".into()), required: false, properties: None, items: None, }); p.insert("column_id".into(), ToolParam { name: "column_id".into(), param_type: "string".into(), description: Some("Move card to a different column. Optional.".into()), required: false, properties: None, items: None, }); p.insert("position".into(), ToolParam { name: "position".into(), param_type: "integer".into(), description: Some("New position within column. Optional.".into()), required: false, properties: None, items: None, }); p.insert("priority".into(), ToolParam { name: "priority".into(), param_type: "string".into(), description: Some("New priority. Optional.".into()), required: false, properties: None, items: None, }); p.insert("assignee_id".into(), ToolParam { name: "assignee_id".into(), param_type: "string".into(), description: Some("New assignee UUID. Optional.".into()), required: false, properties: None, items: None, }); ToolDefinition::new("project_update_board_card") .description( "Update a board card (title, description, column, position, assignee, priority). \ Requires admin or owner role.", ) .parameters(ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["card_id".into()]), }) } pub fn delete_card_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); p.insert("card_id".into(), ToolParam { name: "card_id".into(), param_type: "string".into(), description: Some("Card UUID (required).".into()), required: true, properties: None, items: None, }); ToolDefinition::new("project_delete_board_card") .description("Delete a board card. Requires admin or owner role.") .parameters(ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["card_id".into()]), }) }