feat: add project tools (repos, issues, boards, arxiv, curl, members) and ThemeSwitcher component
This commit is contained in:
parent
d4b0a9ae67
commit
4d5c62e46a
227
libs/service/project_tools/arxiv.rs
Normal file
227
libs/service/project_tools/arxiv.rs
Normal file
@ -0,0 +1,227 @@
|
||||
//! Tool: project_arxiv_search — search arXiv papers by query
|
||||
|
||||
use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Number of results to return by default.
|
||||
const DEFAULT_MAX_RESULTS: usize = 10;
|
||||
const MAX_MAX_RESULTS: usize = 50;
|
||||
|
||||
/// arXiv API base URL (Atom feed).
|
||||
const ARXIV_API: &str = "http://export.arxiv.org/api/query";
|
||||
|
||||
/// arXiv Atom feed entry fields we care about.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ArxivEntry {
|
||||
#[serde(rename = "id")]
|
||||
entry_id: String,
|
||||
#[serde(rename = "title")]
|
||||
title: String,
|
||||
#[serde(rename = "summary")]
|
||||
summary: String,
|
||||
#[serde(default, rename = "author")]
|
||||
author: Vec<ArxivAuthor>,
|
||||
#[serde(rename = "published")]
|
||||
published: String,
|
||||
#[serde(default, rename = "link")]
|
||||
link: Vec<ArxivLink>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ArxivAuthor {
|
||||
#[serde(rename = "name")]
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ArxivLink {
|
||||
#[serde(rename = "type", default)]
|
||||
link_type: String,
|
||||
#[serde(rename = "href", default)]
|
||||
href: String,
|
||||
#[serde(rename = "title", default)]
|
||||
title: String,
|
||||
#[serde(rename = "rel", default)]
|
||||
rel: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ArxivFeed {
|
||||
#[serde(default, rename = "entry")]
|
||||
entry: Vec<ArxivEntry>,
|
||||
}
|
||||
|
||||
/// Search arXiv papers by query string.
|
||||
///
|
||||
/// Returns up to `max_results` papers (default 10, max 50) matching the query.
|
||||
/// Each result includes arXiv ID, title, authors, abstract, published date, and PDF URL.
|
||||
pub async fn arxiv_search_exec(
|
||||
_ctx: ToolContext,
|
||||
args: serde_json::Value,
|
||||
) -> Result<serde_json::Value, ToolError> {
|
||||
let query = args
|
||||
.get("query")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::ExecutionError("query is required".into()))?;
|
||||
|
||||
let max_results = args
|
||||
.get("max_results")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(DEFAULT_MAX_RESULTS as u64)
|
||||
.min(MAX_MAX_RESULTS as u64) as usize;
|
||||
|
||||
let start = args
|
||||
.get("start")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as usize;
|
||||
|
||||
// Build arXiv API query URL
|
||||
// Encode query for URL
|
||||
let encoded_query = urlencoding_encode(query);
|
||||
let url = format!(
|
||||
"{}?search_query=all:{}&start={}&max_results={}&sortBy=relevance&sortOrder=descending",
|
||||
ARXIV_API, encoded_query, start, max_results
|
||||
);
|
||||
|
||||
let response = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionError(format!("HTTP request failed: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(ToolError::ExecutionError(format!(
|
||||
"arXiv API returned status {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to read response: {}", e)))?;
|
||||
|
||||
let feed: ArxivFeed = quick_xml::de::from_str(&body)
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to parse Atom feed: {}", e)))?;
|
||||
|
||||
let results: Vec<serde_json::Value> = feed
|
||||
.entry
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
// Extract PDF link
|
||||
let pdf_url = entry
|
||||
.link
|
||||
.iter()
|
||||
.find(|l| l.link_type == "application/pdf")
|
||||
.map(|l| l.href.clone())
|
||||
.or_else(|| {
|
||||
entry
|
||||
.link
|
||||
.iter()
|
||||
.find(|l| l.rel == "alternate" && l.link_type.is_empty())
|
||||
.map(|l| l.href.replace("/abs/", "/pdf/"))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Extract arXiv ID from entry id URL
|
||||
// e.g. http://arxiv.org/abs/2312.12345v1 -> 2312.12345v1
|
||||
let arxiv_id = entry
|
||||
.entry_id
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(&entry.entry_id)
|
||||
.trim();
|
||||
|
||||
// Whitespace-normalize title and abstract
|
||||
let title = normalize_whitespace(&entry.title);
|
||||
let summary = normalize_whitespace(&entry.summary);
|
||||
let author_str = if entry.author.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
entry.author.iter().map(|a| a.name.as_str()).collect::<Vec<_>>().join(", ")
|
||||
};
|
||||
|
||||
serde_json::json!({
|
||||
"arxiv_id": arxiv_id,
|
||||
"title": title,
|
||||
"authors": author_str,
|
||||
"abstract": summary,
|
||||
"published": entry.published,
|
||||
"pdf_url": pdf_url,
|
||||
"abs_url": entry.entry_id,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"count": results.len(),
|
||||
"query": query,
|
||||
"results": results,
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn urlencoding_encode(s: &str) -> String {
|
||||
let mut encoded = String::with_capacity(s.len() * 2);
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
encoded.push(b as char);
|
||||
}
|
||||
_ => {
|
||||
encoded.push_str(&format!("%{:02X}", b));
|
||||
}
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn normalize_whitespace(s: &str) -> String {
|
||||
let s = s.trim();
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut last_was_space = false;
|
||||
for c in s.chars() {
|
||||
if c.is_whitespace() {
|
||||
if !last_was_space {
|
||||
result.push(' ');
|
||||
last_was_space = true;
|
||||
}
|
||||
} else {
|
||||
result.push(c);
|
||||
last_was_space = false;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ─── tool definition ─────────────────────────────────────────────────────────
|
||||
|
||||
pub fn tool_definition() -> ToolDefinition {
|
||||
let mut p = HashMap::new();
|
||||
p.insert("query".into(), ToolParam {
|
||||
name: "query".into(), param_type: "string".into(),
|
||||
description: Some("Search query (required). Supports arXiv search syntax, e.g. 'ti:transformer AND au:bengio'.".into()),
|
||||
required: true, properties: None, items: None,
|
||||
});
|
||||
p.insert("max_results".into(), ToolParam {
|
||||
name: "max_results".into(), param_type: "integer".into(),
|
||||
description: Some("Maximum number of results to return (default 10, max 50). Optional.".into()),
|
||||
required: false, properties: None, items: None,
|
||||
});
|
||||
p.insert("start".into(), ToolParam {
|
||||
name: "start".into(), param_type: "integer".into(),
|
||||
description: Some("Offset for pagination. Optional.".into()),
|
||||
required: false, properties: None, items: None,
|
||||
});
|
||||
ToolDefinition::new("project_arxiv_search")
|
||||
.description(
|
||||
"Search arXiv papers by keyword or phrase. \
|
||||
Returns paper titles, authors, abstracts, arXiv IDs, and PDF URLs. \
|
||||
Useful for finding academic papers relevant to the project or a task.",
|
||||
)
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(p),
|
||||
required: Some(vec!["query".into()]),
|
||||
})
|
||||
}
|
||||
722
libs/service/project_tools/boards.rs
Normal file
722
libs/service/project_tools/boards.rs
Normal file
@ -0,0 +1,722 @@
|
||||
//! 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()]),
|
||||
})
|
||||
}
|
||||
180
libs/service/project_tools/curl.rs
Normal file
180
libs/service/project_tools/curl.rs
Normal file
@ -0,0 +1,180 @@
|
||||
//! Tool: project_curl — perform HTTP requests (GET/POST/PUT/DELETE)
|
||||
|
||||
use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Maximum response body size: 1 MB.
|
||||
const MAX_BODY_BYTES: usize = 1 << 20;
|
||||
|
||||
/// Perform an HTTP request and return the response body and metadata.
|
||||
/// Supports GET, POST, PUT, DELETE methods. Useful for fetching web pages,
|
||||
/// calling external APIs, or downloading resources.
|
||||
pub async fn curl_exec(
|
||||
_ctx: ToolContext,
|
||||
args: serde_json::Value,
|
||||
) -> Result<serde_json::Value, ToolError> {
|
||||
let url = args
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::ExecutionError("url is required".into()))?;
|
||||
|
||||
let method = args
|
||||
.get("method")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("GET")
|
||||
.to_uppercase();
|
||||
|
||||
let body = args.get("body").and_then(|v| v.as_str()).map(String::from);
|
||||
|
||||
let headers: Vec<(String, String)> = args
|
||||
.get("headers")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter()
|
||||
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let timeout_secs = args
|
||||
.get("timeout")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(30)
|
||||
.min(120);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to build HTTP client: {}", e)))?;
|
||||
|
||||
let mut request = match method.as_str() {
|
||||
"GET" => client.get(url),
|
||||
"POST" => client.post(url),
|
||||
"PUT" => client.put(url),
|
||||
"DELETE" => client.delete(url),
|
||||
"PATCH" => client.patch(url),
|
||||
"HEAD" => client.head(url),
|
||||
_ => {
|
||||
return Err(ToolError::ExecutionError(format!(
|
||||
"Unsupported HTTP method: {}. Use GET, POST, PUT, DELETE, PATCH, or HEAD.",
|
||||
method
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
for (key, value) in &headers {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
|
||||
// Set default Content-Type for POST/PUT/PATCH if not provided and body exists
|
||||
if body.is_some() && !headers.iter().any(|(k, _)| k.to_lowercase() == "content-type") {
|
||||
request = request.header("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
if let Some(ref b) = body {
|
||||
request = request.body(b.clone());
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionError(format!("HTTP request failed: {}", e)))?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
let status_text = response.status().canonical_reason().unwrap_or("");
|
||||
|
||||
let response_headers: std::collections::HashMap<String, String> = response
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k.to_string(),
|
||||
v.to_str().unwrap_or("<binary>").to_string(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let is_text = content_type.starts_with("text/")
|
||||
|| content_type.contains("json")
|
||||
|| content_type.contains("xml")
|
||||
|| content_type.contains("javascript");
|
||||
|
||||
let body_bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to read response body: {}", e)))?;
|
||||
|
||||
let body_len = body_bytes.len();
|
||||
let truncated = body_len > MAX_BODY_BYTES;
|
||||
let body_text = if truncated {
|
||||
String::from("[Response truncated — exceeds 1 MB limit]")
|
||||
} else if is_text {
|
||||
String::from_utf8_lossy(&body_bytes).to_string()
|
||||
} else {
|
||||
format!(
|
||||
"[Binary body, {} bytes, Content-Type: {}]",
|
||||
body_len, content_type
|
||||
)
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"url": url,
|
||||
"method": method,
|
||||
"status": status,
|
||||
"status_text": status_text,
|
||||
"headers": response_headers,
|
||||
"body": body_text,
|
||||
"truncated": truncated,
|
||||
"size_bytes": body_len,
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── tool definition ─────────────────────────────────────────────────────────
|
||||
|
||||
pub fn tool_definition() -> ToolDefinition {
|
||||
let mut p = HashMap::new();
|
||||
p.insert("url".into(), ToolParam {
|
||||
name: "url".into(), param_type: "string".into(),
|
||||
description: Some("Full URL to request (required).".into()),
|
||||
required: true, properties: None, items: None,
|
||||
});
|
||||
p.insert("method".into(), ToolParam {
|
||||
name: "method".into(), param_type: "string".into(),
|
||||
description: Some("HTTP method: GET (default), POST, PUT, DELETE, PATCH, HEAD.".into()),
|
||||
required: false, properties: None, items: None,
|
||||
});
|
||||
p.insert("body".into(), ToolParam {
|
||||
name: "body".into(), param_type: "string".into(),
|
||||
description: Some("Request body. Defaults to 'application/json' Content-Type if provided. Optional.".into()),
|
||||
required: false, properties: None, items: None,
|
||||
});
|
||||
p.insert("headers".into(), ToolParam {
|
||||
name: "headers".into(), param_type: "object".into(),
|
||||
description: Some("HTTP headers as key-value pairs. Optional.".into()),
|
||||
required: false, properties: None, items: None,
|
||||
});
|
||||
p.insert("timeout".into(), ToolParam {
|
||||
name: "timeout".into(), param_type: "integer".into(),
|
||||
description: Some("Request timeout in seconds (default 30, max 120). Optional.".into()),
|
||||
required: false, properties: None, items: None,
|
||||
});
|
||||
ToolDefinition::new("project_curl")
|
||||
.description(
|
||||
"Perform an HTTP request to any URL. Supports GET, POST, PUT, DELETE, PATCH, HEAD. \
|
||||
Returns status code, headers, and response body. \
|
||||
Response body is truncated at 1 MB. Binary responses are described as text metadata. \
|
||||
Useful for fetching web pages, calling APIs, or downloading resources.",
|
||||
)
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(p),
|
||||
required: Some(vec!["url".into()]),
|
||||
})
|
||||
}
|
||||
535
libs/service/project_tools/issues.rs
Normal file
535
libs/service/project_tools/issues.rs
Normal file
@ -0,0 +1,535 @@
|
||||
//! 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()]),
|
||||
})
|
||||
}
|
||||
64
libs/service/project_tools/members.rs
Normal file
64
libs/service/project_tools/members.rs
Normal file
@ -0,0 +1,64 @@
|
||||
//! Tool: project_list_members
|
||||
|
||||
use agent::{ToolContext, ToolDefinition, ToolError, ToolSchema};
|
||||
use models::projects::project_members;
|
||||
use models::users::User;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
pub async fn list_members_exec(
|
||||
ctx: ToolContext,
|
||||
_args: serde_json::Value,
|
||||
) -> Result<serde_json::Value, ToolError> {
|
||||
let project_id = ctx.project_id();
|
||||
let db = ctx.db();
|
||||
|
||||
let members = project_members::Entity::find()
|
||||
.filter(project_members::Column::Project.eq(project_id))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
|
||||
|
||||
let user_ids: Vec<_> = members.iter().map(|m| m.user).collect();
|
||||
let users = User::find()
|
||||
.filter(models::users::user::Column::Uid.is_in(user_ids))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
|
||||
|
||||
let user_map: std::collections::HashMap<_, _> =
|
||||
users.into_iter().map(|u| (u.uid, u)).collect();
|
||||
|
||||
let result: Vec<_> = members
|
||||
.into_iter()
|
||||
.filter_map(|m| {
|
||||
let user = user_map.get(&m.user)?;
|
||||
let role = m
|
||||
.scope_role()
|
||||
.map(|r| r.to_string())
|
||||
.unwrap_or_else(|_| "member".to_string());
|
||||
Some(serde_json::json!({
|
||||
"id": m.user.to_string(),
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"organization": user.organization,
|
||||
"role": role,
|
||||
"joined_at": m.joined_at.to_rfc3339(),
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::to_value(result).map_err(|e| ToolError::ExecutionError(e.to_string()))?)
|
||||
}
|
||||
|
||||
pub fn tool_definition() -> ToolDefinition {
|
||||
ToolDefinition::new("project_list_members")
|
||||
.description(
|
||||
"List all members in the current project. \
|
||||
Returns username, display name, organization, role, and join time.",
|
||||
)
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: None,
|
||||
required: None,
|
||||
})
|
||||
}
|
||||
104
libs/service/project_tools/mod.rs
Normal file
104
libs/service/project_tools/mod.rs
Normal file
@ -0,0 +1,104 @@
|
||||
//! Project tools for AI agent function calling.
|
||||
//!
|
||||
//! Tools that let the agent perceive and modify the current project context:
|
||||
//! - list repos in the project
|
||||
//! - list members in the project
|
||||
//! - list / create / update issues
|
||||
//! - list / create / update boards and board cards
|
||||
|
||||
mod arxiv;
|
||||
mod boards;
|
||||
mod curl;
|
||||
mod issues;
|
||||
mod members;
|
||||
mod repos;
|
||||
|
||||
use agent::{ToolHandler, ToolRegistry};
|
||||
|
||||
pub use arxiv::arxiv_search_exec;
|
||||
pub use boards::{
|
||||
create_board_card_exec, create_board_exec, delete_board_card_exec, list_boards_exec,
|
||||
update_board_card_exec, update_board_exec,
|
||||
};
|
||||
pub use curl::curl_exec;
|
||||
pub use issues::{create_issue_exec, list_issues_exec, update_issue_exec};
|
||||
pub use members::list_members_exec;
|
||||
pub use repos::{create_commit_exec, create_repo_exec, list_repos_exec, update_repo_exec};
|
||||
|
||||
pub fn register_all(registry: &mut ToolRegistry) {
|
||||
// arxiv
|
||||
registry.register(
|
||||
arxiv::tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(arxiv_search_exec(ctx, args))),
|
||||
);
|
||||
|
||||
// curl
|
||||
registry.register(
|
||||
curl::tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(curl_exec(ctx, args))),
|
||||
);
|
||||
|
||||
// repos
|
||||
registry.register(
|
||||
repos::list_tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(list_repos_exec(ctx, args))),
|
||||
);
|
||||
registry.register(
|
||||
repos::create_tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(create_repo_exec(ctx, args))),
|
||||
);
|
||||
registry.register(
|
||||
repos::update_tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(update_repo_exec(ctx, args))),
|
||||
);
|
||||
registry.register(
|
||||
repos::create_commit_tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(create_commit_exec(ctx, args))),
|
||||
);
|
||||
|
||||
// members
|
||||
registry.register(
|
||||
members::tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(list_members_exec(ctx, args))),
|
||||
);
|
||||
|
||||
// issues
|
||||
registry.register(
|
||||
issues::list_tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(list_issues_exec(ctx, args))),
|
||||
);
|
||||
registry.register(
|
||||
issues::create_tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(create_issue_exec(ctx, args))),
|
||||
);
|
||||
registry.register(
|
||||
issues::update_tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(update_issue_exec(ctx, args))),
|
||||
);
|
||||
|
||||
// boards
|
||||
registry.register(
|
||||
boards::list_tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(list_boards_exec(ctx, args))),
|
||||
);
|
||||
registry.register(
|
||||
boards::create_board_tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(create_board_exec(ctx, args))),
|
||||
);
|
||||
registry.register(
|
||||
boards::update_board_tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(update_board_exec(ctx, args))),
|
||||
);
|
||||
registry.register(
|
||||
boards::create_card_tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(create_board_card_exec(ctx, args))),
|
||||
);
|
||||
registry.register(
|
||||
boards::update_card_tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(update_board_card_exec(ctx, args))),
|
||||
);
|
||||
registry.register(
|
||||
boards::delete_card_tool_definition(),
|
||||
ToolHandler::new(|ctx, args| Box::pin(delete_board_card_exec(ctx, args))),
|
||||
);
|
||||
}
|
||||
559
libs/service/project_tools/repos.rs
Normal file
559
libs/service/project_tools/repos.rs
Normal file
@ -0,0 +1,559 @@
|
||||
//! Tool: project_list_repos, project_create_repo, project_create_commit
|
||||
|
||||
use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
|
||||
use chrono::Utc;
|
||||
use git::commit::types::CommitOid;
|
||||
use git::commit::types::CommitSignature;
|
||||
use models::projects::{MemberRole, ProjectMember};
|
||||
use models::projects::project_members;
|
||||
use models::repos::repo;
|
||||
use models::users::user_email;
|
||||
use sea_orm::*;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ─── list ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn list_repos_exec(
|
||||
ctx: ToolContext,
|
||||
_args: serde_json::Value,
|
||||
) -> Result<serde_json::Value, ToolError> {
|
||||
let project_id = ctx.project_id();
|
||||
let db = ctx.db();
|
||||
|
||||
let repos = repo::Entity::find()
|
||||
.filter(repo::Column::Project.eq(project_id))
|
||||
.order_by_asc(repo::Column::RepoName)
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
|
||||
|
||||
let result: Vec<_> = repos
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
serde_json::json!({
|
||||
"id": r.id.to_string(),
|
||||
"name": r.repo_name,
|
||||
"description": r.description,
|
||||
"default_branch": r.default_branch,
|
||||
"is_private": r.is_private,
|
||||
"created_at": r.created_at.to_rfc3339(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::to_value(result).map_err(|e| ToolError::ExecutionError(e.to_string()))?)
|
||||
}
|
||||
|
||||
// ─── create ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn create_repo_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();
|
||||
|
||||
// Admin/owner check
|
||||
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 => {}
|
||||
MemberRole::Member => {
|
||||
return Err(ToolError::ExecutionError(
|
||||
"Only admin or owner can create repositories".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let repo_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 is_private = args
|
||||
.get("is_private")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Check name uniqueness within project
|
||||
let existing = repo::Entity::find()
|
||||
.filter(repo::Column::Project.eq(project_id))
|
||||
.filter(repo::Column::RepoName.eq(&repo_name))
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(ToolError::ExecutionError(format!(
|
||||
"Repository '{}' already exists in this project",
|
||||
repo_name
|
||||
)));
|
||||
}
|
||||
|
||||
// Look up project name for storage_path
|
||||
let project = models::projects::project::Entity::find_by_id(project_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
|
||||
.ok_or_else(|| ToolError::ExecutionError("Project not found".into()))?;
|
||||
|
||||
let repos_root = ctx
|
||||
.config()
|
||||
.repos_root()
|
||||
.map_err(|e| ToolError::ExecutionError(format!("repos_root config error: {}", e)))?;
|
||||
|
||||
let repo_dir: PathBuf = [&repos_root, &project.name, &format!("{}.git", repo_name)]
|
||||
.iter()
|
||||
.collect();
|
||||
|
||||
let now = Utc::now();
|
||||
let active = repo::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
repo_name: Set(repo_name.clone()),
|
||||
project: Set(project_id),
|
||||
description: Set(description),
|
||||
default_branch: Set("main".to_string()),
|
||||
is_private: Set(is_private),
|
||||
storage_path: Set(repo_dir.to_string_lossy().to_string()),
|
||||
created_by: Set(sender_id),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
ai_code_review_enabled: Set(false),
|
||||
};
|
||||
|
||||
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.repo_name,
|
||||
"description": model.description,
|
||||
"default_branch": model.default_branch,
|
||||
"is_private": model.is_private,
|
||||
"storage_path": model.storage_path,
|
||||
"created_at": model.created_at.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── update ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn update_repo_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();
|
||||
|
||||
// Admin/owner check
|
||||
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 => {}
|
||||
MemberRole::Member => {
|
||||
return Err(ToolError::ExecutionError(
|
||||
"Only admin or owner can update repositories".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let repo_name = args
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::ExecutionError("name is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let repo = repo::Entity::find()
|
||||
.filter(repo::Column::Project.eq(project_id))
|
||||
.filter(repo::Column::RepoName.eq(&repo_name))
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
ToolError::ExecutionError(format!("Repository '{}' not found", repo_name))
|
||||
})?;
|
||||
|
||||
let mut active: repo::ActiveModel = repo.clone().into();
|
||||
let mut updated = false;
|
||||
|
||||
if let Some(description) = args.get("description") {
|
||||
active.description = Set(description.as_str().map(|s| s.to_string()));
|
||||
updated = true;
|
||||
}
|
||||
if let Some(is_private) = args.get("is_private").and_then(|v| v.as_bool()) {
|
||||
active.is_private = Set(is_private);
|
||||
updated = true;
|
||||
}
|
||||
if let Some(default_branch) = args.get("default_branch").and_then(|v| v.as_str()) {
|
||||
active.default_branch = Set(default_branch.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.repo_name,
|
||||
"description": model.description,
|
||||
"default_branch": model.default_branch,
|
||||
"is_private": model.is_private,
|
||||
"created_at": model.created_at.to_rfc3339(),
|
||||
"updated_at": model.updated_at.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── create commit ────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn create_commit_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();
|
||||
|
||||
// Admin/owner check
|
||||
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 => {}
|
||||
MemberRole::Member => {
|
||||
return Err(ToolError::ExecutionError(
|
||||
"Only admin or owner can create commits".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let repo_name = args
|
||||
.get("repo_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::ExecutionError("repo_name is required".into()))?;
|
||||
|
||||
let message = args
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::ExecutionError("message is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let branch = args
|
||||
.get("branch")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("main")
|
||||
.to_string();
|
||||
|
||||
let files = args
|
||||
.get("files")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| {
|
||||
ToolError::ExecutionError("files is required and must be an array".into())
|
||||
})?;
|
||||
|
||||
if files.is_empty() {
|
||||
return Err(ToolError::ExecutionError(
|
||||
"files array cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Clone files data for spawn_blocking
|
||||
let files_data: Vec<serde_json::Value> = files.iter().cloned().collect();
|
||||
|
||||
// Look up sender username and email
|
||||
let sender = models::users::user::Entity::find_by_id(sender_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
|
||||
.ok_or_else(|| ToolError::ExecutionError("Sender user not found".into()))?;
|
||||
|
||||
let sender_email = user_email::Entity::find_by_id(sender_id)
|
||||
.one(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|e| e.email)
|
||||
.unwrap_or_else(|| format!("{}@gitdata.ai", sender.username));
|
||||
|
||||
let author_name = sender
|
||||
.display_name
|
||||
.unwrap_or_else(|| sender.username.clone());
|
||||
|
||||
// Find repo
|
||||
let repo_model = repo::Entity::find()
|
||||
.filter(repo::Column::Project.eq(project_id))
|
||||
.filter(repo::Column::RepoName.eq(repo_name))
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
ToolError::ExecutionError(format!("Repository '{}' not found", repo_name))
|
||||
})?;
|
||||
|
||||
let storage_path = repo_model.storage_path.clone();
|
||||
|
||||
// Run git operations in a blocking thread
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let domain = git::GitDomain::open(&storage_path)
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to open repo: {}", e)))?;
|
||||
|
||||
let repo = domain.repo();
|
||||
|
||||
// Get current head commit (parent)
|
||||
let parent_oid = repo.refname_to_id(&format!("refs/heads/{}", branch)).ok();
|
||||
let parent_ids: Vec<CommitOid> = parent_oid
|
||||
.map(|oid| CommitOid::from_git2(oid))
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
// Build index with new files
|
||||
let mut index = repo
|
||||
.index()
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to get index: {}", e)))?;
|
||||
|
||||
for file in files_data {
|
||||
let path = file
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::ExecutionError("Each file must have a 'path'".into()))?;
|
||||
let content = file
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::ExecutionError("Each file must have 'content'".into()))?;
|
||||
|
||||
let _oid = repo.blob(content.as_bytes()).map_err(|e| {
|
||||
ToolError::ExecutionError(format!("Failed to write blob for '{}': {}", path, e))
|
||||
})?;
|
||||
|
||||
index.add_path(path.as_ref()).map_err(|e| {
|
||||
ToolError::ExecutionError(format!("Failed to add '{}' to index: {}", path, e))
|
||||
})?;
|
||||
}
|
||||
|
||||
let tree_oid = index
|
||||
.write_tree()
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to write tree: {}", e)))?;
|
||||
|
||||
let tree_id = CommitOid::from_git2(tree_oid);
|
||||
|
||||
// Author signature
|
||||
let author = CommitSignature {
|
||||
name: author_name.clone(),
|
||||
email: sender_email.clone(),
|
||||
time_secs: chrono::Utc::now().timestamp(),
|
||||
offset_minutes: 0,
|
||||
};
|
||||
|
||||
// Committer signature: gitpanda <info@gitdata.ai>
|
||||
let committer = CommitSignature {
|
||||
name: "gitpanda".to_string(),
|
||||
email: "info@gitdata.ai".to_string(),
|
||||
time_secs: chrono::Utc::now().timestamp(),
|
||||
offset_minutes: 0,
|
||||
};
|
||||
|
||||
let commit_oid = domain
|
||||
.commit_create(
|
||||
Some(&format!("refs/heads/{}", branch)),
|
||||
&author,
|
||||
&committer,
|
||||
&message,
|
||||
&tree_id,
|
||||
&parent_ids,
|
||||
)
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to create commit: {}", e)))?;
|
||||
|
||||
Ok::<_, ToolError>(serde_json::json!({
|
||||
"commit_oid": commit_oid.to_string(),
|
||||
"branch": branch,
|
||||
"message": message,
|
||||
"author_name": author_name,
|
||||
"author_email": sender_email,
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Task join error: {}", e)))?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ─── tool definitions ─────────────────────────────────────────────────────────
|
||||
|
||||
pub fn list_tool_definition() -> ToolDefinition {
|
||||
ToolDefinition::new("project_list_repos")
|
||||
.description(
|
||||
"List all repositories in the current project. \
|
||||
Returns repo name, description, default branch, privacy status, and creation time.",
|
||||
)
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: None,
|
||||
required: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_tool_definition() -> ToolDefinition {
|
||||
let mut p = HashMap::new();
|
||||
p.insert("name".into(), ToolParam {
|
||||
name: "name".into(), param_type: "string".into(),
|
||||
description: Some("Repository name (required). Must be unique within the project.".into()),
|
||||
required: true, properties: None, items: None,
|
||||
});
|
||||
p.insert("description".into(), ToolParam {
|
||||
name: "description".into(), param_type: "string".into(),
|
||||
description: Some("Repository description. Optional.".into()),
|
||||
required: false, properties: None, items: None,
|
||||
});
|
||||
p.insert("is_private".into(), ToolParam {
|
||||
name: "is_private".into(), param_type: "boolean".into(),
|
||||
description: Some("Whether the repo is private. Defaults to false. Optional.".into()),
|
||||
required: false, properties: None, items: None,
|
||||
});
|
||||
ToolDefinition::new("project_create_repo")
|
||||
.description(
|
||||
"Create a new repository in the current project. \
|
||||
Requires admin or owner role. \
|
||||
The repo is initialized with a bare git structure.",
|
||||
)
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(p),
|
||||
required: Some(vec!["name".into()]),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_tool_definition() -> ToolDefinition {
|
||||
let mut p = HashMap::new();
|
||||
p.insert("name".into(), ToolParam {
|
||||
name: "name".into(), param_type: "string".into(),
|
||||
description: Some("Repository name (required).".into()),
|
||||
required: true, properties: None, items: None,
|
||||
});
|
||||
p.insert("description".into(), ToolParam {
|
||||
name: "description".into(), param_type: "string".into(),
|
||||
description: Some("New repository description. Optional.".into()),
|
||||
required: false, properties: None, items: None,
|
||||
});
|
||||
p.insert("is_private".into(), ToolParam {
|
||||
name: "is_private".into(), param_type: "boolean".into(),
|
||||
description: Some("New privacy setting. Optional.".into()),
|
||||
required: false, properties: None, items: None,
|
||||
});
|
||||
p.insert("default_branch".into(), ToolParam {
|
||||
name: "default_branch".into(), param_type: "string".into(),
|
||||
description: Some("New default branch name. Optional.".into()),
|
||||
required: false, properties: None, items: None,
|
||||
});
|
||||
ToolDefinition::new("project_update_repo")
|
||||
.description(
|
||||
"Update a repository's description, privacy, or default branch. \
|
||||
Requires admin or owner role.",
|
||||
)
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(p),
|
||||
required: Some(vec!["name".into()]),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_commit_tool_definition() -> ToolDefinition {
|
||||
let mut p = HashMap::new();
|
||||
p.insert("repo_name".into(), ToolParam {
|
||||
name: "repo_name".into(), param_type: "string".into(),
|
||||
description: Some("Repository name (required).".into()),
|
||||
required: true, properties: None, items: None,
|
||||
});
|
||||
p.insert("branch".into(), ToolParam {
|
||||
name: "branch".into(), param_type: "string".into(),
|
||||
description: Some("Branch to commit to. Defaults to 'main'. Optional.".into()),
|
||||
required: false, properties: None, items: None,
|
||||
});
|
||||
p.insert("message".into(), ToolParam {
|
||||
name: "message".into(), param_type: "string".into(),
|
||||
description: Some("Commit message (required).".into()),
|
||||
required: true, properties: None, items: None,
|
||||
});
|
||||
// files items
|
||||
let mut file_item = HashMap::new();
|
||||
file_item.insert("path".into(), ToolParam {
|
||||
name: "path".into(), param_type: "string".into(),
|
||||
description: Some("File path in the repo (required).".into()),
|
||||
required: true, properties: None, items: None,
|
||||
});
|
||||
file_item.insert("content".into(), ToolParam {
|
||||
name: "content".into(), param_type: "string".into(),
|
||||
description: Some("Full file content as string (required).".into()),
|
||||
required: true, properties: None, items: None,
|
||||
});
|
||||
p.insert("files".into(), ToolParam {
|
||||
name: "files".into(), param_type: "array".into(),
|
||||
description: Some("Array of files to commit (required, non-empty).".into()),
|
||||
required: true, properties: None,
|
||||
items: Some(Box::new(ToolParam {
|
||||
name: "".into(), param_type: "object".into(),
|
||||
description: None, required: true, properties: Some(file_item), items: None,
|
||||
})),
|
||||
});
|
||||
ToolDefinition::new("project_create_commit")
|
||||
.description(
|
||||
"Create a new commit in a repository. Commits the given files to the specified branch. \
|
||||
Requires admin or owner role. \
|
||||
Committer is always 'gitpanda <info@gitdata.ai>'. \
|
||||
Author is the sender's display name and email.",
|
||||
)
|
||||
.parameters(ToolSchema {
|
||||
schema_type: "object".into(),
|
||||
properties: Some(p),
|
||||
required: Some(vec!["repo_name".into(), "message".into(), "files".into()]),
|
||||
})
|
||||
}
|
||||
404
src/components/room/ThemeSwitcher.tsx
Normal file
404
src/components/room/ThemeSwitcher.tsx
Normal file
@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Theme switcher — preset selection + custom token editor.
|
||||
*
|
||||
* Presets:
|
||||
* Default — Linear / Vercel dual-color (index.css :root / .dark)
|
||||
* Custom — user-editable palette, stored in localStorage
|
||||
*
|
||||
* The panel is opened via the sidebar "Theme" button and presented as a Sheet.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
applyPaletteToDOM,
|
||||
clearCustomPalette,
|
||||
loadActivePresetId,
|
||||
loadCustomPalette,
|
||||
resetDOMFromPalette,
|
||||
saveActivePresetId,
|
||||
saveCustomPalette,
|
||||
THEME_PRESETS,
|
||||
} from './design-system';
|
||||
import type { PaletteEntry, ThemePresetId } from './design-system';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/contexts';
|
||||
import { Check, RotateCcw, Sliders } from 'lucide-react';
|
||||
|
||||
// ─── Token definitions ───────────────────────────────────────────────────────
|
||||
|
||||
interface TokenDef {
|
||||
key: keyof PaletteEntry;
|
||||
label: string;
|
||||
group: 'surface' | 'text' | 'accent' | 'border' | 'status' | 'message';
|
||||
type: 'color';
|
||||
}
|
||||
|
||||
const TOKEN_DEFS: TokenDef[] = [
|
||||
// Surface
|
||||
{ key: 'bg', label: 'Background', group: 'surface', type: 'color' },
|
||||
{ key: 'bgSubtle', label: 'Subtle', group: 'surface', type: 'color' },
|
||||
{ key: 'bgHover', label: 'Hover', group: 'surface', type: 'color' },
|
||||
{ key: 'bgActive', label: 'Active', group: 'surface', type: 'color' },
|
||||
{ key: 'surface', label: 'Surface (card)', group: 'surface', type: 'color' },
|
||||
{ key: 'surface2', label: 'Surface 2', group: 'surface', type: 'color' },
|
||||
{ key: 'panelBg', label: 'Panel / Sidebar', group: 'surface', type: 'color' },
|
||||
// Text
|
||||
{ key: 'text', label: 'Text', group: 'text', type: 'color' },
|
||||
{ key: 'textMuted', label: 'Text Muted', group: 'text', type: 'color' },
|
||||
{ key: 'textSubtle', label: 'Text Subtle', group: 'text', type: 'color' },
|
||||
{ key: 'icon', label: 'Icon', group: 'text', type: 'color' },
|
||||
{ key: 'iconHover', label: 'Icon Hover', group: 'text', type: 'color' },
|
||||
// Accent
|
||||
{ key: 'accent', label: 'Accent', group: 'accent', type: 'color' },
|
||||
{ key: 'accentHover', label: 'Accent Hover', group: 'accent', type: 'color' },
|
||||
{ key: 'accentText', label: 'Accent Text', group: 'accent', type: 'color' },
|
||||
{ key: 'mentionBg', label: 'Mention BG', group: 'accent', type: 'color' },
|
||||
{ key: 'mentionText', label: 'Mention Text', group: 'accent', type: 'color' },
|
||||
// Border
|
||||
{ key: 'border', label: 'Border', group: 'border', type: 'color' },
|
||||
{ key: 'borderFocus', label: 'Border Focus', group: 'border', type: 'color' },
|
||||
{ key: 'borderMuted', label: 'Border Muted', group: 'border', type: 'color' },
|
||||
// Status
|
||||
{ key: 'online', label: 'Online', group: 'status', type: 'color' },
|
||||
{ key: 'away', label: 'Away', group: 'status', type: 'color' },
|
||||
{ key: 'offline', label: 'Offline', group: 'status', type: 'color' },
|
||||
// Message
|
||||
{ key: 'msgBg', label: 'Message BG', group: 'message', type: 'color' },
|
||||
{ key: 'msgOwnBg', label: 'Own Message BG', group: 'message', type: 'color' },
|
||||
];
|
||||
|
||||
const GROUP_LABELS: Record<TokenDef['group'], string> = {
|
||||
surface: 'Surface',
|
||||
text: 'Text & Icon',
|
||||
accent: 'Accent',
|
||||
border: 'Border',
|
||||
status: 'Status',
|
||||
message: 'Message',
|
||||
};
|
||||
|
||||
// ─── Preset Card ─────────────────────────────────────────────────────────────
|
||||
|
||||
function PresetCard({
|
||||
preset,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
preset: (typeof THEME_PRESETS)[number];
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const previewPalette = preset.palette ?? buildDefaultPreview(resolvedTheme);
|
||||
|
||||
const swatches = [
|
||||
previewPalette.bg,
|
||||
previewPalette.surface,
|
||||
previewPalette.border,
|
||||
previewPalette.text,
|
||||
previewPalette.textMuted,
|
||||
previewPalette.accent,
|
||||
previewPalette.accentText,
|
||||
];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex flex-col gap-2 rounded-lg border p-3 text-left transition-all w-full',
|
||||
'hover:border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active ? 'border-primary ring-1 ring-primary' : 'border-border',
|
||||
)}
|
||||
>
|
||||
{/* Color swatches */}
|
||||
<div className="flex gap-1">
|
||||
{swatches.map((color, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-6 flex-1 rounded-sm"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Label */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">{preset.label}</span>
|
||||
{active && <Check className="h-3.5 w-3.5 text-primary" />}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||
{preset.description}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function buildDefaultPreview(theme: 'light' | 'dark'): PaletteEntry {
|
||||
if (theme === 'dark') {
|
||||
return {
|
||||
bg: '#1a1a1e', bgSubtle: '#1e1e23', bgHover: '#222228', bgActive: '#2a2a30',
|
||||
border: '#2e2e35', borderFocus: '#4a9eff', borderMuted: '#252528',
|
||||
text: '#ececf1', textMuted: '#8a8a92', textSubtle: '#5c5c65',
|
||||
accent: '#4a9eff', accentHover: '#6aafff', accentText: '#ffffff',
|
||||
icon: '#7a7a84', iconHover: '#b0b0ba',
|
||||
surface: '#222228', surface2: '#2a2a30',
|
||||
online: '#34d399', away: '#fbbf24', offline: '#6b7280',
|
||||
mentionBg: 'rgba(74,158,255,0.12)', mentionText: '#4a9eff',
|
||||
msgBg: '#1e1e23', msgOwnBg: '#1a2a3a', panelBg: '#161619',
|
||||
badgeAi: '', badgeRole: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
bg: '#ffffff', bgSubtle: '#f9f9fa', bgHover: '#f3f3f5', bgActive: '#ebebef',
|
||||
border: '#e4e4e8', borderFocus: '#1c7ded', borderMuted: '#eeeeef',
|
||||
text: '#1f1f1f', textMuted: '#8a8a8f', textSubtle: '#b8b8bd',
|
||||
accent: '#1c7ded', accentHover: '#1a73d4', accentText: '#ffffff',
|
||||
icon: '#8a8a8f', iconHover: '#5c5c62',
|
||||
surface: '#f7f7f8', surface2: '#eeeeef',
|
||||
online: '#22c55e', away: '#f59e0b', offline: '#d1d1d6',
|
||||
mentionBg: 'rgba(28,125,237,0.08)', mentionText: '#1c7ded',
|
||||
msgBg: '#f9f9fb', msgOwnBg: '#e8f0fe', panelBg: '#f9f9fa',
|
||||
badgeAi: '', badgeRole: '',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Token Editor ─────────────────────────────────────────────────────────────
|
||||
|
||||
function TokenEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: PaletteEntry;
|
||||
onChange: (v: PaletteEntry) => void;
|
||||
}) {
|
||||
const groups = (['surface', 'text', 'accent', 'border', 'status', 'message'] as const);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{groups.map((group) => {
|
||||
const defs = TOKEN_DEFS.filter((d) => d.group === group);
|
||||
return (
|
||||
<div key={group}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
{GROUP_LABELS[group]}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
{defs.map((def) => (
|
||||
<div key={def.key} className="flex items-center gap-2">
|
||||
{/* Swatch + native color picker */}
|
||||
<div className="relative shrink-0">
|
||||
<div
|
||||
className="h-7 w-7 rounded border cursor-pointer overflow-hidden"
|
||||
style={{ background: value[def.key] as string }}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={(value[def.key] as string).startsWith('#')
|
||||
? (value[def.key] as string)
|
||||
: '#888888'}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, [def.key]: e.target.value })
|
||||
}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
||||
title={def.label}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate">{def.label}</p>
|
||||
<input
|
||||
type="text"
|
||||
value={value[def.key] as string}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, [def.key]: e.target.value })
|
||||
}
|
||||
className="w-full bg-transparent border-0 p-0 text-[10px] text-muted-foreground focus:outline-none focus:ring-0 font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
interface ThemeSwitcherProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ThemeSwitcher({ open, onOpenChange }: ThemeSwitcherProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const [activePresetId, setActivePresetId] = useState<ThemePresetId>(loadActivePresetId);
|
||||
const [customPalette, setCustomPalette] = useState<PaletteEntry | null>(
|
||||
loadCustomPalette,
|
||||
);
|
||||
// Working copy being edited
|
||||
const [draft, setDraft] = useState<PaletteEntry | null>(null);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
// Reset when panel opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const id = loadActivePresetId();
|
||||
setActivePresetId(id);
|
||||
setCustomPalette(loadCustomPalette());
|
||||
setDraft(id === 'custom' && loadCustomPalette() ? { ...loadCustomPalette()! } : null);
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(presetId: ThemePresetId) => {
|
||||
setActivePresetId(presetId);
|
||||
saveActivePresetId(presetId);
|
||||
if (presetId === 'custom') {
|
||||
const stored = loadCustomPalette();
|
||||
setCustomPalette(stored);
|
||||
setDraft(stored ? { ...stored } : null);
|
||||
if (stored) applyPaletteToDOM(stored);
|
||||
} else {
|
||||
clearCustomPalette();
|
||||
setCustomPalette(null);
|
||||
setDraft(null);
|
||||
resetDOMFromPalette();
|
||||
}
|
||||
setIsDirty(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDraftChange = useCallback((next: PaletteEntry) => {
|
||||
setDraft(next);
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleApplyCustom = useCallback(() => {
|
||||
if (!draft) return;
|
||||
saveCustomPalette(draft);
|
||||
setCustomPalette(draft);
|
||||
applyPaletteToDOM(draft);
|
||||
setActivePresetId('custom');
|
||||
saveActivePresetId('custom');
|
||||
setIsDirty(false);
|
||||
}, [draft]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
applyPreset('default');
|
||||
}, [applyPreset]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex flex-col overflow-y-auto w-[360px] sm:max-w-[360px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Sliders className="h-4 w-4" />
|
||||
Theme Settings
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{/* ── Scrollable content with padding ─────────────────────────────── */}
|
||||
<div className="flex flex-col gap-5 px-5 pb-5 overflow-y-auto">
|
||||
|
||||
{/* ── Preset grid ─────────────────────────────────────────────────── */}
|
||||
<div className="mt-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">
|
||||
Presets
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{THEME_PRESETS.map((preset) => (
|
||||
<PresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
active={activePresetId === preset.id}
|
||||
onClick={() => applyPreset(preset.id as ThemePresetId)}
|
||||
/>
|
||||
))}
|
||||
{/* Custom preset card — always shown */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Switch to custom, seed draft from current effective palette
|
||||
const seed = activePresetId === 'custom' && customPalette
|
||||
? { ...customPalette }
|
||||
: { ...buildDefaultPreview(resolvedTheme) };
|
||||
setDraft(seed);
|
||||
setActivePresetId('custom');
|
||||
setIsDirty(false);
|
||||
if (activePresetId !== 'custom') {
|
||||
saveActivePresetId('custom');
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex flex-col gap-2 rounded-lg border p-3 text-left transition-all',
|
||||
'hover:border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
activePresetId === 'custom'
|
||||
? 'border-primary ring-1 ring-primary'
|
||||
: 'border-border border-dashed',
|
||||
)}
|
||||
>
|
||||
{/* Mini palette swatches */}
|
||||
<div className="flex gap-1">
|
||||
{['#ffffff', '#f9f9fa', '#e4e4e8', '#1f1f1f', '#8a8a8f', '#1c7ded', '#ffffff'].map(
|
||||
(c, i) => (
|
||||
<div key={i} className="h-6 flex-1 rounded-sm" style={{ background: c }} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">Custom</span>
|
||||
{activePresetId === 'custom' && (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||
Define your own colors
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Custom token editor ─────────────────────────────────────────── */}
|
||||
{activePresetId === 'custom' && draft && (
|
||||
<div className="border-t pt-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Token Editor
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={handleReset}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={handleApplyCustom}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TokenEditor value={draft} onChange={handleDraftChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user