diff --git a/src/tools/devtools.rs b/src/tools/devtools.rs new file mode 100644 index 0000000..46f72f2 --- /dev/null +++ b/src/tools/devtools.rs @@ -0,0 +1,325 @@ +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::context::{self, ResponseContext}; +use crate::sdk::gitea::GiteaClient; + +/// Execute a Gitea tool call. Returns a JSON string result. +pub async fn execute( + gitea: &Arc, + name: &str, + arguments: &str, + response_ctx: &ResponseContext, +) -> anyhow::Result { + let args: Value = serde_json::from_str(arguments) + .map_err(|e| anyhow::anyhow!("Invalid tool arguments: {e}"))?; + let localpart = context::localpart(&response_ctx.matrix_user_id); + + match name { + "gitea_list_repos" => { + let query = args["query"].as_str(); + let org = args["org"].as_str(); + let limit = args["limit"].as_u64().map(|n| n as u32); + + match gitea.list_repos(localpart, query, org, limit).await { + Ok(repos) => Ok(serde_json::to_string(&repos).unwrap_or_default()), + Err(e) => Ok(json!({"error": e}).to_string()), + } + } + "gitea_get_repo" => { + let owner = args["owner"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?; + let repo = args["repo"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?; + + match gitea.get_repo(localpart, owner, repo).await { + Ok(r) => Ok(serde_json::to_string(&r).unwrap_or_default()), + Err(e) => Ok(json!({"error": e}).to_string()), + } + } + "gitea_list_issues" => { + let owner = args["owner"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?; + let repo = args["repo"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?; + let state = args["state"].as_str(); + let labels = args["labels"].as_str(); + let limit = args["limit"].as_u64().map(|n| n as u32); + + match gitea + .list_issues(localpart, owner, repo, state, labels, limit) + .await + { + Ok(issues) => Ok(serde_json::to_string(&issues).unwrap_or_default()), + Err(e) => Ok(json!({"error": e}).to_string()), + } + } + "gitea_get_issue" => { + let owner = args["owner"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?; + let repo = args["repo"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?; + let number = args["number"] + .as_u64() + .ok_or_else(|| anyhow::anyhow!("missing 'number'"))?; + + match gitea.get_issue(localpart, owner, repo, number).await { + Ok(issue) => Ok(serde_json::to_string(&issue).unwrap_or_default()), + Err(e) => Ok(json!({"error": e}).to_string()), + } + } + "gitea_create_issue" => { + let owner = args["owner"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?; + let repo = args["repo"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?; + let title = args["title"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing 'title'"))?; + let body = args["body"].as_str(); + let labels: Option> = args["labels"] + .as_array() + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()); + + match gitea + .create_issue(localpart, owner, repo, title, body, labels.as_deref()) + .await + { + Ok(issue) => Ok(serde_json::to_string(&issue).unwrap_or_default()), + Err(e) => Ok(json!({"error": e}).to_string()), + } + } + "gitea_list_pulls" => { + let owner = args["owner"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?; + let repo = args["repo"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?; + let state = args["state"].as_str(); + let limit = args["limit"].as_u64().map(|n| n as u32); + + match gitea + .list_pulls(localpart, owner, repo, state, limit) + .await + { + Ok(prs) => Ok(serde_json::to_string(&prs).unwrap_or_default()), + Err(e) => Ok(json!({"error": e}).to_string()), + } + } + "gitea_get_file" => { + let owner = args["owner"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?; + let repo = args["repo"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?; + let path = args["path"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing 'path'"))?; + let git_ref = args["ref"].as_str(); + + match gitea + .get_file(localpart, owner, repo, path, git_ref) + .await + { + Ok(file) => Ok(serde_json::to_string(&file).unwrap_or_default()), + Err(e) => Ok(json!({"error": e}).to_string()), + } + } + _ => anyhow::bail!("Unknown devtools tool: {name}"), + } +} + +/// Return Mistral tool definitions for Gitea tools. +pub fn tool_definitions() -> Vec { + use mistralai_client::v1::tool::Tool; + + vec![ + Tool::new( + "gitea_list_repos".into(), + "List repositories on Gitea. Can search by name or filter by organization.".into(), + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query to filter repos by name" + }, + "org": { + "type": "string", + "description": "Organization name to list repos from" + }, + "limit": { + "type": "integer", + "description": "Max results (default 20)" + } + } + }), + ), + Tool::new( + "gitea_get_repo".into(), + "Get details about a specific repository.".into(), + json!({ + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner (user or org)" + }, + "repo": { + "type": "string", + "description": "Repository name" + } + }, + "required": ["owner", "repo"] + }), + ), + Tool::new( + "gitea_list_issues".into(), + "List issues in a repository. Filter by state (open/closed) or labels.".into(), + json!({ + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "state": { + "type": "string", + "description": "Filter by state: open, closed, or all (default: open)" + }, + "labels": { + "type": "string", + "description": "Comma-separated label names to filter by" + }, + "limit": { + "type": "integer", + "description": "Max results (default 20)" + } + }, + "required": ["owner", "repo"] + }), + ), + Tool::new( + "gitea_get_issue".into(), + "Get details of a specific issue by number.".into(), + json!({ + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "number": { + "type": "integer", + "description": "Issue number" + } + }, + "required": ["owner", "repo", "number"] + }), + ), + Tool::new( + "gitea_create_issue".into(), + "Create a new issue in a repository. The issue will be authored by the \ + person who asked (not the bot)." + .into(), + json!({ + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "title": { + "type": "string", + "description": "Issue title" + }, + "body": { + "type": "string", + "description": "Issue body (markdown)" + }, + "labels": { + "type": "array", + "items": { "type": "string" }, + "description": "Label IDs to apply" + } + }, + "required": ["owner", "repo", "title"] + }), + ), + Tool::new( + "gitea_list_pulls".into(), + "List pull requests in a repository.".into(), + json!({ + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "state": { + "type": "string", + "description": "Filter by state: open, closed, or all (default: open)" + }, + "limit": { + "type": "integer", + "description": "Max results (default 20)" + } + }, + "required": ["owner", "repo"] + }), + ), + Tool::new( + "gitea_get_file".into(), + "Get the contents of a file from a repository.".into(), + json!({ + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "path": { + "type": "string", + "description": "File path within the repository" + }, + "ref": { + "type": "string", + "description": "Branch, tag, or commit SHA (default: default branch)" + } + }, + "required": ["owner", "repo", "path"] + }), + ), + ] +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 6bb3351..cee6c05 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -1,4 +1,5 @@ pub mod bridge; +pub mod devtools; pub mod room_history; pub mod room_info; pub mod script; @@ -13,24 +14,36 @@ use serde_json::json; use crate::config::Config; use crate::context::ResponseContext; +use crate::sdk::gitea::GiteaClient; pub struct ToolRegistry { opensearch: OpenSearch, matrix: MatrixClient, config: Arc, + gitea: Option>, } impl ToolRegistry { - pub fn new(opensearch: OpenSearch, matrix: MatrixClient, config: Arc) -> Self { + pub fn new( + opensearch: OpenSearch, + matrix: MatrixClient, + config: Arc, + gitea: Option>, + ) -> Self { Self { opensearch, matrix, config, + gitea, } } - pub fn tool_definitions() -> Vec { - vec![ + pub fn has_gitea(&self) -> bool { + self.gitea.is_some() + } + + pub fn tool_definitions(gitea_enabled: bool) -> Vec { + let mut tools = vec![ Tool::new( "search_archive".into(), "Search the message archive. Use this to find past conversations, \ @@ -154,13 +167,19 @@ impl ToolRegistry { "required": ["code"] }), ), - ] + ]; + + if gitea_enabled { + tools.extend(devtools::tool_definitions()); + } + + tools } /// Convert Sol's tool definitions to Mistral AgentTool format /// for use with the Agents API (orchestrator agent creation). - pub fn agent_tool_definitions() -> Vec { - Self::tool_definitions() + pub fn agent_tool_definitions(gitea_enabled: bool) -> Vec { + Self::tool_definitions(gitea_enabled) .into_iter() .map(|t| { mistralai_client::v1::agents::AgentTool::function( @@ -207,6 +226,13 @@ impl ToolRegistry { ) .await } + name if name.starts_with("gitea_") => { + if let Some(ref gitea) = self.gitea { + devtools::execute(gitea, name, arguments, response_ctx).await + } else { + anyhow::bail!("Gitea integration not configured") + } + } _ => anyhow::bail!("Unknown tool: {name}"), } } diff --git a/src/tools/search.rs b/src/tools/search.rs index 8223bab..f761820 100644 --- a/src/tools/search.rs +++ b/src/tools/search.rs @@ -38,10 +38,10 @@ pub fn build_search_query(args: &SearchArgs) -> serde_json::Value { })]; if let Some(ref room) = args.room { - filter.push(json!({ "term": { "room_name.keyword": room } })); + filter.push(json!({ "term": { "room_name": room } })); } if let Some(ref sender) = args.sender { - filter.push(json!({ "term": { "sender_name.keyword": sender } })); + filter.push(json!({ "term": { "sender_name": sender } })); } let mut range = serde_json::Map::new(); @@ -189,7 +189,7 @@ mod tests { let filters = q["query"]["bool"]["filter"].as_array().unwrap(); assert_eq!(filters.len(), 2); - assert_eq!(filters[1]["term"]["room_name.keyword"], "design"); + assert_eq!(filters[1]["term"]["room_name"], "design"); } #[test] @@ -199,7 +199,7 @@ mod tests { let filters = q["query"]["bool"]["filter"].as_array().unwrap(); assert_eq!(filters.len(), 2); - assert_eq!(filters[1]["term"]["sender_name.keyword"], "Bob"); + assert_eq!(filters[1]["term"]["sender_name"], "Bob"); } #[test] @@ -209,8 +209,8 @@ mod tests { let filters = q["query"]["bool"]["filter"].as_array().unwrap(); assert_eq!(filters.len(), 3); - assert_eq!(filters[1]["term"]["room_name.keyword"], "dev"); - assert_eq!(filters[2]["term"]["sender_name.keyword"], "Carol"); + assert_eq!(filters[1]["term"]["room_name"], "dev"); + assert_eq!(filters[2]["term"]["sender_name"], "Carol"); } #[test] @@ -274,6 +274,30 @@ mod tests { assert_eq!(filters.len(), 1); } + #[test] + fn test_wildcard_query_uses_match_all() { + let args = parse_args(r#"{"query": "*"}"#); + let q = build_search_query(&args); + assert!(q["query"]["bool"]["must"][0]["match_all"].is_object()); + } + + #[test] + fn test_empty_query_uses_match_all() { + let args = parse_args(r#"{"query": ""}"#); + let q = build_search_query(&args); + assert!(q["query"]["bool"]["must"][0]["match_all"].is_object()); + } + + #[test] + fn test_room_filter_uses_keyword_field() { + // room_name is mapped as "keyword" in OpenSearch — no .keyword subfield + let args = parse_args(r#"{"query": "test", "room": "general"}"#); + let q = build_search_query(&args); + let filters = q["query"]["bool"]["filter"].as_array().unwrap(); + // Should be room_name, NOT room_name.keyword + assert_eq!(filters[1]["term"]["room_name"], "general"); + } + #[test] fn test_source_fields() { let args = parse_args(r#"{"query": "test"}"#);