536 lines
19 KiB
Rust
536 lines
19 KiB
Rust
//! 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<serde_json::Value, ToolError> {
|
|
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<i64, ToolError> {
|
|
let max_num: Option<Option<i64>> = Issue::find()
|
|
.filter(issue::Column::Project.eq(project_id))
|
|
.select_only()
|
|
.column_as(issue::Column::Number.max(), "max_num")
|
|
.into_tuple::<Option<i64>>()
|
|
.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<serde_json::Value, ToolError> {
|
|
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<Uuid> = 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<i64> = 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<Uuid, serde_json::Value> =
|
|
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<i64, serde_json::Value> = 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::<Vec<_>>(),
|
|
"labels": label_ids.iter().filter_map(|lid| label_map.get(lid)).collect::<Vec<_>>(),
|
|
}))
|
|
}
|
|
|
|
// ─── update ───────────────────────────────────────────────────────────────────
|
|
|
|
pub async fn update_issue_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 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()]),
|
|
})
|
|
}
|