use ai::error::{AiError, AiResult}; use ai::tool::tools::FunctionCall; use async_trait::async_trait; use db::sqlx; use serde_json::{Value, json}; use super::helpers::{arg_str, git_ctx, require_workspace_member}; use crate::agent::run::AppAgentContext; pub struct IssueListTool; impl IssueListTool { pub fn new() -> Self { Self } } impl Default for IssueListTool { fn default() -> Self { Self::new() } } #[async_trait] impl FunctionCall for IssueListTool { type Context = AppAgentContext; fn name(&self) -> &'static str { "issue_list" } fn description(&self) -> &'static str { "List issues in a workspace with optional filters: state (open/closed), priority, label, milestone, assignee." } fn schema(&self) -> Value { json!({ "type": "object", "properties": { "workspace": { "type": "string", "description": "Workspace name" }, "state": { "type": "string", "description": "Filter by state: 'open' or 'closed'" }, "priority": { "type": "string", "description": "Filter by priority" }, "label": { "type": "string", "description": "Filter by label name" }, "milestone": { "type": "string", "description": "Filter by milestone title" }, "assignee": { "type": "string", "description": "Filter by assignee username" }, "limit": { "type": "integer", "description": "Max results (default 20, max 100)" } }, "required": ["workspace"] }) } async fn call( &self, ctx: &mut AppAgentContext, args: Value, ) -> AiResult { let git = git_ctx(ctx)?; let workspace = arg_str(&args, "workspace")?; let wk_id = require_workspace_member(git, ctx.user_id, workspace).await?; let limit = args .get("limit") .and_then(|v| v.as_i64()) .unwrap_or(20) .min(100); let mut conditions = vec!["i.wk = $1".to_string(), "i.deleted_at IS NULL".to_string()]; let mut params: Vec = vec![wk_id.to_string()]; let mut idx = 2i32; for (arg, col) in [("state", "i.state"), ("priority", "i.priority")] { if let Some(v) = args .get(arg) .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) { conditions.push(format!("{col} = ${idx}")); params.push(v.to_string()); idx += 1; } } if let Some(v) = args .get("label") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) { conditions.push(format!("EXISTS(SELECT 1 FROM issue_label il INNER JOIN label l ON l.id = il.label WHERE il.issue = i.id AND l.name = ${idx})")); params.push(v.to_string()); idx += 1; } if let Some(v) = args .get("milestone") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) { conditions.push(format!("EXISTS(SELECT 1 FROM issue_milestone im INNER JOIN milestone m ON m.id = im.milestone WHERE im.issue = i.id AND m.title = ${idx})")); params.push(v.to_string()); idx += 1; } if let Some(v) = args .get("assignee") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) { conditions.push(format!("EXISTS(SELECT 1 FROM issue_assignee ia INNER JOIN \"user\" u ON u.id = ia.\"user\" WHERE ia.issue = i.id AND u.username = ${idx})")); params.push(v.to_string()); idx += 1; } let where_clause = conditions.join(" AND "); let query = format!( "SELECT i.number, i.title, i.body, i.state, i.priority, \ i.closed_at, i.due_at, i.created_at \ FROM issue i WHERE {where_clause} \ ORDER BY i.created_at DESC LIMIT ${idx}", ); let mut q = sqlx::query_as::<_, IssueRow>(db::sqlx::AssertSqlSafe(query)); q = q.bind(wk_id); for i in 1..params.len() { q = q.bind(¶ms[i]); } q = q.bind(limit); let rows = q .fetch_all(git.db.reader()) .await .map_err(AiError::Database)?; let issues: Vec = rows.iter().map(|r| json!({ "number": r.number, "title": r.title, "state": r.state, "priority": r.priority, "body": r.body.as_ref().map(|b| if b.len() > 500 { format!("{}...", &b[..500]) } else { b.clone() }), "created_at": r.created_at.to_rfc3339(), })).collect(); Ok(json!({ "issues": issues, "count": issues.len() })) } } #[derive(sqlx::FromRow)] struct IssueRow { number: i64, title: String, body: Option, state: String, priority: String, due_at: Option>, closed_at: Option>, created_at: chrono::DateTime, } pub struct IssueGetTool; impl IssueGetTool { pub fn new() -> Self { Self } } impl Default for IssueGetTool { fn default() -> Self { Self::new() } } #[async_trait] impl FunctionCall for IssueGetTool { type Context = AppAgentContext; fn name(&self) -> &'static str { "issue_get" } fn description(&self) -> &'static str { "Get full details of a single issue by its number." } fn schema(&self) -> Value { json!({ "type": "object", "properties": { "workspace": { "type": "string", "description": "Workspace name" }, "number": { "type": "integer", "description": "Issue number" } }, "required": ["workspace", "number"] }) } async fn call( &self, ctx: &mut AppAgentContext, args: Value, ) -> AiResult { let git = git_ctx(ctx)?; let workspace = arg_str(&args, "workspace")?; let number = args.get("number").and_then(|v| v.as_i64()).ok_or_else(|| { AiError::Config("'number' parameter is required".to_string()) })?; let wk_id = require_workspace_member(git, ctx.user_id, workspace).await?; let row = sqlx::query_as::<_, IssueRow>( "SELECT number, title, body, state, priority, \ closed_at, due_at, created_at \ FROM issue WHERE wk = $1 AND number = $2 AND deleted_at IS NULL", ) .bind(wk_id) .bind(number) .fetch_optional(git.db.reader()) .await .map_err(AiError::Database)? .ok_or_else(|| AiError::Config(format!("issue #{number} not found")))?; // Load labels #[derive(sqlx::FromRow)] struct LabelRow { name: String, } let labels: Vec = sqlx::query_as::<_, LabelRow>( "SELECT l.name FROM label l \ INNER JOIN issue_label il ON il.label = l.id \ WHERE il.issue = (SELECT id FROM issue WHERE wk = $1 AND number = $2)", ) .bind(wk_id).bind(number) .fetch_all(git.db.reader()).await.map_err(AiError::Database)? .iter().map(|r| r.name.clone()).collect(); // Load assignees #[derive(sqlx::FromRow)] struct AssigneeRow { username: String, } let assignees: Vec = sqlx::query_as::<_, AssigneeRow>( "SELECT u.username FROM \"user\" u \ INNER JOIN issue_assignee ia ON ia.\"user\" = u.id \ WHERE ia.issue = (SELECT id FROM issue WHERE wk = $1 AND number = $2)", ) .bind(wk_id).bind(number) .fetch_all(git.db.reader()).await.map_err(AiError::Database)? .iter().map(|r| r.username.clone()).collect(); Ok(json!({ "number": row.number, "title": row.title, "body": row.body, "state": row.state, "priority": row.priority, "labels": labels, "assignees": assignees, "created_at": row.created_at.to_rfc3339(), "due_at": row.due_at.map(|d| d.to_rfc3339()), "closed_at": row.closed_at.map(|d| d.to_rfc3339()), })) } } pub struct IssueCommentsTool; impl IssueCommentsTool { pub fn new() -> Self { Self } } impl Default for IssueCommentsTool { fn default() -> Self { Self::new() } } #[async_trait] impl FunctionCall for IssueCommentsTool { type Context = AppAgentContext; fn name(&self) -> &'static str { "issue_comments" } fn description(&self) -> &'static str { "List comments on an issue, ordered by time." } fn schema(&self) -> Value { json!({ "type": "object", "properties": { "workspace": { "type": "string", "description": "Workspace name" }, "number": { "type": "integer", "description": "Issue number" }, "limit": { "type": "integer", "description": "Max results (default 50)" } }, "required": ["workspace", "number"] }) } async fn call( &self, ctx: &mut AppAgentContext, args: Value, ) -> AiResult { let git = git_ctx(ctx)?; let workspace = arg_str(&args, "workspace")?; let number = args.get("number").and_then(|v| v.as_i64()).ok_or_else(|| { AiError::Config("'number' parameter is required".to_string()) })?; let wk_id = require_workspace_member(git, ctx.user_id, workspace).await?; let limit = args .get("limit") .and_then(|v| v.as_i64()) .unwrap_or(50) .min(200); #[derive(sqlx::FromRow)] struct CommentRow { body: String, username: String, created_at: chrono::DateTime, } let rows: Vec = sqlx::query_as( "SELECT ic.body, u.username, ic.created_at \ FROM issue_comment ic \ INNER JOIN \"user\" u ON u.id = ic.author \ WHERE ic.issue = (SELECT id FROM issue WHERE wk = $1 AND number = $2) \ AND ic.deleted_at IS NULL \ ORDER BY ic.created_at ASC LIMIT $3", ) .bind(wk_id).bind(number).bind(limit) .fetch_all(git.db.reader()).await.map_err(AiError::Database)?; let comments: Vec = rows .iter() .map(|r| { json!({ "author": r.username, "body": r.body, "created_at": r.created_at.to_rfc3339(), }) }) .collect(); Ok( json!({ "issue_number": number, "comments": comments, "count": comments.len() }), ) } } pub struct IssueEventsTool; impl IssueEventsTool { pub fn new() -> Self { Self } } impl Default for IssueEventsTool { fn default() -> Self { Self::new() } } #[async_trait] impl FunctionCall for IssueEventsTool { type Context = AppAgentContext; fn name(&self) -> &'static str { "issue_events" } fn description(&self) -> &'static str { "List the timeline of events for an issue (created, commented, closed, labeled, etc)." } fn schema(&self) -> Value { json!({ "type": "object", "properties": { "workspace": { "type": "string", "description": "Workspace name" }, "number": { "type": "integer", "description": "Issue number" } }, "required": ["workspace", "number"] }) } async fn call( &self, ctx: &mut AppAgentContext, args: Value, ) -> AiResult { let git = git_ctx(ctx)?; let workspace = arg_str(&args, "workspace")?; let number = args.get("number").and_then(|v| v.as_i64()).ok_or_else(|| { AiError::Config("'number' parameter is required".to_string()) })?; let wk_id = require_workspace_member(git, ctx.user_id, workspace).await?; #[derive(sqlx::FromRow)] struct EventRow { event: String, from_value: Option, to_value: Option, username: Option, created_at: chrono::DateTime, } let rows: Vec = sqlx::query_as( "SELECT e.event, e.from_value, e.to_value, u.username, e.created_at \ FROM issue_event e \ LEFT JOIN \"user\" u ON u.id = e.actor \ WHERE e.issue = (SELECT id FROM issue WHERE wk = $1 AND number = $2) \ ORDER BY e.created_at ASC", ) .bind(wk_id).bind(number) .fetch_all(git.db.reader()).await.map_err(AiError::Database)?; let events: Vec = rows .iter() .map(|r| { json!({ "event": r.event, "actor": r.username, "from": r.from_value, "to": r.to_value, "created_at": r.created_at.to_rfc3339(), }) }) .collect(); Ok( json!({ "issue_number": number, "events": events, "count": events.len() }), ) } }