gitdataai/libs/service/project_tools/boards.rs

723 lines
26 KiB
Rust

//! 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(),
)),
}
}
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<serde_json::Value, ToolError> {
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<Uuid, Vec<serde_json::Value>> = columns
.clone()
.into_iter()
.map(|c| {
let cards_in_col: Vec<serde_json::Value> = 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<Uuid, Vec<serde_json::Value>> = 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<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();
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::<serde_json::Value>::new(),
}))
}
// ─── update board ─────────────────────────────────────────────────────────────
pub async fn update_board_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();
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<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();
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<Option<i32>> = 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::<Option<i32>>()
.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<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();
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<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();
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()]),
})
}