gitdataai/libs/service/project_tools/issues.rs

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()]),
})
}