Skip to main content

devboy_executor/
executor.rs

1use devboy_core::types::ChatType;
2use devboy_core::{
3    AddStructureRowsInput, CreateCommentInput, CreateIssueInput, CreateMergeRequestInput,
4    CreatePageParams, CreateStructureInput, Error, GetChatsParams, GetForestOptions,
5    GetMessagesParams, GetPipelineInput, GetStructureValuesInput, GetUsersOptions, IssueFilter,
6    IssueProvider, JobLogMode, JobLogOptions, KnowledgeBaseProvider, ListPagesParams,
7    ListProjectVersionsParams, MeetingFilter, MeetingNotesProvider, MergeRequestProvider,
8    MessengerProvider, MoveStructureRowsInput, MrFilter, PipelineProvider, Result,
9    SaveStructureViewInput, SearchKbParams, SearchMessagesParams, SendMessageParams,
10    StructureRowItem, StructureViewColumn, ToolCategory, UpdateIssueInput, UpdatePageParams,
11    UpsertProjectVersionInput,
12};
13use serde::Deserialize;
14use serde_json::Value;
15use tracing::debug;
16
17use crate::context::AdditionalContext;
18use crate::factory;
19use crate::output::{ResultMeta, ToolOutput};
20use devboy_core::ToolEnricher;
21
22/// Maximum file size for upload / download asset operations (10 MB).
23const MAX_FILE_SIZE: usize = 10 * 1024 * 1024;
24
25/// Parse `tools/call` args into a typed `Params` struct, turning
26/// deserialisation failures into `Error::InvalidData` so callers see
27/// the exact field that went wrong instead of the tool silently
28/// running with defaults (the previous `unwrap_or_default()` path).
29///
30/// `Value::Null` is accepted as "no arguments" and yields `T::default()`
31/// — the MCP spec allows a null `arguments` field for tools whose
32/// params are all optional, so rejecting null here would break those
33/// call sites. Actual content is validated by `serde_json::from_value`.
34fn parse_tool_params<T>(args: &Value, tool: &str) -> Result<T>
35where
36    T: Default + serde::de::DeserializeOwned,
37{
38    if args.is_null() {
39        return Ok(T::default());
40    }
41    serde_json::from_value(args.clone())
42        .map_err(|e| Error::InvalidData(format!("invalid {tool} params: {e}")))
43}
44
45/// Deserialize a value that can be either a string or a number into Option<String>.
46/// Enricher may transform priority "high" → 2 (number), but executor needs String.
47fn deserialize_string_or_number<'de, D>(
48    deserializer: D,
49) -> std::result::Result<Option<String>, D::Error>
50where
51    D: serde::Deserializer<'de>,
52{
53    let value: Option<Value> = Option::deserialize(deserializer)?;
54    Ok(value.map(|v| match v {
55        Value::String(s) => s,
56        Value::Number(n) => n.to_string(),
57        other => other.to_string(),
58    }))
59}
60
61/// Tool execution engine.
62///
63/// Manages enrichers and dispatches tool calls to providers.
64/// Stateless per call — provider is created from `AdditionalContext` each time.
65pub struct Executor {
66    enrichers: Vec<Box<dyn ToolEnricher>>,
67    asset_manager: Option<devboy_assets::AssetManager>,
68}
69
70impl Executor {
71    pub fn new() -> Self {
72        Self {
73            enrichers: Vec::new(),
74            asset_manager: None,
75        }
76    }
77
78    /// Configure an optional local asset cache for download/delete operations.
79    pub fn with_asset_manager(mut self, mgr: devboy_assets::AssetManager) -> Self {
80        self.asset_manager = Some(mgr);
81        self
82    }
83
84    /// Register an enricher (provider, pipeline, or custom).
85    /// Enrichers are applied in registration order.
86    pub fn add_enricher(&mut self, enricher: Box<dyn ToolEnricher>) {
87        self.enrichers.push(enricher);
88    }
89
90    /// List available tools with enriched schemas.
91    ///
92    /// 1. Starts with base tool definitions
93    /// 2. Keeps only tools whose category is supported by at least one enricher
94    /// 3. Applies schema enrichment from enrichers that support each tool's category
95    pub fn list_tools(&self) -> Vec<crate::tools::ToolDefinition> {
96        let mut tools = crate::tools::base_tool_definitions();
97
98        // Collect all supported categories from enrichers
99        let supported_categories: std::collections::HashSet<devboy_core::ToolCategory> = self
100            .enrichers
101            .iter()
102            .flat_map(|e| e.supported_categories().iter().copied())
103            .collect();
104
105        // Keep only tools whose category is supported
106        tools.retain(|t| supported_categories.contains(&t.category));
107
108        // Apply schema enrichment from enrichers that support each tool's category
109        for enricher in &self.enrichers {
110            let cats = enricher.supported_categories();
111            for tool in &mut tools {
112                if cats.contains(&tool.category) {
113                    enricher.enrich_schema(&tool.name, &mut tool.input_schema);
114                }
115            }
116        }
117
118        tools
119    }
120
121    /// Execute a tool with the given arguments and context.
122    ///
123    /// Flow:
124    /// 1. Pre-execute: enrichers transform args
125    /// 2. Create provider from context (cheap, stack-allocated)
126    /// 3. Dispatch tool call to provider method
127    /// 4. Post-execute: enrichers transform output
128    /// 5. Return typed ToolOutput
129    pub async fn execute(
130        &self,
131        tool: &str,
132        args: Value,
133        ctx: &AdditionalContext,
134    ) -> Result<ToolOutput> {
135        let mut args = args;
136
137        // Pre-execute: enrichers transform args
138        // Look up tool category from base definitions for matching
139        let tool_category = crate::tools::base_tool_definitions()
140            .iter()
141            .find(|t| t.name == tool)
142            .map(|t| t.category);
143        for enricher in &self.enrichers {
144            if let Some(cat) = tool_category
145                && enricher.supported_categories().contains(&cat)
146            {
147                enricher.transform_args(tool, &mut args);
148            }
149        }
150
151        debug!(
152            tool = tool,
153            provider = ctx.provider.provider_name(),
154            "executing tool"
155        );
156
157        // Dispatch based on tool category
158        let output = if tool_category == Some(ToolCategory::MeetingNotes) {
159            let provider = factory::create_meeting_notes_provider(&ctx.provider)?;
160            dispatch_meeting_tool(tool, &args, provider.as_ref()).await?
161        } else if tool_category == Some(ToolCategory::KnowledgeBase) {
162            let provider =
163                factory::create_knowledge_base_provider(&ctx.provider, ctx.proxy.as_ref())?;
164            dispatch_knowledge_base_tool(tool, &args, provider.as_ref()).await?
165        } else if tool_category == Some(ToolCategory::Messenger) {
166            let provider = factory::create_messenger_provider(&ctx.provider)?;
167            dispatch_messenger_tool(tool, &args, provider.as_ref()).await?
168        } else {
169            let provider = factory::create_provider(&ctx.provider, ctx.proxy.as_ref())?;
170            dispatch_tool(tool, &args, provider.as_ref(), self.asset_manager.as_ref()).await?
171        };
172
173        Ok(output)
174    }
175
176    /// Execute a tool with a pre-created Provider (for MCP server).
177    /// Enrichers are applied if configured.
178    pub async fn execute_direct(
179        &self,
180        tool: &str,
181        args: Value,
182        provider: &dyn devboy_core::Provider,
183    ) -> Result<ToolOutput> {
184        let mut args = args;
185        // Apply enricher transforms (same as execute())
186        let tool_category = Self::tool_category(tool);
187        for enricher in &self.enrichers {
188            if let Some(cat) = tool_category
189                && enricher.supported_categories().contains(&cat)
190            {
191                enricher.transform_args(tool, &mut args);
192            }
193        }
194        dispatch_tool(tool, &args, provider, self.asset_manager.as_ref()).await
195    }
196
197    /// Execute a meeting tool with a pre-created MeetingNotesProvider.
198    pub async fn execute_direct_meeting(
199        &self,
200        tool: &str,
201        args: Value,
202        provider: &dyn MeetingNotesProvider,
203    ) -> Result<ToolOutput> {
204        let mut args = args;
205        let tool_category = Self::tool_category(tool);
206        for enricher in &self.enrichers {
207            if let Some(cat) = tool_category
208                && enricher.supported_categories().contains(&cat)
209            {
210                enricher.transform_args(tool, &mut args);
211            }
212        }
213        dispatch_meeting_tool(tool, &args, provider).await
214    }
215
216    /// Execute a knowledge base tool with a pre-created KnowledgeBaseProvider.
217    pub async fn execute_direct_knowledge_base(
218        &self,
219        tool: &str,
220        args: Value,
221        provider: &dyn KnowledgeBaseProvider,
222    ) -> Result<ToolOutput> {
223        let mut args = args;
224        let tool_category = Self::tool_category(tool);
225        for enricher in &self.enrichers {
226            if let Some(cat) = tool_category
227                && enricher.supported_categories().contains(&cat)
228            {
229                enricher.transform_args(tool, &mut args);
230            }
231        }
232        dispatch_knowledge_base_tool(tool, &args, provider).await
233    }
234
235    /// Execute a messenger tool with a pre-created MessengerProvider.
236    pub async fn execute_direct_messenger(
237        &self,
238        tool: &str,
239        args: Value,
240        provider: &dyn MessengerProvider,
241    ) -> Result<ToolOutput> {
242        let mut args = args;
243        let tool_category = Self::tool_category(tool);
244        for enricher in &self.enrichers {
245            if let Some(cat) = tool_category
246                && enricher.supported_categories().contains(&cat)
247            {
248                enricher.transform_args(tool, &mut args);
249            }
250        }
251        dispatch_messenger_tool(tool, &args, provider).await
252    }
253
254    /// Get the tool category for a tool name, if known.
255    pub fn tool_category(tool: &str) -> Option<ToolCategory> {
256        crate::tools::base_tool_definitions()
257            .iter()
258            .find(|t| t.name == tool)
259            .map(|t| t.category)
260    }
261}
262
263impl Default for Executor {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269// --- Knowledge base tool dispatch ---
270
271/// Dispatch a knowledge base tool call.
272async fn dispatch_knowledge_base_tool(
273    tool: &str,
274    args: &Value,
275    provider: &dyn KnowledgeBaseProvider,
276) -> Result<ToolOutput> {
277    match tool {
278        "get_knowledge_base_spaces" => execute_get_knowledge_base_spaces(provider).await,
279        "list_knowledge_base_pages" => execute_list_knowledge_base_pages(provider, args).await,
280        "get_knowledge_base_page" => execute_get_knowledge_base_page(provider, args).await,
281        "create_knowledge_base_page" => execute_create_knowledge_base_page(provider, args).await,
282        "update_knowledge_base_page" => execute_update_knowledge_base_page(provider, args).await,
283        "search_knowledge_base" => execute_search_knowledge_base(provider, args).await,
284        _ => Err(Error::NotFound(format!(
285            "unknown knowledge base tool: {tool}"
286        ))),
287    }
288}
289
290// --- Knowledge base tool handlers ---
291
292async fn execute_get_knowledge_base_spaces(
293    provider: &dyn KnowledgeBaseProvider,
294) -> Result<ToolOutput> {
295    let result = provider.get_spaces().await?;
296    let meta = ResultMeta {
297        pagination: result.pagination,
298        sort_info: result.sort_info,
299    };
300    Ok(ToolOutput::KnowledgeBaseSpaces(result.items, Some(meta)))
301}
302
303#[derive(Deserialize)]
304#[serde(rename_all = "camelCase")]
305struct ListKnowledgeBasePagesParams {
306    space_key: String,
307    limit: Option<u32>,
308    offset: Option<u32>,
309    cursor: Option<String>,
310    search: Option<String>,
311    parent_id: Option<String>,
312}
313
314async fn execute_list_knowledge_base_pages(
315    provider: &dyn KnowledgeBaseProvider,
316    args: &Value,
317) -> Result<ToolOutput> {
318    let params: ListKnowledgeBasePagesParams =
319        serde_json::from_value(args.clone()).map_err(|e| {
320            Error::InvalidData(format!("invalid list_knowledge_base_pages params: {e}"))
321        })?;
322    let result = provider
323        .list_pages(ListPagesParams {
324            space_key: params.space_key,
325            limit: params.limit,
326            offset: params.offset,
327            cursor: params.cursor,
328            search: params.search,
329            parent_id: params.parent_id,
330        })
331        .await?;
332    let meta = ResultMeta {
333        pagination: result.pagination,
334        sort_info: result.sort_info,
335    };
336    Ok(ToolOutput::KnowledgeBasePages(result.items, Some(meta)))
337}
338
339#[derive(Deserialize)]
340#[serde(rename_all = "camelCase")]
341struct GetKnowledgeBasePageParams {
342    page_id: String,
343}
344
345async fn execute_get_knowledge_base_page(
346    provider: &dyn KnowledgeBaseProvider,
347    args: &Value,
348) -> Result<ToolOutput> {
349    let params: GetKnowledgeBasePageParams = serde_json::from_value(args.clone())
350        .map_err(|e| Error::InvalidData(format!("invalid get_knowledge_base_page params: {e}")))?;
351    let page = provider.get_page(&params.page_id).await?;
352    Ok(ToolOutput::KnowledgeBasePage(Box::new(page)))
353}
354
355#[derive(Deserialize)]
356#[serde(rename_all = "camelCase")]
357struct CreateKnowledgeBasePageParams {
358    space_key: String,
359    title: String,
360    content: String,
361    #[serde(default)]
362    content_type: Option<String>,
363    parent_id: Option<String>,
364    #[serde(default)]
365    labels: Vec<String>,
366}
367
368async fn execute_create_knowledge_base_page(
369    provider: &dyn KnowledgeBaseProvider,
370    args: &Value,
371) -> Result<ToolOutput> {
372    let params: CreateKnowledgeBasePageParams =
373        serde_json::from_value(args.clone()).map_err(|e| {
374            Error::InvalidData(format!("invalid create_knowledge_base_page params: {e}"))
375        })?;
376    let page = provider
377        .create_page(CreatePageParams {
378            space_key: params.space_key,
379            title: params.title,
380            content: params.content,
381            content_type: params.content_type,
382            parent_id: params.parent_id,
383            labels: params.labels,
384        })
385        .await?;
386    Ok(ToolOutput::KnowledgeBasePageSummary(Box::new(page)))
387}
388
389#[derive(Deserialize)]
390#[serde(rename_all = "camelCase")]
391struct UpdateKnowledgeBasePageParams {
392    page_id: String,
393    #[serde(default)]
394    title: Option<String>,
395    #[serde(default)]
396    content: Option<String>,
397    #[serde(default)]
398    content_type: Option<String>,
399    version: Option<u32>,
400    #[serde(default)]
401    labels: Option<Vec<String>>,
402    parent_id: Option<String>,
403}
404
405async fn execute_update_knowledge_base_page(
406    provider: &dyn KnowledgeBaseProvider,
407    args: &Value,
408) -> Result<ToolOutput> {
409    let params: UpdateKnowledgeBasePageParams =
410        serde_json::from_value(args.clone()).map_err(|e| {
411            Error::InvalidData(format!("invalid update_knowledge_base_page params: {e}"))
412        })?;
413    let page = provider
414        .update_page(UpdatePageParams {
415            page_id: params.page_id,
416            title: params.title,
417            content: params.content,
418            content_type: params.content_type,
419            version: params.version,
420            labels: params.labels,
421            parent_id: params.parent_id,
422        })
423        .await?;
424    Ok(ToolOutput::KnowledgeBasePageSummary(Box::new(page)))
425}
426
427#[derive(Deserialize)]
428#[serde(rename_all = "camelCase")]
429struct SearchKnowledgeBaseParams {
430    query: String,
431    space_key: Option<String>,
432    cursor: Option<String>,
433    limit: Option<u32>,
434    #[serde(default)]
435    raw_query: bool,
436}
437
438async fn execute_search_knowledge_base(
439    provider: &dyn KnowledgeBaseProvider,
440    args: &Value,
441) -> Result<ToolOutput> {
442    let params: SearchKnowledgeBaseParams = serde_json::from_value(args.clone())
443        .map_err(|e| Error::InvalidData(format!("invalid search_knowledge_base params: {e}")))?;
444    let result = provider
445        .search(SearchKbParams {
446            query: params.query,
447            space_key: params.space_key,
448            cursor: params.cursor,
449            limit: params.limit,
450            raw_query: params.raw_query,
451        })
452        .await?;
453    let meta = ResultMeta {
454        pagination: result.pagination,
455        sort_info: result.sort_info,
456    };
457    Ok(ToolOutput::KnowledgeBasePages(result.items, Some(meta)))
458}
459
460// --- Messenger tool dispatch ---
461
462/// Dispatch a messenger tool call.
463async fn dispatch_messenger_tool(
464    tool: &str,
465    args: &Value,
466    provider: &dyn MessengerProvider,
467) -> Result<ToolOutput> {
468    match tool {
469        "get_messenger_chats" => execute_get_messenger_chats(provider, args).await,
470        "get_chat_messages" => execute_get_chat_messages(provider, args).await,
471        "search_chat_messages" => execute_search_chat_messages(provider, args).await,
472        "send_message" => execute_send_message(provider, args).await,
473        _ => Err(Error::NotFound(format!("unknown messenger tool: {tool}"))),
474    }
475}
476
477// --- Messenger tool handlers ---
478
479#[derive(Deserialize, Default)]
480struct GetMessengerChatsParams {
481    search: Option<String>,
482    chat_type: Option<ChatType>,
483    limit: Option<u32>,
484    cursor: Option<String>,
485    include_inactive: Option<bool>,
486}
487
488async fn execute_get_messenger_chats(
489    provider: &dyn MessengerProvider,
490    args: &Value,
491) -> Result<ToolOutput> {
492    let params: GetMessengerChatsParams = parse_tool_params(args, "get_messenger_chats")?;
493    let request = GetChatsParams {
494        search: params.search,
495        chat_type: params.chat_type,
496        limit: params.limit,
497        cursor: params.cursor,
498        include_inactive: params.include_inactive,
499    };
500    let result = provider.get_chats(request).await?;
501    let meta = ResultMeta {
502        pagination: result.pagination,
503        sort_info: result.sort_info,
504    };
505    Ok(ToolOutput::MessengerChats(result.items, Some(meta)))
506}
507
508#[derive(Deserialize)]
509struct GetChatMessagesParams {
510    chat_id: String,
511    limit: Option<u32>,
512    cursor: Option<String>,
513    thread_id: Option<String>,
514    since: Option<String>,
515    until: Option<String>,
516}
517
518async fn execute_get_chat_messages(
519    provider: &dyn MessengerProvider,
520    args: &Value,
521) -> Result<ToolOutput> {
522    let params: GetChatMessagesParams = serde_json::from_value(args.clone())
523        .map_err(|e| Error::InvalidData(format!("missing 'chat_id' parameter: {e}")))?;
524    let request = GetMessagesParams {
525        chat_id: params.chat_id,
526        limit: params.limit,
527        cursor: params.cursor,
528        thread_id: params.thread_id,
529        since: params.since,
530        until: params.until,
531    };
532    let result = provider.get_messages(request).await?;
533    let meta = ResultMeta {
534        pagination: result.pagination,
535        sort_info: result.sort_info,
536    };
537    Ok(ToolOutput::MessengerMessages(result.items, Some(meta)))
538}
539
540#[derive(Deserialize)]
541struct SearchChatMessagesParams {
542    query: String,
543    chat_id: Option<String>,
544    limit: Option<u32>,
545    cursor: Option<String>,
546    since: Option<String>,
547    until: Option<String>,
548}
549
550async fn execute_search_chat_messages(
551    provider: &dyn MessengerProvider,
552    args: &Value,
553) -> Result<ToolOutput> {
554    let params: SearchChatMessagesParams = serde_json::from_value(args.clone())
555        .map_err(|e| Error::InvalidData(format!("missing 'query' parameter: {e}")))?;
556    let request = SearchMessagesParams {
557        query: params.query,
558        chat_id: params.chat_id,
559        limit: params.limit,
560        cursor: params.cursor,
561        since: params.since,
562        until: params.until,
563    };
564    let result = provider.search_messages(request).await?;
565    let meta = ResultMeta {
566        pagination: result.pagination,
567        sort_info: result.sort_info,
568    };
569    Ok(ToolOutput::MessengerMessages(result.items, Some(meta)))
570}
571
572#[derive(Deserialize)]
573struct SendMessengerMessageParams {
574    chat_id: String,
575    text: String,
576    thread_id: Option<String>,
577    reply_to_id: Option<String>,
578}
579
580async fn execute_send_message(
581    provider: &dyn MessengerProvider,
582    args: &Value,
583) -> Result<ToolOutput> {
584    let params: SendMessengerMessageParams = serde_json::from_value(args.clone())
585        .map_err(|e| Error::InvalidData(format!("invalid send_message params: {e}")))?;
586    let request = SendMessageParams {
587        chat_id: params.chat_id,
588        text: params.text,
589        thread_id: params.thread_id,
590        reply_to_id: params.reply_to_id,
591        attachments: vec![],
592    };
593    let message = provider.send_message(request).await?;
594    Ok(ToolOutput::SingleMessage(Box::new(message)))
595}
596
597// --- Tool dispatch ---
598
599/// Dispatch a tool call to the appropriate provider method.
600async fn dispatch_tool(
601    tool: &str,
602    args: &Value,
603    provider: &dyn devboy_core::Provider,
604    asset_manager: Option<&devboy_assets::AssetManager>,
605) -> Result<ToolOutput> {
606    match tool {
607        // Issue tools
608        "get_issues" => execute_get_issues(provider, args).await,
609        "get_issue" => execute_get_issue(provider, args).await,
610        "get_issue_comments" => execute_get_issue_comments(provider, args).await,
611        "get_issue_relations" => execute_get_issue_relations(provider, args).await,
612        "create_issue" => execute_create_issue(provider, args).await,
613        "update_issue" => execute_update_issue(provider, args).await,
614        "add_issue_comment" => execute_add_issue_comment(provider, args).await,
615
616        // Merge request tools
617        "get_merge_requests" => execute_get_merge_requests(provider, args).await,
618        "get_merge_request" => execute_get_merge_request(provider, args).await,
619        "get_merge_request_discussions" => {
620            execute_get_merge_request_discussions(provider, args).await
621        }
622        "get_merge_request_diffs" => execute_get_merge_request_diffs(provider, args).await,
623        "create_merge_request" => execute_create_merge_request(provider, args).await,
624        "create_merge_request_comment" => {
625            execute_create_merge_request_comment(provider, args).await
626        }
627
628        // Pipeline tools
629        "get_pipeline" => execute_get_pipeline(provider, args).await,
630        "get_job_logs" => execute_get_job_logs(provider, args).await,
631
632        // Status / user / link tools
633        "get_available_statuses" => execute_get_available_statuses(provider).await,
634        "get_users" => execute_get_users(provider, args).await,
635        "link_issues" => execute_link_issues(provider, args).await,
636        "unlink_issues" => execute_unlink_issues(provider, args).await,
637
638        // Epic tools (issue-based with "epic" label convention)
639        "get_epics" => execute_get_epics(provider, args).await,
640        "create_epic" => execute_create_epic(provider, args).await,
641        "update_epic" => execute_update_epic(provider, args).await,
642
643        // MR update
644        "update_merge_request" => execute_update_merge_request(provider, args).await,
645
646        // Asset tools
647        "get_assets" => execute_get_assets(provider, args).await,
648        "upload_asset" => execute_upload_asset(provider, args).await,
649        "download_asset" => execute_download_asset(provider, args, asset_manager).await,
650        "delete_asset" => execute_delete_asset(provider, args, asset_manager).await,
651
652        // Jira Structure tools
653        "get_structures" => execute_get_structures(provider).await,
654        "get_structure_forest" => execute_get_structure_forest(provider, args).await,
655        "add_structure_rows" => execute_add_structure_rows(provider, args).await,
656        "move_structure_rows" => execute_move_structure_rows(provider, args).await,
657        "remove_structure_row" => execute_remove_structure_row(provider, args).await,
658        "get_structure_values" => execute_get_structure_values(provider, args).await,
659        "get_structure_views" => execute_get_structure_views(provider, args).await,
660        "save_structure_view" => execute_save_structure_view(provider, args).await,
661        "create_structure" => execute_create_structure(provider, args).await,
662
663        // Project versions / fixVersion (issue #238)
664        "list_project_versions" => execute_list_project_versions(provider, args).await,
665        "upsert_project_version" => execute_upsert_project_version(provider, args).await,
666
667        _ => Err(Error::NotFound(format!("unknown tool: {tool}"))),
668    }
669}
670
671/// Dispatch a meeting notes tool call.
672async fn dispatch_meeting_tool(
673    tool: &str,
674    args: &Value,
675    provider: &dyn MeetingNotesProvider,
676) -> Result<ToolOutput> {
677    match tool {
678        "get_meeting_notes" => execute_get_meeting_notes(provider, args).await,
679        "get_meeting_transcript" => execute_get_meeting_transcript(provider, args).await,
680        "search_meeting_notes" => execute_search_meeting_notes(provider, args).await,
681        _ => Err(Error::NotFound(format!("unknown meeting tool: {tool}"))),
682    }
683}
684
685// --- Meeting notes tool handlers ---
686
687#[derive(Deserialize, Default)]
688struct GetMeetingNotesParams {
689    from_date: Option<String>,
690    to_date: Option<String>,
691    participants: Option<Vec<String>>,
692    host_email: Option<String>,
693    limit: Option<u32>,
694    offset: Option<u32>,
695}
696
697async fn execute_get_meeting_notes(
698    provider: &dyn MeetingNotesProvider,
699    args: &Value,
700) -> Result<ToolOutput> {
701    let params: GetMeetingNotesParams = parse_tool_params(args, "get_meeting_notes")?;
702    let filter = MeetingFilter {
703        keyword: None,
704        from_date: params.from_date,
705        to_date: params.to_date,
706        participants: params.participants,
707        host_email: params.host_email,
708        limit: params.limit,
709        skip: params.offset,
710    };
711    let result = provider.get_meetings(filter).await?;
712    let meta = ResultMeta {
713        pagination: result.pagination,
714        sort_info: result.sort_info,
715    };
716    Ok(ToolOutput::MeetingNotes(result.items, Some(meta)))
717}
718
719#[derive(Deserialize)]
720struct GetMeetingTranscriptParams {
721    meeting_id: String,
722}
723
724async fn execute_get_meeting_transcript(
725    provider: &dyn MeetingNotesProvider,
726    args: &Value,
727) -> Result<ToolOutput> {
728    let params: GetMeetingTranscriptParams = serde_json::from_value(args.clone())
729        .map_err(|e| Error::InvalidData(format!("invalid params: {e}")))?;
730    let transcript = provider.get_transcript(&params.meeting_id).await?;
731    Ok(ToolOutput::MeetingTranscript(Box::new(transcript)))
732}
733
734#[derive(Deserialize)]
735struct SearchMeetingNotesParams {
736    query: String,
737    from_date: Option<String>,
738    to_date: Option<String>,
739    participants: Option<Vec<String>>,
740    host_email: Option<String>,
741    limit: Option<u32>,
742    offset: Option<u32>,
743}
744
745async fn execute_search_meeting_notes(
746    provider: &dyn MeetingNotesProvider,
747    args: &Value,
748) -> Result<ToolOutput> {
749    let params: SearchMeetingNotesParams = serde_json::from_value(args.clone())
750        .map_err(|e| Error::InvalidData(format!("invalid params: {e}")))?;
751    let filter = MeetingFilter {
752        keyword: None,
753        from_date: params.from_date,
754        to_date: params.to_date,
755        participants: params.participants,
756        host_email: params.host_email,
757        limit: params.limit,
758        skip: params.offset,
759    };
760    let result = provider.search_meetings(&params.query, filter).await?;
761    let meta = ResultMeta {
762        pagination: result.pagination,
763        sort_info: result.sort_info,
764    };
765    Ok(ToolOutput::MeetingNotes(result.items, Some(meta)))
766}
767
768// --- Issue tool handlers ---
769
770#[derive(Deserialize, Default)]
771struct GetIssuesParams {
772    state: Option<String>,
773    #[serde(rename = "stateCategory")]
774    state_category: Option<String>,
775    search: Option<String>,
776    labels: Option<Vec<String>>,
777    #[serde(rename = "labelsOperator")]
778    labels_operator: Option<String>,
779    assignee: Option<String>,
780    limit: Option<u32>,
781    offset: Option<u32>,
782    sort_by: Option<String>,
783    sort_order: Option<String>,
784    #[serde(rename = "projectKey")]
785    project_key: Option<String>,
786    #[serde(rename = "nativeQuery")]
787    native_query: Option<String>,
788    /// Token budget for response size control (consumed by format layer via execute_and_format).
789    #[allow(dead_code)]
790    budget: Option<usize>,
791}
792
793async fn execute_get_issues(
794    provider: &dyn devboy_core::Provider,
795    args: &Value,
796) -> Result<ToolOutput> {
797    let params: GetIssuesParams = parse_tool_params(args, "get_issues")?;
798    let filter = IssueFilter {
799        state: params.state,
800        state_category: params.state_category,
801        search: params.search,
802        labels: params.labels,
803        labels_operator: params.labels_operator,
804        assignee: params.assignee,
805        limit: params.limit.or(Some(20)),
806        offset: params.offset,
807        sort_by: params.sort_by,
808        sort_order: params.sort_order,
809        project_key: params.project_key,
810        native_query: params.native_query,
811    };
812    let result = provider.get_issues(filter).await?;
813    let meta = ResultMeta {
814        pagination: result.pagination,
815        sort_info: result.sort_info,
816    };
817    Ok(ToolOutput::Issues(result.items, Some(meta)))
818}
819
820#[derive(Deserialize)]
821struct KeyParam {
822    key: String,
823    /// Token budget for response size control (consumed by format layer via execute_and_format).
824    #[serde(default)]
825    #[allow(dead_code)]
826    budget: Option<usize>,
827}
828
829#[derive(Deserialize)]
830struct GetIssueParams {
831    key: String,
832    #[serde(default = "default_true", rename = "includeComments")]
833    include_comments: bool,
834    #[serde(default = "default_true", rename = "includeRelations")]
835    include_relations: bool,
836    #[serde(default)]
837    #[allow(dead_code)]
838    budget: Option<usize>,
839}
840
841fn default_true() -> bool {
842    true
843}
844
845async fn execute_get_issue(
846    provider: &dyn devboy_core::Provider,
847    args: &Value,
848) -> Result<ToolOutput> {
849    let params: GetIssueParams = serde_json::from_value(args.clone())
850        .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
851    let issue = provider.get_issue(&params.key).await?;
852
853    // If no extras requested, return just the issue
854    if !params.include_comments && !params.include_relations {
855        return Ok(ToolOutput::SingleIssue(Box::new(issue)));
856    }
857
858    // Build a composite JSON with issue + optional comments/relations
859    let mut result = serde_json::to_value(&issue).unwrap_or_default();
860    let mut has_extras = false;
861
862    if params.include_comments
863        && let Ok(comments_result) = provider.get_comments(&params.key).await
864    {
865        result["comments"] = serde_json::to_value(&comments_result.items).unwrap_or_default();
866        result["comments_count"] = serde_json::json!(comments_result.items.len());
867        has_extras = true;
868    }
869
870    if params.include_relations
871        && let Ok(relations) = provider.get_issue_relations(&params.key).await
872    {
873        result["relations"] = serde_json::to_value(&relations).unwrap_or_default();
874        if issue.subtasks.is_empty() && !relations.subtasks.is_empty() {
875            result["subtasks"] = serde_json::to_value(&relations.subtasks).unwrap_or_default();
876        }
877        result["subtasks_count"] =
878            serde_json::json!(issue.subtasks.len().max(relations.subtasks.len()));
879        has_extras = true;
880    }
881
882    // If no extras were actually fetched, return simple issue
883    if !has_extras {
884        return Ok(ToolOutput::SingleIssue(Box::new(issue)));
885    }
886
887    Ok(ToolOutput::Text(
888        serde_json::to_string_pretty(&result).unwrap_or_default(),
889    ))
890}
891
892async fn execute_get_issue_comments(
893    provider: &dyn devboy_core::Provider,
894    args: &Value,
895) -> Result<ToolOutput> {
896    let params: KeyParam = serde_json::from_value(args.clone())
897        .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
898    let result = provider.get_comments(&params.key).await?;
899    let meta = ResultMeta {
900        pagination: result.pagination,
901        sort_info: result.sort_info,
902    };
903    Ok(ToolOutput::Comments(result.items, Some(meta)))
904}
905
906async fn execute_get_issue_relations(
907    provider: &dyn devboy_core::Provider,
908    args: &Value,
909) -> Result<ToolOutput> {
910    let params: KeyParam = serde_json::from_value(args.clone())
911        .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
912    let relations = provider.get_issue_relations(&params.key).await?;
913    Ok(ToolOutput::Relations(Box::new(relations)))
914}
915
916#[derive(Deserialize)]
917struct CreateIssueParams {
918    title: String,
919    description: Option<String>,
920    #[serde(default)]
921    labels: Vec<String>,
922    #[serde(default)]
923    assignees: Vec<String>,
924    #[serde(default, deserialize_with = "deserialize_string_or_number")]
925    priority: Option<String>,
926    #[serde(alias = "parentId")]
927    parent: Option<String>,
928    markdown: Option<bool>,
929    #[serde(rename = "projectId")]
930    project_id: Option<String>,
931    #[serde(rename = "issueType")]
932    issue_type: Option<String>,
933    /// Jira component names (issue #197). Serde rejects non-string-array
934    /// input instead of silently dropping entries (Copilot review on PR #205).
935    #[serde(default)]
936    components: Vec<String>,
937}
938
939async fn execute_create_issue(
940    provider: &dyn devboy_core::Provider,
941    args: &Value,
942) -> Result<ToolOutput> {
943    let params: CreateIssueParams = serde_json::from_value(args.clone())
944        .map_err(|e| Error::InvalidData(format!("invalid create_issue params: {e}")))?;
945    let custom_fields = args.get("customFields").cloned();
946    let input = CreateIssueInput {
947        title: params.title,
948        description: params.description,
949        labels: params.labels,
950        assignees: params.assignees,
951        priority: params.priority,
952        parent: params.parent,
953        markdown: params.markdown.unwrap_or(true),
954        project_id: params.project_id,
955        issue_type: params.issue_type,
956        custom_fields,
957        components: params.components,
958    };
959    let issue = provider.create_issue(input).await?;
960
961    // Set custom fields via separate API call (ClickUp uses Array format)
962    if let Some(cf) = args.get("customFields").and_then(|v| v.as_array())
963        && !cf.is_empty()
964        && let Err(e) = provider.set_custom_fields(&issue.key, cf).await
965    {
966        tracing::warn!(error = %e, "Failed to set custom fields on created issue");
967    }
968
969    Ok(ToolOutput::SingleIssue(Box::new(issue)))
970}
971
972#[derive(Deserialize)]
973struct UpdateIssueParams {
974    key: String,
975    title: Option<String>,
976    description: Option<String>,
977    state: Option<String>,
978    labels: Option<Vec<String>>,
979    assignees: Option<Vec<String>>,
980    #[serde(default, deserialize_with = "deserialize_string_or_number")]
981    priority: Option<String>,
982    #[serde(rename = "parentId")]
983    parent_id: Option<String>,
984    markdown: Option<bool>,
985    /// Jira component names (issue #197). `None` (key absent) leaves
986    /// components untouched; `Some([])` clears all; `Some([...])` replaces.
987    /// Serde-parsed so non-array / non-string input errors fast.
988    #[serde(default)]
989    components: Option<Vec<String>>,
990}
991
992async fn execute_update_issue(
993    provider: &dyn devboy_core::Provider,
994    args: &Value,
995) -> Result<ToolOutput> {
996    let params: UpdateIssueParams = serde_json::from_value(args.clone())
997        .map_err(|e| Error::InvalidData(format!("invalid update_issue params: {e}")))?;
998    let custom_fields = args.get("customFields").cloned();
999    let input = UpdateIssueInput {
1000        title: params.title,
1001        description: params.description,
1002        state: params.state,
1003        labels: params.labels,
1004        assignees: params.assignees,
1005        priority: params.priority,
1006        parent_id: params.parent_id,
1007        markdown: params.markdown.unwrap_or(true),
1008        custom_fields,
1009        components: params.components,
1010    };
1011    let key = params.key;
1012    let issue = provider.update_issue(&key, input).await?;
1013
1014    // Set custom fields via separate API call (ClickUp uses Array format)
1015    if let Some(cf) = args.get("customFields").and_then(|v| v.as_array())
1016        && !cf.is_empty()
1017        && let Err(e) = provider.set_custom_fields(&key, cf).await
1018    {
1019        tracing::warn!(error = %e, "Failed to set custom fields on updated issue");
1020    }
1021    Ok(ToolOutput::SingleIssue(Box::new(issue)))
1022}
1023
1024#[derive(Deserialize)]
1025struct AddCommentParams {
1026    key: String,
1027    body: String,
1028    #[serde(default)]
1029    attachments: Vec<AttachmentParam>,
1030}
1031
1032#[derive(Deserialize)]
1033struct AttachmentParam {
1034    /// Base64-encoded file content
1035    #[serde(rename = "fileData")]
1036    file_data: String,
1037    /// Filename (e.g., "screenshot.png")
1038    filename: String,
1039}
1040
1041async fn execute_add_issue_comment(
1042    provider: &dyn devboy_core::Provider,
1043    args: &Value,
1044) -> Result<ToolOutput> {
1045    let params: AddCommentParams = serde_json::from_value(args.clone())
1046        .map_err(|e| Error::InvalidData(format!("invalid add_issue_comment params: {e}")))?;
1047
1048    let mut body = params.body.clone();
1049    let mut uploaded = 0;
1050    let mut upload_errors = Vec::new();
1051
1052    // Validate attachment limits
1053    const MAX_ATTACHMENTS: usize = 10;
1054
1055    if params.attachments.len() > MAX_ATTACHMENTS {
1056        return Err(Error::InvalidData(format!(
1057            "Too many attachments: {} (max {})",
1058            params.attachments.len(),
1059            MAX_ATTACHMENTS
1060        )));
1061    }
1062
1063    // Upload attachments and append links to comment body
1064    for att in &params.attachments {
1065        use base64::Engine;
1066        let data = match base64::engine::general_purpose::STANDARD.decode(&att.file_data) {
1067            Ok(d) => d,
1068            Err(e) => {
1069                upload_errors.push(format!("{}: decode error: {}", att.filename, e));
1070                continue;
1071            }
1072        };
1073
1074        if data.len() > MAX_FILE_SIZE {
1075            upload_errors.push(format!(
1076                "{}: file too large ({} bytes, max {})",
1077                att.filename,
1078                data.len(),
1079                MAX_FILE_SIZE
1080            ));
1081            continue;
1082        }
1083
1084        match provider
1085            .upload_attachment(&params.key, &att.filename, &data)
1086            .await
1087        {
1088            Ok(url) => {
1089                if !url.is_empty() {
1090                    body.push_str(&format!("\n\n[{}]({})", att.filename, url));
1091                }
1092                uploaded += 1;
1093            }
1094            Err(e) => {
1095                upload_errors.push(format!("{}: {}", att.filename, e));
1096            }
1097        }
1098    }
1099
1100    let comment = devboy_core::IssueProvider::add_comment(provider, &params.key, &body).await?;
1101
1102    let mut msg = format!("Comment added to {} (id: {})", params.key, comment.id);
1103    if uploaded > 0 {
1104        msg.push_str(&format!(", {} attachment(s) uploaded", uploaded));
1105    }
1106    if !upload_errors.is_empty() {
1107        msg.push_str(&format!(
1108            ", {} attachment error(s): {}",
1109            upload_errors.len(),
1110            upload_errors.join("; ")
1111        ));
1112    }
1113    Ok(ToolOutput::Text(msg))
1114}
1115
1116// --- Merge request tool handlers ---
1117
1118#[derive(Deserialize, Default)]
1119struct GetMergeRequestsParams {
1120    state: Option<String>,
1121    author: Option<String>,
1122    labels: Option<Vec<String>>,
1123    source_branch: Option<String>,
1124    target_branch: Option<String>,
1125    limit: Option<u32>,
1126    offset: Option<u32>,
1127    sort_by: Option<String>,
1128    sort_order: Option<String>,
1129    /// Token budget for response size control (consumed by format layer via execute_and_format).
1130    #[allow(dead_code)]
1131    budget: Option<usize>,
1132}
1133
1134async fn execute_get_merge_requests(
1135    provider: &dyn devboy_core::Provider,
1136    args: &Value,
1137) -> Result<ToolOutput> {
1138    let params: GetMergeRequestsParams = parse_tool_params(args, "get_merge_requests")?;
1139    let filter = MrFilter {
1140        state: params.state,
1141        source_branch: params.source_branch,
1142        target_branch: params.target_branch,
1143        author: params.author,
1144        labels: params.labels,
1145        limit: params.limit.or(Some(20)),
1146        offset: params.offset,
1147        sort_by: params.sort_by,
1148        sort_order: params.sort_order,
1149    };
1150    let result = provider.get_merge_requests(filter).await?;
1151    let meta = ResultMeta {
1152        pagination: result.pagination,
1153        sort_info: result.sort_info,
1154    };
1155    Ok(ToolOutput::MergeRequests(result.items, Some(meta)))
1156}
1157
1158async fn execute_get_merge_request(
1159    provider: &dyn devboy_core::Provider,
1160    args: &Value,
1161) -> Result<ToolOutput> {
1162    let params: KeyParam = serde_json::from_value(args.clone())
1163        .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
1164    let mr = provider.get_merge_request(&params.key).await?;
1165    Ok(ToolOutput::SingleMergeRequest(Box::new(mr)))
1166}
1167
1168async fn execute_get_merge_request_discussions(
1169    provider: &dyn devboy_core::Provider,
1170    args: &Value,
1171) -> Result<ToolOutput> {
1172    let params: KeyParam = serde_json::from_value(args.clone())
1173        .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
1174    let result = provider.get_discussions(&params.key).await?;
1175    let meta = ResultMeta {
1176        pagination: result.pagination,
1177        sort_info: result.sort_info,
1178    };
1179    Ok(ToolOutput::Discussions(result.items, Some(meta)))
1180}
1181
1182async fn execute_get_merge_request_diffs(
1183    provider: &dyn devboy_core::Provider,
1184    args: &Value,
1185) -> Result<ToolOutput> {
1186    let params: KeyParam = serde_json::from_value(args.clone())
1187        .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
1188    let result = provider.get_diffs(&params.key).await?;
1189    let meta = ResultMeta {
1190        pagination: result.pagination,
1191        sort_info: result.sort_info,
1192    };
1193    Ok(ToolOutput::Diffs(result.items, Some(meta)))
1194}
1195
1196#[derive(Deserialize)]
1197struct CreateMergeRequestParams {
1198    title: String,
1199    description: Option<String>,
1200    source_branch: String,
1201    target_branch: String,
1202    #[serde(default)]
1203    draft: bool,
1204    #[serde(default)]
1205    labels: Vec<String>,
1206    #[serde(default)]
1207    reviewers: Vec<String>,
1208}
1209
1210async fn execute_create_merge_request(
1211    provider: &dyn devboy_core::Provider,
1212    args: &Value,
1213) -> Result<ToolOutput> {
1214    let params: CreateMergeRequestParams = serde_json::from_value(args.clone())
1215        .map_err(|e| Error::InvalidData(format!("invalid create_merge_request params: {e}")))?;
1216    let input = CreateMergeRequestInput {
1217        title: params.title,
1218        description: params.description,
1219        source_branch: params.source_branch,
1220        target_branch: params.target_branch,
1221        draft: params.draft,
1222        labels: params.labels,
1223        reviewers: params.reviewers,
1224    };
1225    let mr = provider.create_merge_request(input).await?;
1226    Ok(ToolOutput::SingleMergeRequest(Box::new(mr)))
1227}
1228
1229#[derive(Deserialize)]
1230struct CreateMrCommentParams {
1231    #[serde(alias = "mrKey")]
1232    key: String,
1233    body: String,
1234    #[serde(alias = "filePath")]
1235    file_path: Option<String>,
1236    line: Option<u32>,
1237    #[serde(alias = "lineType")]
1238    line_type: Option<String>,
1239    #[serde(alias = "commitSha")]
1240    commit_sha: Option<String>,
1241    #[serde(alias = "discussionId")]
1242    discussion_id: Option<String>,
1243}
1244
1245async fn execute_create_merge_request_comment(
1246    provider: &dyn devboy_core::Provider,
1247    args: &Value,
1248) -> Result<ToolOutput> {
1249    let params: CreateMrCommentParams = serde_json::from_value(args.clone()).map_err(|e| {
1250        Error::InvalidData(format!("invalid create_merge_request_comment params: {e}"))
1251    })?;
1252
1253    let position = params.file_path.map(|fp| devboy_core::CodePosition {
1254        file_path: fp,
1255        line: params.line.unwrap_or(1),
1256        line_type: params.line_type.unwrap_or_else(|| "new".into()),
1257        commit_sha: params.commit_sha,
1258    });
1259
1260    let input = CreateCommentInput {
1261        body: params.body,
1262        position,
1263        discussion_id: params.discussion_id,
1264    };
1265
1266    let comment = MergeRequestProvider::add_comment(provider, &params.key, input).await?;
1267    Ok(ToolOutput::Text(format!(
1268        "Comment added to {} (id: {})",
1269        params.key, comment.id
1270    )))
1271}
1272
1273// --- Pipeline tool handlers ---
1274
1275#[derive(Deserialize, Default)]
1276struct GetPipelineParams {
1277    branch: Option<String>,
1278    #[serde(rename = "mrKey")]
1279    mr_key: Option<String>,
1280    #[serde(rename = "includeFailedLogs")]
1281    include_failed_logs: Option<bool>,
1282}
1283
1284async fn execute_get_pipeline(
1285    provider: &dyn devboy_core::Provider,
1286    args: &Value,
1287) -> Result<ToolOutput> {
1288    let params: GetPipelineParams = parse_tool_params(args, "get_pipeline")?;
1289    let input = GetPipelineInput {
1290        branch: params.branch,
1291        mr_key: params.mr_key,
1292        include_failed_logs: params.include_failed_logs.unwrap_or(true),
1293    };
1294    let pipeline = PipelineProvider::get_pipeline(provider, input).await?;
1295    Ok(ToolOutput::Pipeline(Box::new(pipeline)))
1296}
1297
1298#[derive(Deserialize)]
1299struct GetJobLogsParams {
1300    #[serde(rename = "jobId")]
1301    job_id: String,
1302    pattern: Option<String>,
1303    context: Option<usize>,
1304    #[serde(rename = "maxMatches")]
1305    max_matches: Option<usize>,
1306    offset: Option<usize>,
1307    limit: Option<usize>,
1308    full: Option<bool>,
1309}
1310
1311async fn execute_get_job_logs(
1312    provider: &dyn devboy_core::Provider,
1313    args: &Value,
1314) -> Result<ToolOutput> {
1315    let params: GetJobLogsParams = serde_json::from_value(args.clone())
1316        .map_err(|e| Error::InvalidData(format!("invalid get_job_logs params: {e}")))?;
1317
1318    // Clamp limit to max 1000 as declared in schema
1319    let clamped_limit = params.limit.map(|l| l.min(1000));
1320
1321    let mode = if let Some(pattern) = params.pattern {
1322        JobLogMode::Search {
1323            pattern,
1324            context: params.context.unwrap_or(5).min(50),
1325            max_matches: params.max_matches.unwrap_or(20).min(100),
1326        }
1327    } else if let Some(true) = params.full {
1328        JobLogMode::Full {
1329            max_lines: clamped_limit.unwrap_or(1000),
1330        }
1331    } else if params.offset.is_some() || clamped_limit.is_some() {
1332        JobLogMode::Paginated {
1333            offset: params.offset.unwrap_or(0),
1334            limit: clamped_limit.unwrap_or(200),
1335        }
1336    } else {
1337        JobLogMode::Smart
1338    };
1339
1340    let options = JobLogOptions { mode };
1341    let log_output = PipelineProvider::get_job_logs(provider, &params.job_id, options).await?;
1342    Ok(ToolOutput::JobLog(Box::new(log_output)))
1343}
1344
1345// --- Status / User / Link tool handlers ---
1346
1347async fn execute_get_available_statuses(
1348    provider: &dyn devboy_core::Provider,
1349) -> Result<ToolOutput> {
1350    let result = IssueProvider::get_statuses(provider).await?;
1351    let meta = ResultMeta {
1352        pagination: result.pagination,
1353        sort_info: result.sort_info,
1354    };
1355    Ok(ToolOutput::Statuses(result.items, Some(meta)))
1356}
1357
1358#[derive(Deserialize, Default)]
1359struct GetUsersParams {
1360    user_id: Option<String>,
1361    project_key: Option<String>,
1362    search: Option<String>,
1363    include_inactive: Option<bool>,
1364    start_at: Option<u32>,
1365    max_results: Option<u32>,
1366}
1367
1368async fn execute_get_users(
1369    provider: &dyn devboy_core::Provider,
1370    args: &Value,
1371) -> Result<ToolOutput> {
1372    let params: GetUsersParams = parse_tool_params(args, "get_users")?;
1373    let options = GetUsersOptions {
1374        user_id: params.user_id,
1375        project_key: params.project_key,
1376        search: params.search,
1377        include_inactive: params.include_inactive,
1378        start_at: params.start_at,
1379        max_results: params.max_results,
1380    };
1381    let result = IssueProvider::get_users(provider, options).await?;
1382    let meta = ResultMeta {
1383        pagination: result.pagination,
1384        sort_info: result.sort_info,
1385    };
1386    Ok(ToolOutput::Users(result.items, Some(meta)))
1387}
1388
1389#[derive(Deserialize)]
1390struct LinkIssuesParams {
1391    #[serde(alias = "sourceIssueKey", alias = "issueKey1")]
1392    source_key: String,
1393    #[serde(alias = "targetIssueKey", alias = "issueKey2")]
1394    target_key: String,
1395    #[serde(alias = "linkType")]
1396    link_type: String,
1397}
1398
1399async fn execute_link_issues(
1400    provider: &dyn devboy_core::Provider,
1401    args: &Value,
1402) -> Result<ToolOutput> {
1403    let params: LinkIssuesParams = serde_json::from_value(args.clone())
1404        .map_err(|e| Error::InvalidData(format!("invalid link_issues params: {e}")))?;
1405    IssueProvider::link_issues(
1406        provider,
1407        &params.source_key,
1408        &params.target_key,
1409        &params.link_type,
1410    )
1411    .await?;
1412    Ok(ToolOutput::Text(format!(
1413        "Linked {} -> {} (type: {})",
1414        params.source_key, params.target_key, params.link_type
1415    )))
1416}
1417
1418async fn execute_unlink_issues(
1419    provider: &dyn devboy_core::Provider,
1420    args: &Value,
1421) -> Result<ToolOutput> {
1422    let params: LinkIssuesParams = serde_json::from_value(args.clone())
1423        .map_err(|e| Error::InvalidData(format!("invalid unlink_issues params: {e}")))?;
1424    IssueProvider::unlink_issues(
1425        provider,
1426        &params.source_key,
1427        &params.target_key,
1428        &params.link_type,
1429    )
1430    .await?;
1431    Ok(ToolOutput::Text(format!(
1432        "Unlinked {} -> {} (type: {})",
1433        params.source_key, params.target_key, params.link_type
1434    )))
1435}
1436
1437// --- Epic tool handlers ---
1438
1439#[derive(Deserialize, Default)]
1440struct GetEpicsParams {
1441    state: Option<String>,
1442    search: Option<String>,
1443    assignee: Option<String>,
1444    #[serde(rename = "goalId")]
1445    goal_id: Option<String>,
1446    limit: Option<u32>,
1447    offset: Option<u32>,
1448}
1449
1450/// Extract goal ID (G1-G9) from issue labels/tags.
1451fn extract_goal_id(labels: &[String]) -> Option<String> {
1452    labels.iter().find_map(|l| {
1453        let lower = l.to_lowercase();
1454        if lower.len() == 2
1455            && lower.starts_with('g')
1456            && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1457        {
1458            Some(lower.to_uppercase())
1459        } else {
1460            None
1461        }
1462    })
1463}
1464
1465/// Calculate epic progress from subtasks.
1466fn epic_progress(subtasks: &[devboy_core::Issue]) -> serde_json::Value {
1467    let total = subtasks.len();
1468    let completed = subtasks.iter().filter(|s| s.state == "closed").count();
1469    let percentage = if total > 0 {
1470        (completed as f64 / total as f64 * 100.0).round() as u32
1471    } else {
1472        0
1473    };
1474    serde_json::json!({
1475        "total_subtasks": total,
1476        "completed_subtasks": completed,
1477        "percentage": percentage,
1478    })
1479}
1480
1481async fn execute_get_epics(
1482    provider: &dyn devboy_core::Provider,
1483    args: &Value,
1484) -> Result<ToolOutput> {
1485    let params: GetEpicsParams = parse_tool_params(args, "get_epics")?;
1486    let filter = IssueFilter {
1487        state: params.state,
1488        state_category: None,
1489        search: params.search,
1490        labels: Some(vec!["epic".to_string()]),
1491        labels_operator: None,
1492        assignee: params.assignee,
1493        limit: params.limit.or(Some(50)),
1494        offset: params.offset,
1495        sort_by: None,
1496        sort_order: None,
1497        project_key: None,
1498        native_query: None,
1499    };
1500    let result = provider.get_issues(filter).await?;
1501    let mut epics = result.items;
1502
1503    // Filter by goalId if provided
1504    if let Some(ref goal) = params.goal_id {
1505        let goal_lower = goal.to_lowercase();
1506        epics.retain(|e| e.labels.iter().any(|l| l.to_lowercase() == goal_lower));
1507    }
1508
1509    // Enrich each epic with goal ID and progress
1510    let enriched: Vec<serde_json::Value> = epics
1511        .iter()
1512        .map(|epic| {
1513            let mut v = serde_json::to_value(epic).unwrap_or_default();
1514            v["goal_id"] = serde_json::json!(extract_goal_id(&epic.labels));
1515            v["progress"] = epic_progress(&epic.subtasks);
1516            v
1517        })
1518        .collect();
1519
1520    Ok(ToolOutput::Text(
1521        serde_json::to_string_pretty(&enriched).unwrap_or_default(),
1522    ))
1523}
1524
1525#[derive(Deserialize)]
1526struct CreateEpicParams {
1527    title: String,
1528    description: Option<String>,
1529    #[serde(rename = "goalId")]
1530    goal_id: Option<String>,
1531    #[serde(default)]
1532    labels: Vec<String>,
1533    #[serde(default)]
1534    assignees: Vec<String>,
1535    #[serde(default, deserialize_with = "deserialize_string_or_number")]
1536    priority: Option<String>,
1537    markdown: Option<bool>,
1538}
1539
1540async fn execute_create_epic(
1541    provider: &dyn devboy_core::Provider,
1542    args: &Value,
1543) -> Result<ToolOutput> {
1544    let params: CreateEpicParams = serde_json::from_value(args.clone())
1545        .map_err(|e| Error::InvalidData(format!("invalid create_epic params: {e}")))?;
1546
1547    // Ensure "epic" label is included
1548    let mut labels = params.labels;
1549    if !labels.iter().any(|l| l.eq_ignore_ascii_case("epic")) {
1550        labels.push("epic".to_string());
1551    }
1552
1553    // Add goal tag if goalId provided (e.g., "G1" → tag "g1")
1554    if let Some(ref goal) = params.goal_id {
1555        let goal_tag = goal.to_lowercase();
1556        if !labels.iter().any(|l| l.to_lowercase() == goal_tag) {
1557            labels.push(goal_tag);
1558        }
1559    }
1560
1561    let input = CreateIssueInput {
1562        title: params.title,
1563        description: params.description,
1564        labels,
1565        assignees: params.assignees,
1566        priority: params.priority,
1567        parent: None,
1568        markdown: params.markdown.unwrap_or(true),
1569        project_id: None,
1570        issue_type: None,
1571        custom_fields: args.get("customFields").cloned(),
1572        components: Vec::new(),
1573    };
1574    let issue = provider.create_issue(input).await?;
1575
1576    // Set custom fields via separate API call (ClickUp uses Array format)
1577    if let Some(cf) = args.get("customFields").and_then(|v| v.as_array())
1578        && !cf.is_empty()
1579        && let Err(e) = provider.set_custom_fields(&issue.key, cf).await
1580    {
1581        tracing::warn!(error = %e, "Failed to set custom fields on created epic");
1582    }
1583
1584    Ok(ToolOutput::SingleIssue(Box::new(issue)))
1585}
1586
1587#[derive(Deserialize)]
1588struct UpdateEpicParams {
1589    #[serde(alias = "epicKey")]
1590    key: String,
1591    title: Option<String>,
1592    description: Option<String>,
1593    state: Option<String>,
1594    #[serde(rename = "goalId")]
1595    goal_id: Option<String>,
1596    labels: Option<Vec<String>>,
1597    assignees: Option<Vec<String>>,
1598    #[serde(default, deserialize_with = "deserialize_string_or_number")]
1599    priority: Option<String>,
1600    markdown: Option<bool>,
1601}
1602
1603async fn execute_update_epic(
1604    provider: &dyn devboy_core::Provider,
1605    args: &Value,
1606) -> Result<ToolOutput> {
1607    let params: UpdateEpicParams = serde_json::from_value(args.clone())
1608        .map_err(|e| Error::InvalidData(format!("invalid update_epic params: {e}")))?;
1609
1610    // Handle goal tag transition: if goalId is changing, update labels
1611    let labels = if let Some(ref new_goal) = params.goal_id {
1612        // Fetch current issue to get existing labels
1613        let current = provider.get_issue(&params.key).await?;
1614        let mut labels: Vec<String> = current
1615            .labels
1616            .iter()
1617            // Remove old goal tags (g1-g9)
1618            .filter(|l| {
1619                let lower = l.to_lowercase();
1620                !(lower.len() == 2
1621                    && lower.starts_with('g')
1622                    && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit()))
1623            })
1624            .cloned()
1625            .collect();
1626
1627        // Add new goal tag
1628        let goal_tag = new_goal.to_lowercase();
1629        if !labels.iter().any(|l| l.to_lowercase() == goal_tag) {
1630            labels.push(goal_tag);
1631        }
1632
1633        // Merge with explicitly provided labels
1634        if let Some(extra) = params.labels {
1635            for l in extra {
1636                if !labels
1637                    .iter()
1638                    .any(|existing| existing.eq_ignore_ascii_case(&l))
1639                {
1640                    labels.push(l);
1641                }
1642            }
1643        }
1644        Some(labels)
1645    } else {
1646        params.labels
1647    };
1648
1649    let input = UpdateIssueInput {
1650        title: params.title,
1651        description: params.description,
1652        state: params.state,
1653        labels,
1654        assignees: params.assignees,
1655        priority: params.priority,
1656        parent_id: None,
1657        markdown: params.markdown.unwrap_or(true),
1658        custom_fields: args.get("customFields").cloned(),
1659        components: None,
1660    };
1661    let key = params.key;
1662    let issue = provider.update_issue(&key, input).await?;
1663
1664    // Set custom fields via separate API call (ClickUp uses Array format)
1665    if let Some(cf) = args.get("customFields").and_then(|v| v.as_array())
1666        && !cf.is_empty()
1667        && let Err(e) = provider.set_custom_fields(&key, cf).await
1668    {
1669        tracing::warn!(error = %e, "Failed to set custom fields on updated epic");
1670    }
1671
1672    Ok(ToolOutput::SingleIssue(Box::new(issue)))
1673}
1674
1675/// List of all tool names supported by the executor.
1676pub const SUPPORTED_TOOLS: &[&str] = &[
1677    "get_issues",
1678    "get_issue",
1679    "get_issue_comments",
1680    "get_issue_relations",
1681    "create_issue",
1682    "update_issue",
1683    "add_issue_comment",
1684    "get_merge_requests",
1685    "get_merge_request",
1686    "get_merge_request_discussions",
1687    "get_merge_request_diffs",
1688    "create_merge_request",
1689    "create_merge_request_comment",
1690    "update_merge_request",
1691    "get_pipeline",
1692    "get_job_logs",
1693    "get_available_statuses",
1694    "get_users",
1695    "link_issues",
1696    "unlink_issues",
1697    "get_epics",
1698    "create_epic",
1699    "update_epic",
1700    "get_meeting_notes",
1701    "get_meeting_transcript",
1702    "search_meeting_notes",
1703    // Knowledge base tools
1704    "get_knowledge_base_spaces",
1705    "list_knowledge_base_pages",
1706    "get_knowledge_base_page",
1707    "create_knowledge_base_page",
1708    "update_knowledge_base_page",
1709    "search_knowledge_base",
1710    // Messenger tools
1711    "get_messenger_chats",
1712    "get_chat_messages",
1713    "search_chat_messages",
1714    "send_message",
1715    // Asset tools
1716    "get_assets",
1717    "upload_asset",
1718    "download_asset",
1719    "delete_asset",
1720];
1721
1722// =============================================================================
1723// Update Merge Request handler
1724// =============================================================================
1725
1726#[derive(Deserialize)]
1727struct UpdateMergeRequestParams {
1728    key: String,
1729    #[serde(default)]
1730    title: Option<String>,
1731    #[serde(default)]
1732    description: Option<String>,
1733    #[serde(default)]
1734    state: Option<String>,
1735    #[serde(default)]
1736    labels: Option<Vec<String>>,
1737    #[serde(default)]
1738    draft: Option<bool>,
1739}
1740
1741async fn execute_update_merge_request(
1742    provider: &dyn devboy_core::Provider,
1743    args: &Value,
1744) -> Result<ToolOutput> {
1745    let params: UpdateMergeRequestParams = serde_json::from_value(args.clone())?;
1746    debug!(key = %params.key, "update_merge_request");
1747
1748    let input = devboy_core::UpdateMergeRequestInput {
1749        title: params.title,
1750        description: params.description,
1751        state: params.state,
1752        labels: params.labels,
1753        draft: params.draft,
1754    };
1755
1756    let mr = MergeRequestProvider::update_merge_request(provider, &params.key, input).await?;
1757    Ok(ToolOutput::SingleMergeRequest(Box::new(mr)))
1758}
1759
1760// =============================================================================
1761// Asset tool handlers
1762// =============================================================================
1763
1764#[derive(Deserialize)]
1765struct GetAssetsParams {
1766    /// "issue" or "mr"
1767    context_type: String,
1768    /// Issue key (e.g. "DEV-123") or MR key (e.g. "mr#42")
1769    key: String,
1770}
1771
1772async fn execute_get_assets(
1773    provider: &dyn devboy_core::Provider,
1774    args: &Value,
1775) -> Result<ToolOutput> {
1776    let params: GetAssetsParams = serde_json::from_value(args.clone())?;
1777    debug!(context_type = %params.context_type, key = %params.key, "get_assets");
1778
1779    let assets = match params.context_type.as_str() {
1780        "issue" => IssueProvider::get_issue_attachments(provider, &params.key).await?,
1781        "mr" | "merge_request" | "pull_request" => {
1782            MergeRequestProvider::get_mr_attachments(provider, &params.key).await?
1783        }
1784        other => {
1785            return Err(Error::InvalidData(format!(
1786                "unsupported context_type: '{other}', expected 'issue' or 'mr'"
1787            )));
1788        }
1789    };
1790
1791    let capabilities =
1792        serde_json::to_value(IssueProvider::asset_capabilities(provider)).unwrap_or_default();
1793    let count = assets.len();
1794    let attachments: Vec<serde_json::Value> = assets
1795        .into_iter()
1796        .map(|a| serde_json::to_value(a).unwrap_or_default())
1797        .collect();
1798    Ok(ToolOutput::AssetList {
1799        attachments,
1800        count,
1801        capabilities,
1802    })
1803}
1804
1805#[derive(Deserialize)]
1806struct UploadAssetParams {
1807    /// "issue" or "mr"
1808    context_type: String,
1809    key: String,
1810    filename: String,
1811    /// Base64-encoded file data
1812    #[serde(rename = "fileData")]
1813    file_data: String,
1814}
1815
1816async fn execute_upload_asset(
1817    provider: &dyn devboy_core::Provider,
1818    args: &Value,
1819) -> Result<ToolOutput> {
1820    let params: UploadAssetParams = serde_json::from_value(args.clone())?;
1821    debug!(context_type = %params.context_type, key = %params.key, filename = %params.filename, "upload_asset");
1822
1823    let data = base64_decode(&params.file_data)?;
1824
1825    if data.len() > MAX_FILE_SIZE {
1826        return Err(Error::InvalidData(format!(
1827            "file '{}' is {} bytes, max allowed is {} bytes",
1828            params.filename,
1829            data.len(),
1830            MAX_FILE_SIZE,
1831        )));
1832    }
1833
1834    let size = data.len();
1835    let url = match params.context_type.as_str() {
1836        "issue" => {
1837            IssueProvider::upload_attachment(provider, &params.key, &params.filename, &data).await?
1838        }
1839        other => {
1840            return Err(Error::InvalidData(format!(
1841                "upload not supported for context_type: '{other}', use 'issue'"
1842            )));
1843        }
1844    };
1845
1846    Ok(ToolOutput::AssetUploaded {
1847        url,
1848        filename: params.filename,
1849        size,
1850    })
1851}
1852
1853#[derive(Deserialize)]
1854struct DownloadAssetParams {
1855    /// "issue" or "mr"
1856    context_type: String,
1857    key: String,
1858    /// Asset identifier (provider-specific)
1859    asset_id: String,
1860}
1861
1862async fn execute_download_asset(
1863    provider: &dyn devboy_core::Provider,
1864    args: &Value,
1865    asset_manager: Option<&devboy_assets::AssetManager>,
1866) -> Result<ToolOutput> {
1867    let params: DownloadAssetParams = serde_json::from_value(args.clone())?;
1868    debug!(context_type = %params.context_type, key = %params.key, asset_id = %params.asset_id, "download_asset");
1869
1870    // Check local cache first.
1871    if let Some(mgr) = asset_manager
1872        && let Ok(Some(resolved)) = mgr.get(&params.asset_id)
1873    {
1874        return Ok(ToolOutput::AssetDownloaded {
1875            asset_id: params.asset_id,
1876            size: resolved.asset.size as usize,
1877            local_path: Some(resolved.absolute_path.to_string_lossy().into_owned()),
1878            data: None,
1879            cached: true,
1880        });
1881    }
1882
1883    // Not cached — download from provider.
1884    let bytes = match params.context_type.as_str() {
1885        "issue" => {
1886            IssueProvider::download_attachment(provider, &params.key, &params.asset_id).await?
1887        }
1888        "mr" | "merge_request" | "pull_request" => {
1889            MergeRequestProvider::download_mr_attachment(provider, &params.key, &params.asset_id)
1890                .await?
1891        }
1892        other => {
1893            return Err(Error::InvalidData(format!(
1894                "unsupported context_type: '{other}', expected 'issue' or 'mr'"
1895            )));
1896        }
1897    };
1898
1899    // Store in cache if available.
1900    if let Some(mgr) = asset_manager {
1901        let context = match params.context_type.as_str() {
1902            "mr" | "merge_request" | "pull_request" => devboy_core::AssetContext::MergeRequest {
1903                mr_id: params.key.clone(),
1904            },
1905            _ => devboy_core::AssetContext::Issue {
1906                key: params.key.clone(),
1907            },
1908        };
1909        let filename = devboy_core::filename_from_url(&params.asset_id);
1910        match mgr.store(devboy_assets::StoreRequest {
1911            context,
1912            asset_id: Some(&params.asset_id),
1913            filename: &filename,
1914            mime_type: None,
1915            remote_url: None,
1916            data: &bytes,
1917        }) {
1918            Ok(cached) => {
1919                let abs = mgr.cache_dir().join(&cached.local_path);
1920                return Ok(ToolOutput::AssetDownloaded {
1921                    asset_id: cached.id,
1922                    size: cached.size as usize,
1923                    local_path: Some(abs.to_string_lossy().into_owned()),
1924                    data: None,
1925                    cached: true,
1926                });
1927            }
1928            Err(e) => {
1929                tracing::warn!(?e, "failed to cache asset, returning base64 fallback");
1930            }
1931        }
1932    }
1933
1934    // Fallback: return base64-encoded content.
1935    if bytes.len() > MAX_FILE_SIZE {
1936        return Err(Error::InvalidData(format!(
1937            "downloaded attachment is {} bytes, max allowed for base64 response is {} bytes",
1938            bytes.len(),
1939            MAX_FILE_SIZE,
1940        )));
1941    }
1942
1943    let encoded = base64_encode(&bytes);
1944    Ok(ToolOutput::AssetDownloaded {
1945        asset_id: params.asset_id,
1946        size: bytes.len(),
1947        local_path: None,
1948        data: Some(encoded),
1949        cached: false,
1950    })
1951}
1952
1953#[derive(Deserialize)]
1954struct DeleteAssetParams {
1955    key: String,
1956    asset_id: String,
1957}
1958
1959async fn execute_delete_asset(
1960    provider: &dyn devboy_core::Provider,
1961    args: &Value,
1962    asset_manager: Option<&devboy_assets::AssetManager>,
1963) -> Result<ToolOutput> {
1964    let params: DeleteAssetParams = serde_json::from_value(args.clone())?;
1965    debug!(key = %params.key, asset_id = %params.asset_id, "delete_asset");
1966
1967    IssueProvider::delete_attachment(provider, &params.key, &params.asset_id).await?;
1968
1969    // Evict from local cache so stale files aren't served.
1970    if let Some(mgr) = asset_manager
1971        && let Err(e) = mgr.delete(&params.asset_id)
1972    {
1973        tracing::warn!(?e, asset_id = %params.asset_id, "failed to evict deleted asset from cache");
1974    }
1975
1976    let message = format!(
1977        "Attachment '{}' deleted from {}",
1978        params.asset_id, params.key
1979    );
1980    Ok(ToolOutput::AssetDeleted {
1981        asset_id: params.asset_id,
1982        message,
1983    })
1984}
1985
1986/// Maximum base64 encoded length for MAX_FILE_SIZE bytes.
1987const MAX_BASE64_LEN: usize = (MAX_FILE_SIZE / 3 + 1) * 4 + 4;
1988
1989/// Decode base64 with standard or URL-safe alphabet, rejecting
1990/// oversized inputs *before* allocating the decoded buffer.
1991fn base64_decode(input: &str) -> Result<Vec<u8>> {
1992    let trimmed = input.trim();
1993    if trimmed.len() > MAX_BASE64_LEN {
1994        return Err(Error::InvalidData(format!(
1995            "base64 input too large ({} chars), max decoded size is {} bytes",
1996            trimmed.len(),
1997            MAX_FILE_SIZE,
1998        )));
1999    }
2000    use base64::Engine;
2001    base64::engine::general_purpose::STANDARD
2002        .decode(trimmed)
2003        .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(trimmed))
2004        .map_err(|e| Error::InvalidData(format!("invalid base64: {e}")))
2005}
2006
2007/// Encode bytes as standard base64.
2008fn base64_encode(data: &[u8]) -> String {
2009    use base64::Engine;
2010    base64::engine::general_purpose::STANDARD.encode(data)
2011}
2012
2013// =============================================================================
2014// Jira Structure tool handlers
2015// =============================================================================
2016
2017async fn execute_get_structures(provider: &dyn devboy_core::Provider) -> Result<ToolOutput> {
2018    let result = provider.get_structures().await?;
2019    let meta = ResultMeta {
2020        pagination: result.pagination,
2021        sort_info: result.sort_info,
2022    };
2023    Ok(ToolOutput::Structures(result.items, Some(meta)))
2024}
2025
2026#[derive(Deserialize)]
2027#[serde(rename_all = "camelCase")]
2028struct GetStructureForestParams {
2029    structure_id: u64,
2030    offset: Option<u64>,
2031    limit: Option<u64>,
2032}
2033
2034async fn execute_get_structure_forest(
2035    provider: &dyn devboy_core::Provider,
2036    args: &Value,
2037) -> Result<ToolOutput> {
2038    let params: GetStructureForestParams = serde_json::from_value(args.clone())
2039        .map_err(|e| Error::InvalidData(format!("missing 'structureId': {e}")))?;
2040    let forest = provider
2041        .get_structure_forest(
2042            params.structure_id,
2043            GetForestOptions {
2044                offset: params.offset,
2045                limit: Some(params.limit.unwrap_or(200)),
2046            },
2047        )
2048        .await?;
2049    Ok(ToolOutput::StructureForest(Box::new(forest)))
2050}
2051
2052#[derive(Deserialize)]
2053#[serde(rename_all = "camelCase")]
2054struct AddStructureRowsParams {
2055    structure_id: u64,
2056    items: Vec<Value>,
2057    under: Option<u64>,
2058    after: Option<u64>,
2059    forest_version: Option<u64>,
2060}
2061
2062/// Turn a single `items[]` entry from `add_structure_rows` into a
2063/// `StructureRowItem`. The tool schema can only express a list of
2064/// strings, so callers wanting to set `item_type` or nested fields
2065/// are forced to pass JSON inside a string. Accept both:
2066///
2067/// - bare string → `{ item_id: s, item_type: None }`
2068/// - JSON object (either as a real object or a string that parses as
2069///   one) → `serde_json::from_value`
2070///
2071/// Malformed input surfaces as `InvalidData` rather than silently
2072/// dropping values through `unwrap_or_default()`.
2073fn parse_structure_row_item(v: Value) -> Result<StructureRowItem> {
2074    if let Some(s) = v.as_str() {
2075        if let Ok(parsed) = serde_json::from_str::<Value>(s)
2076            && parsed.is_object()
2077        {
2078            return serde_json::from_value(parsed)
2079                .map_err(|e| Error::InvalidData(format!("invalid structure row item JSON: {e}")));
2080        }
2081        return Ok(StructureRowItem {
2082            item_id: s.to_string(),
2083            item_type: None,
2084        });
2085    }
2086    serde_json::from_value(v)
2087        .map_err(|e| Error::InvalidData(format!("invalid structure row item: {e}")))
2088}
2089
2090/// Turn a `columns[]` entry from `get_structure_values` /
2091/// `save_structure_view` into a `StructureViewColumn`. Same dual
2092/// shape as `parse_structure_row_item`: bare string means
2093/// `{ field: Some(s) }`, anything else (or a JSON-object string) is
2094/// deserialised as a full spec. Errors propagate.
2095fn parse_structure_column_spec(v: Value) -> Result<StructureViewColumn> {
2096    if let Some(s) = v.as_str() {
2097        if let Ok(parsed) = serde_json::from_str::<Value>(s)
2098            && parsed.is_object()
2099        {
2100            return serde_json::from_value(parsed).map_err(|e| {
2101                Error::InvalidData(format!("invalid structure column spec JSON: {e}"))
2102            });
2103        }
2104        return Ok(StructureViewColumn {
2105            field: Some(s.to_string()),
2106            ..Default::default()
2107        });
2108    }
2109    serde_json::from_value(v)
2110        .map_err(|e| Error::InvalidData(format!("invalid structure column spec: {e}")))
2111}
2112
2113async fn execute_add_structure_rows(
2114    provider: &dyn devboy_core::Provider,
2115    args: &Value,
2116) -> Result<ToolOutput> {
2117    let params: AddStructureRowsParams = serde_json::from_value(args.clone())
2118        .map_err(|e| Error::InvalidData(format!("invalid add_structure_rows params: {e}")))?;
2119
2120    let items: Vec<StructureRowItem> = params
2121        .items
2122        .into_iter()
2123        .map(parse_structure_row_item)
2124        .collect::<Result<Vec<_>>>()?;
2125
2126    let result = provider
2127        .add_structure_rows(
2128            params.structure_id,
2129            AddStructureRowsInput {
2130                items,
2131                under: params.under,
2132                after: params.after,
2133                forest_version: params.forest_version,
2134            },
2135        )
2136        .await?;
2137    Ok(ToolOutput::ForestModified(result))
2138}
2139
2140#[derive(Deserialize)]
2141#[serde(rename_all = "camelCase")]
2142struct MoveStructureRowsParams {
2143    structure_id: u64,
2144    row_ids: Vec<u64>,
2145    under: Option<u64>,
2146    after: Option<u64>,
2147    forest_version: Option<u64>,
2148}
2149
2150async fn execute_move_structure_rows(
2151    provider: &dyn devboy_core::Provider,
2152    args: &Value,
2153) -> Result<ToolOutput> {
2154    let params: MoveStructureRowsParams = serde_json::from_value(args.clone())
2155        .map_err(|e| Error::InvalidData(format!("invalid move_structure_rows params: {e}")))?;
2156    let result = provider
2157        .move_structure_rows(
2158            params.structure_id,
2159            MoveStructureRowsInput {
2160                row_ids: params.row_ids,
2161                under: params.under,
2162                after: params.after,
2163                forest_version: params.forest_version,
2164            },
2165        )
2166        .await?;
2167    Ok(ToolOutput::ForestModified(result))
2168}
2169
2170#[derive(Deserialize)]
2171#[serde(rename_all = "camelCase")]
2172struct RemoveStructureRowParams {
2173    structure_id: u64,
2174    row_id: u64,
2175}
2176
2177async fn execute_remove_structure_row(
2178    provider: &dyn devboy_core::Provider,
2179    args: &Value,
2180) -> Result<ToolOutput> {
2181    let params: RemoveStructureRowParams = serde_json::from_value(args.clone())
2182        .map_err(|e| Error::InvalidData(format!("invalid remove_structure_row params: {e}")))?;
2183    provider
2184        .remove_structure_row(params.structure_id, params.row_id)
2185        .await?;
2186    Ok(ToolOutput::Text(format!(
2187        "Row {} removed from structure {}",
2188        params.row_id, params.structure_id
2189    )))
2190}
2191
2192#[derive(Deserialize)]
2193#[serde(rename_all = "camelCase")]
2194struct GetStructureValuesParams {
2195    structure_id: u64,
2196    rows: Vec<u64>,
2197    columns: Vec<Value>,
2198}
2199
2200async fn execute_get_structure_values(
2201    provider: &dyn devboy_core::Provider,
2202    args: &Value,
2203) -> Result<ToolOutput> {
2204    let params: GetStructureValuesParams = serde_json::from_value(args.clone())
2205        .map_err(|e| Error::InvalidData(format!("invalid get_structure_values params: {e}")))?;
2206
2207    let columns: Vec<StructureViewColumn> = params
2208        .columns
2209        .into_iter()
2210        .map(parse_structure_column_spec)
2211        .collect::<Result<Vec<_>>>()?;
2212
2213    let result = provider
2214        .get_structure_values(GetStructureValuesInput {
2215            structure_id: params.structure_id,
2216            rows: params.rows,
2217            columns,
2218        })
2219        .await?;
2220    Ok(ToolOutput::StructureValues(Box::new(result)))
2221}
2222
2223#[derive(Deserialize)]
2224#[serde(rename_all = "camelCase")]
2225struct GetStructureViewsParams {
2226    structure_id: u64,
2227    view_id: Option<u64>,
2228}
2229
2230async fn execute_get_structure_views(
2231    provider: &dyn devboy_core::Provider,
2232    args: &Value,
2233) -> Result<ToolOutput> {
2234    let params: GetStructureViewsParams = serde_json::from_value(args.clone())
2235        .map_err(|e| Error::InvalidData(format!("invalid get_structure_views params: {e}")))?;
2236    let views = provider
2237        .get_structure_views(params.structure_id, params.view_id)
2238        .await?;
2239    Ok(ToolOutput::StructureViews(views, None))
2240}
2241
2242#[derive(Deserialize)]
2243#[serde(rename_all = "camelCase")]
2244struct SaveStructureViewParams {
2245    id: Option<u64>,
2246    structure_id: u64,
2247    name: String,
2248    columns: Option<Vec<Value>>,
2249    group_by: Option<String>,
2250    sort_by: Option<String>,
2251    filter: Option<String>,
2252}
2253
2254async fn execute_save_structure_view(
2255    provider: &dyn devboy_core::Provider,
2256    args: &Value,
2257) -> Result<ToolOutput> {
2258    let params: SaveStructureViewParams = serde_json::from_value(args.clone())
2259        .map_err(|e| Error::InvalidData(format!("invalid save_structure_view params: {e}")))?;
2260
2261    let columns: Option<Vec<StructureViewColumn>> = params
2262        .columns
2263        .map(|cols| {
2264            cols.into_iter()
2265                .map(parse_structure_column_spec)
2266                .collect::<Result<Vec<_>>>()
2267        })
2268        .transpose()?;
2269
2270    let view = provider
2271        .save_structure_view(SaveStructureViewInput {
2272            id: params.id,
2273            structure_id: params.structure_id,
2274            name: params.name,
2275            columns,
2276            group_by: params.group_by,
2277            sort_by: params.sort_by,
2278            filter: params.filter,
2279        })
2280        .await?;
2281    Ok(ToolOutput::StructureViews(vec![view], None))
2282}
2283
2284#[derive(Deserialize)]
2285struct CreateStructureParams {
2286    name: String,
2287    description: Option<String>,
2288}
2289
2290async fn execute_create_structure(
2291    provider: &dyn devboy_core::Provider,
2292    args: &Value,
2293) -> Result<ToolOutput> {
2294    let params: CreateStructureParams = serde_json::from_value(args.clone())
2295        .map_err(|e| Error::InvalidData(format!("missing 'name': {e}")))?;
2296    let structure = provider
2297        .create_structure(CreateStructureInput {
2298            name: params.name,
2299            description: params.description,
2300        })
2301        .await?;
2302    Ok(ToolOutput::Structures(vec![structure], None))
2303}
2304
2305// =============================================================================
2306// Project versions / fixVersion handlers (issue #238)
2307// =============================================================================
2308
2309/// Tri-state filter for `released` / `archived` — accepts the strings
2310/// `"true"`, `"false"`, `"all"` (default `"all"` → no filter).
2311fn parse_tri_filter(s: Option<&str>) -> Result<Option<bool>> {
2312    match s.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
2313        None | Some("") | Some("all") | Some("any") => Ok(None),
2314        Some("true") | Some("yes") | Some("1") => Ok(Some(true)),
2315        Some("false") | Some("no") | Some("0") => Ok(Some(false)),
2316        Some(other) => Err(Error::InvalidData(format!(
2317            "expected 'true' | 'false' | 'all', got '{other}'"
2318        ))),
2319    }
2320}
2321
2322/// Validate that a string is an ISO 8601 calendar date in `YYYY-MM-DD`
2323/// form. Jira accepts that exact shape on `releaseDate`/`startDate`
2324/// payloads; anything else (timestamps, slashes, locale formats) gets
2325/// rejected with a 400 by the server, so catch it client-side with a
2326/// clear error pointing at the offending field.
2327fn validate_iso_date(field: &str, value: &str) -> Result<()> {
2328    let bytes = value.as_bytes();
2329    let shape_ok = bytes.len() == 10
2330        && bytes[4] == b'-'
2331        && bytes[7] == b'-'
2332        && bytes[..4].iter().all(u8::is_ascii_digit)
2333        && bytes[5..7].iter().all(u8::is_ascii_digit)
2334        && bytes[8..].iter().all(u8::is_ascii_digit);
2335    if !shape_ok {
2336        return Err(Error::InvalidData(format!(
2337            "{field} must be an ISO 8601 calendar date (YYYY-MM-DD), got '{value}'"
2338        )));
2339    }
2340    let month: u32 = value[5..7].parse().unwrap();
2341    let day: u32 = value[8..10].parse().unwrap();
2342    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
2343        return Err(Error::InvalidData(format!(
2344            "{field} = '{value}' is not a valid calendar date"
2345        )));
2346    }
2347    Ok(())
2348}
2349
2350#[derive(Deserialize, Default)]
2351#[serde(rename_all = "camelCase")]
2352struct ListProjectVersionsArgs {
2353    project: Option<String>,
2354    released: Option<String>,
2355    archived: Option<String>,
2356    limit: Option<u32>,
2357    include_issue_count: Option<bool>,
2358}
2359
2360async fn execute_list_project_versions(
2361    provider: &dyn devboy_core::Provider,
2362    args: &Value,
2363) -> Result<ToolOutput> {
2364    let params: ListProjectVersionsArgs = parse_tool_params(args, "list_project_versions")?;
2365
2366    // Paper 1 / TrimTree defaults: hide archived noise + cap at 20 most
2367    // recent. Defaults only apply when the caller omits the field —
2368    // explicit `"all"` must round-trip as `None` (no filter).
2369    let archived = match params.archived.as_deref() {
2370        None => Some(false),
2371        Some(s) => parse_tri_filter(Some(s))?,
2372    };
2373    let released = match params.released.as_deref() {
2374        None => None,
2375        Some(s) => parse_tri_filter(Some(s))?,
2376    };
2377    // `limit: 0` would round-trip to a useless empty list — reject up
2378    // front instead of letting the schema's `min: 1` get bypassed by a
2379    // raw call site (Codex review on PR #239).
2380    if let Some(0) = params.limit {
2381        return Err(Error::InvalidData(
2382            "limit must be at least 1 (use the default by omitting the field)".into(),
2383        ));
2384    }
2385    let limit = params.limit.unwrap_or(20).min(200);
2386
2387    let result = provider
2388        .list_project_versions(ListProjectVersionsParams {
2389            project: params.project.unwrap_or_default(),
2390            released,
2391            archived,
2392            limit: Some(limit),
2393            include_issue_count: params.include_issue_count.unwrap_or(false),
2394        })
2395        .await?;
2396
2397    let meta = ResultMeta {
2398        pagination: result.pagination,
2399        sort_info: result.sort_info,
2400    };
2401    Ok(ToolOutput::ProjectVersions(result.items, Some(meta)))
2402}
2403
2404#[derive(Deserialize)]
2405#[serde(rename_all = "camelCase")]
2406struct UpsertProjectVersionArgs {
2407    project: Option<String>,
2408    name: String,
2409    description: Option<String>,
2410    start_date: Option<String>,
2411    release_date: Option<String>,
2412    released: Option<bool>,
2413    archived: Option<bool>,
2414}
2415
2416async fn execute_upsert_project_version(
2417    provider: &dyn devboy_core::Provider,
2418    args: &Value,
2419) -> Result<ToolOutput> {
2420    let params: UpsertProjectVersionArgs = serde_json::from_value(args.clone())
2421        .map_err(|e| Error::InvalidData(format!("invalid upsert_project_version params: {e}")))?;
2422
2423    // Codex review on PR #239 — validate inputs before they cross the
2424    // wire so the failure points at the parameter, not at "Jira said 400".
2425    if let Some(ref d) = params.start_date {
2426        validate_iso_date("startDate", d)?;
2427    }
2428    if let Some(ref d) = params.release_date {
2429        validate_iso_date("releaseDate", d)?;
2430    }
2431
2432    let version = provider
2433        .upsert_project_version(UpsertProjectVersionInput {
2434            project: params.project.unwrap_or_default(),
2435            name: params.name,
2436            description: params.description,
2437            start_date: params.start_date,
2438            release_date: params.release_date,
2439            released: params.released,
2440            archived: params.archived,
2441        })
2442        .await?;
2443
2444    Ok(ToolOutput::SingleProjectVersion(Box::new(version)))
2445}
2446
2447#[cfg(test)]
2448mod tests {
2449    use super::*;
2450    use async_trait::async_trait;
2451    use devboy_core::{
2452        Comment, CreateMergeRequestInput, Discussion, FileDiff, Issue, IssueLink, IssueProvider,
2453        IssueRelations, KbPage, KbPageContent, KbSpace, KnowledgeBaseProvider, MergeRequest,
2454        MergeRequestProvider, Provider, User,
2455    };
2456
2457    // --- Mock Provider ---
2458
2459    struct MockProvider;
2460
2461    fn sample_issue() -> Issue {
2462        Issue {
2463            key: "gh#1".into(),
2464            title: "Test Issue".into(),
2465            description: Some("Body".into()),
2466            state: "open".into(),
2467            source: "mock".into(),
2468            priority: None,
2469            labels: vec!["bug".into()],
2470            author: None,
2471            assignees: vec![],
2472            url: Some("https://example.com/1".into()),
2473            created_at: Some("2024-01-01T00:00:00Z".into()),
2474            updated_at: Some("2024-01-02T00:00:00Z".into()),
2475            attachments_count: None,
2476            parent: None,
2477            subtasks: vec![],
2478        }
2479    }
2480
2481    fn sample_mr() -> MergeRequest {
2482        MergeRequest {
2483            key: "pr#1".into(),
2484            title: "Test PR".into(),
2485            description: Some("PR body".into()),
2486            state: "open".into(),
2487            source: "mock".into(),
2488            source_branch: "feature".into(),
2489            target_branch: "main".into(),
2490            author: None,
2491            assignees: vec![],
2492            reviewers: vec![],
2493            labels: vec![],
2494            draft: false,
2495            url: Some("https://example.com/pr/1".into()),
2496            created_at: Some("2024-01-01T00:00:00Z".into()),
2497            updated_at: Some("2024-01-02T00:00:00Z".into()),
2498        }
2499    }
2500
2501    fn sample_comment() -> Comment {
2502        Comment {
2503            id: "c1".into(),
2504            body: "Test comment".into(),
2505            author: None,
2506            created_at: None,
2507            updated_at: None,
2508            position: None,
2509        }
2510    }
2511
2512    fn sample_discussion() -> Discussion {
2513        Discussion {
2514            id: "d1".into(),
2515            resolved: false,
2516            resolved_by: None,
2517            comments: vec![sample_comment()],
2518            position: None,
2519        }
2520    }
2521
2522    fn sample_diff() -> FileDiff {
2523        FileDiff {
2524            file_path: "src/main.rs".into(),
2525            old_path: None,
2526            new_file: false,
2527            deleted_file: false,
2528            renamed_file: false,
2529            diff: "+added\n-removed".into(),
2530            additions: Some(1),
2531            deletions: Some(1),
2532        }
2533    }
2534
2535    fn sample_kb_space() -> KbSpace {
2536        KbSpace {
2537            id: "space-1".into(),
2538            key: "ENG".into(),
2539            name: "Engineering".into(),
2540            ..Default::default()
2541        }
2542    }
2543
2544    fn sample_kb_page() -> KbPage {
2545        KbPage {
2546            id: "page-1".into(),
2547            title: "Architecture".into(),
2548            space_key: Some("ENG".into()),
2549            ..Default::default()
2550        }
2551    }
2552
2553    fn sample_kb_page_content() -> KbPageContent {
2554        KbPageContent {
2555            page: sample_kb_page(),
2556            content: "<p>body</p>".into(),
2557            content_type: "storage".into(),
2558            ancestors: vec![],
2559            labels: vec!["docs".into()],
2560        }
2561    }
2562
2563    #[async_trait]
2564    impl IssueProvider for MockProvider {
2565        async fn get_issues(
2566            &self,
2567            _filter: IssueFilter,
2568        ) -> devboy_core::Result<devboy_core::ProviderResult<Issue>> {
2569            Ok(vec![sample_issue()].into())
2570        }
2571        async fn get_issue(&self, _key: &str) -> devboy_core::Result<Issue> {
2572            Ok(sample_issue())
2573        }
2574        async fn create_issue(
2575            &self,
2576            _input: devboy_core::CreateIssueInput,
2577        ) -> devboy_core::Result<Issue> {
2578            Ok(sample_issue())
2579        }
2580        async fn update_issue(
2581            &self,
2582            _key: &str,
2583            _input: devboy_core::UpdateIssueInput,
2584        ) -> devboy_core::Result<Issue> {
2585            Ok(sample_issue())
2586        }
2587        async fn get_comments(
2588            &self,
2589            _key: &str,
2590        ) -> devboy_core::Result<devboy_core::ProviderResult<Comment>> {
2591            Ok(vec![sample_comment()].into())
2592        }
2593        async fn add_comment(&self, _key: &str, _body: &str) -> devboy_core::Result<Comment> {
2594            Ok(sample_comment())
2595        }
2596        async fn get_issue_relations(&self, _key: &str) -> devboy_core::Result<IssueRelations> {
2597            Ok(IssueRelations {
2598                parent: Some(sample_issue()),
2599                subtasks: vec![sample_issue()],
2600                blocks: vec![IssueLink {
2601                    issue: sample_issue(),
2602                    link_type: "Blocks".into(),
2603                }],
2604                ..Default::default()
2605            })
2606        }
2607        async fn get_structures(
2608            &self,
2609        ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::Structure>> {
2610            Ok(vec![sample_structure()].into())
2611        }
2612        async fn get_structure_forest(
2613            &self,
2614            structure_id: u64,
2615            _options: devboy_core::GetForestOptions,
2616        ) -> devboy_core::Result<devboy_core::StructureForest> {
2617            Ok(sample_forest(structure_id))
2618        }
2619        async fn add_structure_rows(
2620            &self,
2621            _structure_id: u64,
2622            input: devboy_core::AddStructureRowsInput,
2623        ) -> devboy_core::Result<devboy_core::ForestModifyResult> {
2624            Ok(devboy_core::ForestModifyResult {
2625                version: 2,
2626                affected_count: input.items.len(),
2627            })
2628        }
2629        async fn move_structure_rows(
2630            &self,
2631            _structure_id: u64,
2632            input: devboy_core::MoveStructureRowsInput,
2633        ) -> devboy_core::Result<devboy_core::ForestModifyResult> {
2634            Ok(devboy_core::ForestModifyResult {
2635                version: 3,
2636                affected_count: input.row_ids.len(),
2637            })
2638        }
2639        async fn remove_structure_row(
2640            &self,
2641            _structure_id: u64,
2642            _row_id: u64,
2643        ) -> devboy_core::Result<()> {
2644            Ok(())
2645        }
2646        async fn get_structure_values(
2647            &self,
2648            input: devboy_core::GetStructureValuesInput,
2649        ) -> devboy_core::Result<devboy_core::StructureValues> {
2650            Ok(devboy_core::StructureValues {
2651                structure_id: input.structure_id,
2652                values: vec![],
2653            })
2654        }
2655        async fn get_structure_views(
2656            &self,
2657            structure_id: u64,
2658            _view_id: Option<u64>,
2659        ) -> devboy_core::Result<Vec<devboy_core::StructureView>> {
2660            Ok(vec![sample_view(structure_id)])
2661        }
2662        async fn save_structure_view(
2663            &self,
2664            input: devboy_core::SaveStructureViewInput,
2665        ) -> devboy_core::Result<devboy_core::StructureView> {
2666            Ok(devboy_core::StructureView {
2667                id: input.id.unwrap_or(99),
2668                name: input.name,
2669                structure_id: input.structure_id,
2670                ..Default::default()
2671            })
2672        }
2673        async fn create_structure(
2674            &self,
2675            input: devboy_core::CreateStructureInput,
2676        ) -> devboy_core::Result<devboy_core::Structure> {
2677            Ok(devboy_core::Structure {
2678                id: 42,
2679                name: input.name,
2680                description: input.description,
2681            })
2682        }
2683        async fn list_project_versions(
2684            &self,
2685            params: devboy_core::ListProjectVersionsParams,
2686        ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::ProjectVersion>> {
2687            // Echo applied filters back through the data so dispatch
2688            // tests can pin behaviour without sniffing call args.
2689            let mut name = format!(
2690                "v-released={:?}-archived={:?}-limit={:?}-expand={}",
2691                params.released, params.archived, params.limit, params.include_issue_count
2692            );
2693            if !params.project.is_empty() {
2694                name.push_str(&format!("-project={}", params.project));
2695            }
2696            Ok(vec![devboy_core::ProjectVersion {
2697                id: "1".into(),
2698                project: if params.project.is_empty() {
2699                    "MOCK".into()
2700                } else {
2701                    params.project
2702                },
2703                name,
2704                description: Some("desc".into()),
2705                start_date: None,
2706                release_date: Some("2026-01-01".into()),
2707                released: false,
2708                archived: false,
2709                overdue: None,
2710                issue_count: Some(0),
2711                unresolved_issue_count: None,
2712                source: "mock".into(),
2713            }]
2714            .into())
2715        }
2716        async fn upsert_project_version(
2717            &self,
2718            input: devboy_core::UpsertProjectVersionInput,
2719        ) -> devboy_core::Result<devboy_core::ProjectVersion> {
2720            Ok(devboy_core::ProjectVersion {
2721                id: "777".into(),
2722                project: if input.project.is_empty() {
2723                    "MOCK".into()
2724                } else {
2725                    input.project
2726                },
2727                name: input.name,
2728                description: input.description,
2729                start_date: input.start_date,
2730                release_date: input.release_date,
2731                released: input.released.unwrap_or(false),
2732                archived: input.archived.unwrap_or(false),
2733                overdue: None,
2734                issue_count: None,
2735                unresolved_issue_count: None,
2736                source: "mock".into(),
2737            })
2738        }
2739        fn provider_name(&self) -> &'static str {
2740            "mock"
2741        }
2742    }
2743
2744    #[async_trait]
2745    impl MergeRequestProvider for MockProvider {
2746        async fn get_merge_requests(
2747            &self,
2748            _filter: MrFilter,
2749        ) -> devboy_core::Result<devboy_core::ProviderResult<MergeRequest>> {
2750            Ok(vec![sample_mr()].into())
2751        }
2752        async fn get_merge_request(&self, _key: &str) -> devboy_core::Result<MergeRequest> {
2753            Ok(sample_mr())
2754        }
2755        async fn get_discussions(
2756            &self,
2757            _key: &str,
2758        ) -> devboy_core::Result<devboy_core::ProviderResult<Discussion>> {
2759            Ok(vec![sample_discussion()].into())
2760        }
2761        async fn get_diffs(
2762            &self,
2763            _key: &str,
2764        ) -> devboy_core::Result<devboy_core::ProviderResult<FileDiff>> {
2765            Ok(vec![sample_diff()].into())
2766        }
2767        async fn add_comment(
2768            &self,
2769            _key: &str,
2770            _input: CreateCommentInput,
2771        ) -> devboy_core::Result<Comment> {
2772            Ok(sample_comment())
2773        }
2774        async fn create_merge_request(
2775            &self,
2776            _input: CreateMergeRequestInput,
2777        ) -> devboy_core::Result<MergeRequest> {
2778            Ok(sample_mr())
2779        }
2780        fn provider_name(&self) -> &'static str {
2781            "mock"
2782        }
2783    }
2784
2785    #[async_trait]
2786    impl devboy_core::PipelineProvider for MockProvider {
2787        fn provider_name(&self) -> &'static str {
2788            "mock"
2789        }
2790    }
2791
2792    #[async_trait]
2793    impl KnowledgeBaseProvider for MockProvider {
2794        fn provider_name(&self) -> &'static str {
2795            "mock"
2796        }
2797
2798        async fn get_spaces(&self) -> devboy_core::Result<devboy_core::ProviderResult<KbSpace>> {
2799            Ok(vec![sample_kb_space()].into())
2800        }
2801
2802        async fn list_pages(
2803            &self,
2804            _params: ListPagesParams,
2805        ) -> devboy_core::Result<devboy_core::ProviderResult<KbPage>> {
2806            Ok(vec![sample_kb_page()].into())
2807        }
2808
2809        async fn get_page(&self, _page_id: &str) -> devboy_core::Result<KbPageContent> {
2810            Ok(sample_kb_page_content())
2811        }
2812
2813        async fn create_page(
2814            &self,
2815            _params: devboy_core::CreatePageParams,
2816        ) -> devboy_core::Result<KbPage> {
2817            Ok(sample_kb_page())
2818        }
2819
2820        async fn update_page(
2821            &self,
2822            _params: devboy_core::UpdatePageParams,
2823        ) -> devboy_core::Result<KbPage> {
2824            Ok(sample_kb_page())
2825        }
2826
2827        async fn search(
2828            &self,
2829            _params: SearchKbParams,
2830        ) -> devboy_core::Result<devboy_core::ProviderResult<KbPage>> {
2831            Ok(vec![sample_kb_page()].into())
2832        }
2833    }
2834
2835    #[async_trait]
2836    impl Provider for MockProvider {
2837        async fn get_current_user(&self) -> devboy_core::Result<User> {
2838            Ok(User {
2839                id: "1".into(),
2840                username: "test".into(),
2841                name: None,
2842                email: None,
2843                avatar_url: None,
2844            })
2845        }
2846    }
2847
2848    // --- Tests ---
2849
2850    #[test]
2851    fn test_executor_new() {
2852        let executor = Executor::new();
2853        assert!(executor.enrichers.is_empty());
2854    }
2855
2856    #[test]
2857    fn test_supported_tools_contains_all() {
2858        assert!(SUPPORTED_TOOLS.contains(&"get_issues"));
2859        assert!(SUPPORTED_TOOLS.contains(&"get_merge_requests"));
2860        assert!(SUPPORTED_TOOLS.contains(&"create_merge_request_comment"));
2861        assert!(SUPPORTED_TOOLS.contains(&"get_meeting_notes"));
2862        assert!(SUPPORTED_TOOLS.contains(&"get_meeting_transcript"));
2863        assert!(SUPPORTED_TOOLS.contains(&"search_meeting_notes"));
2864        assert!(SUPPORTED_TOOLS.contains(&"get_knowledge_base_spaces"));
2865        assert!(SUPPORTED_TOOLS.contains(&"list_knowledge_base_pages"));
2866        assert!(SUPPORTED_TOOLS.contains(&"get_knowledge_base_page"));
2867        assert!(SUPPORTED_TOOLS.contains(&"create_knowledge_base_page"));
2868        assert!(SUPPORTED_TOOLS.contains(&"update_knowledge_base_page"));
2869        assert!(SUPPORTED_TOOLS.contains(&"search_knowledge_base"));
2870        assert!(SUPPORTED_TOOLS.contains(&"get_messenger_chats"));
2871        assert!(SUPPORTED_TOOLS.contains(&"get_chat_messages"));
2872        assert!(SUPPORTED_TOOLS.contains(&"search_chat_messages"));
2873        assert!(SUPPORTED_TOOLS.contains(&"send_message"));
2874        assert_eq!(SUPPORTED_TOOLS.len(), 40);
2875    }
2876
2877    #[tokio::test]
2878    async fn test_dispatch_get_knowledge_base_spaces() {
2879        let provider = MockProvider;
2880        let result =
2881            dispatch_knowledge_base_tool("get_knowledge_base_spaces", &Value::Null, &provider)
2882                .await
2883                .unwrap();
2884        assert!(matches!(result, ToolOutput::KnowledgeBaseSpaces(v, _) if v.len() == 1));
2885    }
2886
2887    #[tokio::test]
2888    async fn test_dispatch_list_knowledge_base_pages() {
2889        let provider = MockProvider;
2890        let args = serde_json::json!({"spaceKey": "ENG", "limit": 10});
2891        let result = dispatch_knowledge_base_tool("list_knowledge_base_pages", &args, &provider)
2892            .await
2893            .unwrap();
2894        assert!(matches!(result, ToolOutput::KnowledgeBasePages(v, _) if v.len() == 1));
2895    }
2896
2897    #[tokio::test]
2898    async fn test_dispatch_get_knowledge_base_page() {
2899        let provider = MockProvider;
2900        let args = serde_json::json!({"pageId": "page-1"});
2901        let result = dispatch_knowledge_base_tool("get_knowledge_base_page", &args, &provider)
2902            .await
2903            .unwrap();
2904        assert!(matches!(result, ToolOutput::KnowledgeBasePage(_)));
2905    }
2906
2907    #[tokio::test]
2908    async fn test_dispatch_create_knowledge_base_page() {
2909        let provider = MockProvider;
2910        let args = serde_json::json!({
2911            "spaceKey": "ENG",
2912            "title": "New Page",
2913            "content": "<p>body</p>",
2914            "contentType": "storage",
2915            "labels": ["docs"]
2916        });
2917        let result = dispatch_knowledge_base_tool("create_knowledge_base_page", &args, &provider)
2918            .await
2919            .unwrap();
2920        assert!(matches!(result, ToolOutput::KnowledgeBasePageSummary(_)));
2921    }
2922
2923    #[tokio::test]
2924    async fn test_dispatch_update_knowledge_base_page() {
2925        let provider = MockProvider;
2926        let args = serde_json::json!({
2927            "pageId": "page-1",
2928            "title": "Updated",
2929            "content": "<p>new body</p>",
2930            "version": 2
2931        });
2932        let result = dispatch_knowledge_base_tool("update_knowledge_base_page", &args, &provider)
2933            .await
2934            .unwrap();
2935        assert!(matches!(result, ToolOutput::KnowledgeBasePageSummary(_)));
2936    }
2937
2938    #[tokio::test]
2939    async fn test_dispatch_search_knowledge_base() {
2940        let provider = MockProvider;
2941        let args = serde_json::json!({"query": "architecture", "spaceKey": "ENG"});
2942        let result = dispatch_knowledge_base_tool("search_knowledge_base", &args, &provider)
2943            .await
2944            .unwrap();
2945        assert!(matches!(result, ToolOutput::KnowledgeBasePages(v, _) if v.len() == 1));
2946    }
2947
2948    // --- Issue tool dispatch tests ---
2949
2950    #[tokio::test]
2951    async fn test_dispatch_get_issues() {
2952        let provider = MockProvider;
2953        let args = serde_json::json!({"state": "open", "limit": 10});
2954        let result = dispatch_tool("get_issues", &args, &provider, None)
2955            .await
2956            .unwrap();
2957        assert!(matches!(result, ToolOutput::Issues(v, _) if v.len() == 1));
2958    }
2959
2960    #[tokio::test]
2961    async fn test_dispatch_get_issues_empty_args() {
2962        let provider = MockProvider;
2963        let result = dispatch_tool("get_issues", &Value::Null, &provider, None)
2964            .await
2965            .unwrap();
2966        assert!(matches!(result, ToolOutput::Issues(_, _)));
2967    }
2968
2969    #[tokio::test]
2970    async fn test_dispatch_get_issues_invalid_params_are_rejected() {
2971        // Regression for #188: before parse_tool_params the executor
2972        // silently accepted `{"state": 42}` by falling through to
2973        // default() and ran the tool without any filter. Now it must
2974        // surface the deserialisation error instead.
2975        let provider = MockProvider;
2976        let args = serde_json::json!({"state": 42});
2977        let err = dispatch_tool("get_issues", &args, &provider, None)
2978            .await
2979            .unwrap_err();
2980        assert!(
2981            matches!(err, devboy_core::Error::InvalidData(ref msg) if msg.contains("get_issues")),
2982            "expected InvalidData referencing get_issues, got {err:?}"
2983        );
2984    }
2985
2986    #[tokio::test]
2987    async fn test_dispatch_get_merge_requests_invalid_params_rejected() {
2988        let provider = MockProvider;
2989        let args = serde_json::json!({"limit": "not-a-number"});
2990        let err = dispatch_tool("get_merge_requests", &args, &provider, None)
2991            .await
2992            .unwrap_err();
2993        assert!(
2994            matches!(err, devboy_core::Error::InvalidData(ref msg) if msg.contains("get_merge_requests")),
2995            "expected InvalidData referencing get_merge_requests, got {err:?}"
2996        );
2997    }
2998
2999    #[tokio::test]
3000    async fn test_dispatch_get_pipeline_invalid_params_rejected() {
3001        let provider = MockProvider;
3002        let args = serde_json::json!({"includeFailedLogs": "yes"});
3003        let err = dispatch_tool("get_pipeline", &args, &provider, None)
3004            .await
3005            .unwrap_err();
3006        assert!(
3007            matches!(err, devboy_core::Error::InvalidData(ref msg) if msg.contains("get_pipeline")),
3008            "expected InvalidData referencing get_pipeline, got {err:?}"
3009        );
3010    }
3011
3012    #[test]
3013    fn parse_tool_params_null_yields_default() {
3014        #[derive(Debug, Default, serde::Deserialize)]
3015        struct P {
3016            #[allow(dead_code)]
3017            x: Option<String>,
3018        }
3019        let _: P = parse_tool_params(&Value::Null, "test").expect("null → default");
3020    }
3021
3022    #[test]
3023    fn parse_tool_params_empty_object_yields_default() {
3024        // MCP clients often send `{}` for "no arguments"; the helper
3025        // must accept it alongside `null`.
3026        #[derive(Debug, Default, serde::Deserialize)]
3027        struct P {
3028            #[allow(dead_code)]
3029            x: Option<String>,
3030        }
3031        let _: P = parse_tool_params(&serde_json::json!({}), "test").expect("{} → default");
3032    }
3033
3034    #[test]
3035    fn parse_tool_params_invalid_maps_to_invalid_data() {
3036        #[derive(Debug, Default, serde::Deserialize)]
3037        struct P {
3038            #[allow(dead_code)]
3039            n: u32,
3040        }
3041        let err = parse_tool_params::<P>(&serde_json::json!({"n": "nope"}), "tool-x").unwrap_err();
3042        assert!(
3043            matches!(err, devboy_core::Error::InvalidData(ref msg) if msg.contains("tool-x")),
3044            "expected InvalidData(tool-x), got {err:?}"
3045        );
3046    }
3047
3048    #[tokio::test]
3049    async fn test_dispatch_get_issue() {
3050        let provider = MockProvider;
3051        // With includeComments/includeRelations defaulting to true, returns composite Text
3052        let args = serde_json::json!({"key": "gh#1"});
3053        let result = dispatch_tool("get_issue", &args, &provider, None)
3054            .await
3055            .unwrap();
3056        assert!(matches!(result, ToolOutput::Text(_)));
3057
3058        // Without extras, returns SingleIssue
3059        let args =
3060            serde_json::json!({"key": "gh#1", "includeComments": false, "includeRelations": false});
3061        let result = dispatch_tool("get_issue", &args, &provider, None)
3062            .await
3063            .unwrap();
3064        assert!(matches!(result, ToolOutput::SingleIssue(_)));
3065    }
3066
3067    #[tokio::test]
3068    async fn test_dispatch_get_issue_missing_key() {
3069        let provider = MockProvider;
3070        let result = dispatch_tool("get_issue", &serde_json::json!({}), &provider, None).await;
3071        assert!(result.is_err());
3072    }
3073
3074    #[tokio::test]
3075    async fn test_dispatch_get_issue_comments() {
3076        let provider = MockProvider;
3077        let args = serde_json::json!({"key": "gh#1"});
3078        let result = dispatch_tool("get_issue_comments", &args, &provider, None)
3079            .await
3080            .unwrap();
3081        assert!(matches!(result, ToolOutput::Comments(v, _) if v.len() == 1));
3082    }
3083
3084    #[tokio::test]
3085    async fn test_dispatch_create_issue() {
3086        let provider = MockProvider;
3087        let args =
3088            serde_json::json!({"title": "New issue", "description": "Body", "labels": ["bug"]});
3089        let result = dispatch_tool("create_issue", &args, &provider, None)
3090            .await
3091            .unwrap();
3092        assert!(matches!(result, ToolOutput::SingleIssue(_)));
3093    }
3094
3095    #[test]
3096    fn create_issue_params_accepts_parent_id_alias() {
3097        let args = serde_json::json!({ "title": "t", "parentId": "DEV-799" });
3098        let params: CreateIssueParams = serde_json::from_value(args).unwrap();
3099        assert_eq!(params.parent.as_deref(), Some("DEV-799"));
3100    }
3101
3102    #[test]
3103    fn create_issue_params_still_accepts_parent() {
3104        let args = serde_json::json!({ "title": "t", "parent": "DEV-799" });
3105        let params: CreateIssueParams = serde_json::from_value(args).unwrap();
3106        assert_eq!(params.parent.as_deref(), Some("DEV-799"));
3107    }
3108
3109    #[tokio::test]
3110    async fn test_dispatch_update_issue() {
3111        let provider = MockProvider;
3112        let args = serde_json::json!({"key": "gh#1", "title": "Updated"});
3113        let result = dispatch_tool("update_issue", &args, &provider, None)
3114            .await
3115            .unwrap();
3116        assert!(matches!(result, ToolOutput::SingleIssue(_)));
3117    }
3118
3119    #[tokio::test]
3120    async fn test_dispatch_add_issue_comment() {
3121        let provider = MockProvider;
3122        let args = serde_json::json!({"key": "gh#1", "body": "A comment"});
3123        let result = dispatch_tool("add_issue_comment", &args, &provider, None)
3124            .await
3125            .unwrap();
3126        assert!(matches!(result, ToolOutput::Text(ref t) if t.contains("Comment added")));
3127    }
3128
3129    #[tokio::test]
3130    async fn test_dispatch_get_issue_relations() {
3131        let provider = MockProvider;
3132        let args = serde_json::json!({"key": "gh#1"});
3133        let result = dispatch_tool("get_issue_relations", &args, &provider, None)
3134            .await
3135            .unwrap();
3136        match result {
3137            ToolOutput::Relations(relations) => {
3138                assert!(relations.parent.is_some());
3139                assert_eq!(relations.subtasks.len(), 1);
3140                assert_eq!(relations.blocks.len(), 1);
3141            }
3142            other => panic!("Expected Relations, got {:?}", other),
3143        }
3144    }
3145
3146    #[tokio::test]
3147    async fn test_dispatch_get_issue_relations_missing_key() {
3148        let provider = MockProvider;
3149        let result = dispatch_tool(
3150            "get_issue_relations",
3151            &serde_json::json!({}),
3152            &provider,
3153            None,
3154        )
3155        .await;
3156        assert!(result.is_err());
3157    }
3158
3159    // --- MR tool dispatch tests ---
3160
3161    #[tokio::test]
3162    async fn test_dispatch_get_merge_requests() {
3163        let provider = MockProvider;
3164        let args = serde_json::json!({"state": "open", "limit": 5});
3165        let result = dispatch_tool("get_merge_requests", &args, &provider, None)
3166            .await
3167            .unwrap();
3168        assert!(matches!(result, ToolOutput::MergeRequests(v, _) if v.len() == 1));
3169    }
3170
3171    #[tokio::test]
3172    async fn test_dispatch_get_merge_requests_empty_args() {
3173        let provider = MockProvider;
3174        let result = dispatch_tool("get_merge_requests", &Value::Null, &provider, None)
3175            .await
3176            .unwrap();
3177        assert!(matches!(result, ToolOutput::MergeRequests(_, _)));
3178    }
3179
3180    #[tokio::test]
3181    async fn test_dispatch_get_merge_request() {
3182        let provider = MockProvider;
3183        let args = serde_json::json!({"key": "pr#1"});
3184        let result = dispatch_tool("get_merge_request", &args, &provider, None)
3185            .await
3186            .unwrap();
3187        assert!(matches!(result, ToolOutput::SingleMergeRequest(_)));
3188    }
3189
3190    #[tokio::test]
3191    async fn test_dispatch_get_merge_request_discussions() {
3192        let provider = MockProvider;
3193        let args = serde_json::json!({"key": "pr#1"});
3194        let result = dispatch_tool("get_merge_request_discussions", &args, &provider, None)
3195            .await
3196            .unwrap();
3197        assert!(matches!(result, ToolOutput::Discussions(v, _) if v.len() == 1));
3198    }
3199
3200    #[tokio::test]
3201    async fn test_dispatch_get_merge_request_diffs() {
3202        let provider = MockProvider;
3203        let args = serde_json::json!({"key": "pr#1"});
3204        let result = dispatch_tool("get_merge_request_diffs", &args, &provider, None)
3205            .await
3206            .unwrap();
3207        assert!(matches!(result, ToolOutput::Diffs(v, _) if v.len() == 1));
3208    }
3209
3210    #[tokio::test]
3211    async fn test_dispatch_create_merge_request() {
3212        let provider = MockProvider;
3213        let args = serde_json::json!({
3214            "title": "New PR",
3215            "source_branch": "feature",
3216            "target_branch": "main",
3217            "draft": false
3218        });
3219        let result = dispatch_tool("create_merge_request", &args, &provider, None)
3220            .await
3221            .unwrap();
3222        assert!(matches!(result, ToolOutput::SingleMergeRequest(_)));
3223    }
3224
3225    #[tokio::test]
3226    async fn test_dispatch_create_merge_request_comment_general() {
3227        let provider = MockProvider;
3228        let args = serde_json::json!({"key": "pr#1", "body": "LGTM"});
3229        let result = dispatch_tool("create_merge_request_comment", &args, &provider, None)
3230            .await
3231            .unwrap();
3232        assert!(matches!(result, ToolOutput::Text(ref t) if t.contains("Comment added")));
3233    }
3234
3235    #[tokio::test]
3236    async fn test_dispatch_create_merge_request_comment_inline() {
3237        let provider = MockProvider;
3238        let args = serde_json::json!({
3239            "key": "pr#1",
3240            "body": "Fix this line",
3241            "file_path": "src/main.rs",
3242            "line": 42,
3243            "line_type": "new",
3244            "commit_sha": "abc123"
3245        });
3246        let result = dispatch_tool("create_merge_request_comment", &args, &provider, None)
3247            .await
3248            .unwrap();
3249        assert!(matches!(result, ToolOutput::Text(ref t) if t.contains("Comment added")));
3250    }
3251
3252    #[test]
3253    fn test_create_merge_request_comment_params_accept_camel_case() {
3254        let args = serde_json::json!({
3255            "mrKey": "mr#566",
3256            "body": "reply",
3257            "filePath": "src/main.rs",
3258            "line": 12,
3259            "lineType": "new",
3260            "commitSha": "abc123",
3261            "discussionId": "788adb16c57805c9a5d59272c944cddea381a605"
3262        });
3263
3264        let params: CreateMrCommentParams = serde_json::from_value(args).unwrap();
3265        assert_eq!(params.key, "mr#566");
3266        assert_eq!(params.file_path.as_deref(), Some("src/main.rs"));
3267        assert_eq!(params.line_type.as_deref(), Some("new"));
3268        assert_eq!(params.commit_sha.as_deref(), Some("abc123"));
3269        assert_eq!(
3270            params.discussion_id.as_deref(),
3271            Some("788adb16c57805c9a5d59272c944cddea381a605")
3272        );
3273    }
3274
3275    #[test]
3276    fn test_create_merge_request_comment_params_still_accept_snake_case() {
3277        // Belt-and-suspenders: the camelCase aliases must not break the
3278        // original snake_case payload shape, which the MCP schema
3279        // declares and which some callers (our own skills included)
3280        // send today.
3281        let args = serde_json::json!({
3282            "key": "mr#566",
3283            "body": "reply",
3284            "file_path": "src/main.rs",
3285            "line": 12,
3286            "line_type": "new",
3287            "commit_sha": "abc123",
3288            "discussion_id": "788adb16c57805c9a5d59272c944cddea381a605"
3289        });
3290
3291        let params: CreateMrCommentParams = serde_json::from_value(args).unwrap();
3292        assert_eq!(params.key, "mr#566");
3293        assert_eq!(params.file_path.as_deref(), Some("src/main.rs"));
3294        assert_eq!(params.line_type.as_deref(), Some("new"));
3295        assert_eq!(params.commit_sha.as_deref(), Some("abc123"));
3296        assert_eq!(
3297            params.discussion_id.as_deref(),
3298            Some("788adb16c57805c9a5d59272c944cddea381a605")
3299        );
3300    }
3301
3302    #[tokio::test]
3303    async fn test_dispatch_create_merge_request_comment_accepts_camel_case_args() {
3304        // End-to-end: the executor's `dispatch_tool` path must accept
3305        // the same camelCase payload that real MCP clients (and our
3306        // skills) send, otherwise the alias would only help direct
3307        // `from_value` callers.
3308        let provider = MockProvider;
3309        let args = serde_json::json!({
3310            "mrKey": "mr#1",
3311            "body": "threaded reply",
3312            "discussionId": "abc123"
3313        });
3314        let result = dispatch_tool("create_merge_request_comment", &args, &provider, None)
3315            .await
3316            .unwrap();
3317        assert!(matches!(result, ToolOutput::Text(ref t) if t.contains("Comment added")));
3318    }
3319
3320    #[tokio::test]
3321    async fn test_dispatch_unknown_tool() {
3322        let provider = MockProvider;
3323        let result = dispatch_tool("nonexistent_tool", &Value::Null, &provider, None).await;
3324        assert!(result.is_err());
3325    }
3326
3327    // --- Executor enricher integration ---
3328
3329    #[tokio::test]
3330    async fn test_executor_enricher_transforms_args() {
3331        use devboy_core::{ToolEnricher, ToolSchema};
3332
3333        struct TestEnricher;
3334        impl ToolEnricher for TestEnricher {
3335            fn supported_categories(&self) -> &[devboy_core::ToolCategory] {
3336                &[devboy_core::ToolCategory::IssueTracker]
3337            }
3338            fn enrich_schema(&self, _tool: &str, _schema: &mut ToolSchema) {}
3339            fn transform_args(&self, _tool: &str, args: &mut Value) {
3340                if let Some(obj) = args.as_object_mut() {
3341                    obj.insert("transformed".into(), Value::Bool(true));
3342                }
3343            }
3344        }
3345
3346        let mut executor = Executor::new();
3347        executor.add_enricher(Box::new(TestEnricher));
3348        assert_eq!(executor.enrichers.len(), 1);
3349    }
3350
3351    // --- Pipeline dispatch tests ---
3352
3353    #[tokio::test]
3354    async fn test_dispatch_get_pipeline_unsupported() {
3355        let provider = MockProvider;
3356        let args = serde_json::json!({"branch": "main"});
3357        let result = dispatch_tool("get_pipeline", &args, &provider, None).await;
3358        // MockProvider doesn't implement get_pipeline → ProviderUnsupported
3359        assert!(result.is_err());
3360    }
3361
3362    #[tokio::test]
3363    async fn test_dispatch_get_job_logs_unsupported() {
3364        let provider = MockProvider;
3365        let args = serde_json::json!({"jobId": "123"});
3366        let result = dispatch_tool("get_job_logs", &args, &provider, None).await;
3367        assert!(result.is_err());
3368    }
3369
3370    #[tokio::test]
3371    async fn test_dispatch_get_pipeline_with_mr_key() {
3372        let provider = MockProvider;
3373        let args = serde_json::json!({"mrKey": "pr#1", "includeFailedLogs": false});
3374        let result = dispatch_tool("get_pipeline", &args, &provider, None).await;
3375        assert!(result.is_err());
3376    }
3377
3378    #[tokio::test]
3379    async fn test_dispatch_get_job_logs_with_pattern() {
3380        let provider = MockProvider;
3381        let args = serde_json::json!({"jobId": "123", "pattern": "ERROR", "context": 3});
3382        let result = dispatch_tool("get_job_logs", &args, &provider, None).await;
3383        assert!(result.is_err());
3384    }
3385
3386    #[tokio::test]
3387    async fn test_dispatch_get_job_logs_paginated() {
3388        let provider = MockProvider;
3389        let args = serde_json::json!({"jobId": "123", "offset": 10, "limit": 50});
3390        let result = dispatch_tool("get_job_logs", &args, &provider, None).await;
3391        assert!(result.is_err());
3392    }
3393
3394    #[tokio::test]
3395    async fn test_dispatch_get_job_logs_full() {
3396        let provider = MockProvider;
3397        let args = serde_json::json!({"jobId": "123", "full": true});
3398        let result = dispatch_tool("get_job_logs", &args, &provider, None).await;
3399        assert!(result.is_err());
3400    }
3401
3402    #[test]
3403    fn test_executor_default() {
3404        let executor = Executor::default();
3405        assert!(executor.enrichers.is_empty());
3406    }
3407
3408    // --- Status / User / Link / Epic dispatch tests ---
3409
3410    #[tokio::test]
3411    async fn test_dispatch_get_available_statuses_unsupported() {
3412        let provider = MockProvider;
3413        let result = dispatch_tool("get_available_statuses", &Value::Null, &provider, None).await;
3414        // MockProvider returns ProviderUnsupported for get_statuses
3415        assert!(result.is_err());
3416    }
3417
3418    #[tokio::test]
3419    async fn test_dispatch_get_users_unsupported() {
3420        let provider = MockProvider;
3421        let args = serde_json::json!({"search": "test"});
3422        let result = dispatch_tool("get_users", &args, &provider, None).await;
3423        // MockProvider uses default impl which returns ProviderUnsupported
3424        assert!(result.is_err());
3425    }
3426
3427    #[tokio::test]
3428    async fn test_dispatch_link_issues_unsupported() {
3429        let provider = MockProvider;
3430        let args = serde_json::json!({
3431            "source_key": "gh#1",
3432            "target_key": "gh#2",
3433            "link_type": "blocks"
3434        });
3435        let result = dispatch_tool("link_issues", &args, &provider, None).await;
3436        assert!(result.is_err());
3437    }
3438
3439    #[tokio::test]
3440    async fn test_dispatch_get_epics() {
3441        let provider = MockProvider;
3442        let args = serde_json::json!({"state": "open", "limit": 10});
3443        let result = dispatch_tool("get_epics", &args, &provider, None)
3444            .await
3445            .unwrap();
3446        // Returns enriched JSON with goal_id and progress
3447        assert!(matches!(result, ToolOutput::Text(_)));
3448    }
3449
3450    #[tokio::test]
3451    async fn test_dispatch_get_epics_empty_args() {
3452        let provider = MockProvider;
3453        let result = dispatch_tool("get_epics", &Value::Null, &provider, None)
3454            .await
3455            .unwrap();
3456        assert!(matches!(result, ToolOutput::Text(_)));
3457    }
3458
3459    #[tokio::test]
3460    async fn test_dispatch_create_epic() {
3461        let provider = MockProvider;
3462        let args = serde_json::json!({"title": "New Epic", "description": "Epic description"});
3463        let result = dispatch_tool("create_epic", &args, &provider, None)
3464            .await
3465            .unwrap();
3466        assert!(matches!(result, ToolOutput::SingleIssue(_)));
3467    }
3468
3469    #[tokio::test]
3470    async fn test_dispatch_update_epic() {
3471        let provider = MockProvider;
3472        let args = serde_json::json!({"key": "gh#1", "title": "Updated Epic"});
3473        let result = dispatch_tool("update_epic", &args, &provider, None)
3474            .await
3475            .unwrap();
3476        assert!(matches!(result, ToolOutput::SingleIssue(_)));
3477    }
3478
3479    #[tokio::test]
3480    async fn test_dispatch_link_issues_missing_params() {
3481        let provider = MockProvider;
3482        let args = serde_json::json!({"source_key": "gh#1"});
3483        let result = dispatch_tool("link_issues", &args, &provider, None).await;
3484        assert!(result.is_err());
3485    }
3486
3487    // --- Mock MeetingNotesProvider tests ---
3488
3489    struct MockMeetingProvider;
3490
3491    #[async_trait]
3492    impl MeetingNotesProvider for MockMeetingProvider {
3493        fn provider_name(&self) -> &'static str {
3494            "mock_meetings"
3495        }
3496
3497        async fn get_meetings(
3498            &self,
3499            _filter: MeetingFilter,
3500        ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::MeetingNote>> {
3501            Ok(vec![devboy_core::MeetingNote {
3502                id: "m1".into(),
3503                title: "Test Meeting".into(),
3504                ..Default::default()
3505            }]
3506            .into())
3507        }
3508
3509        async fn get_transcript(
3510            &self,
3511            meeting_id: &str,
3512        ) -> devboy_core::Result<devboy_core::MeetingTranscript> {
3513            Ok(devboy_core::MeetingTranscript {
3514                meeting_id: meeting_id.to_string(),
3515                title: Some("Test Transcript".into()),
3516                sentences: vec![devboy_core::TranscriptSentence {
3517                    speaker_id: "s1".into(),
3518                    speaker_name: Some("Alice".into()),
3519                    text: "Hello".into(),
3520                    start_time: 0.0,
3521                    end_time: 1.0,
3522                }],
3523            })
3524        }
3525
3526        async fn search_meetings(
3527            &self,
3528            _query: &str,
3529            _filter: MeetingFilter,
3530        ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::MeetingNote>> {
3531            Ok(vec![devboy_core::MeetingNote {
3532                id: "m2".into(),
3533                title: "Search Result Meeting".into(),
3534                ..Default::default()
3535            }]
3536            .into())
3537        }
3538    }
3539
3540    #[tokio::test]
3541    async fn test_dispatch_get_meeting_notes() {
3542        let provider = MockMeetingProvider;
3543        let args = serde_json::json!({"from_date": "2025-01-01", "limit": 10});
3544        let result = dispatch_meeting_tool("get_meeting_notes", &args, &provider)
3545            .await
3546            .unwrap();
3547        match result {
3548            ToolOutput::MeetingNotes(meetings, _) => {
3549                assert_eq!(meetings.len(), 1);
3550                assert_eq!(meetings[0].title, "Test Meeting");
3551            }
3552            other => panic!("Expected MeetingNotes, got {:?}", other),
3553        }
3554    }
3555
3556    #[tokio::test]
3557    async fn test_dispatch_get_meeting_transcript() {
3558        let provider = MockMeetingProvider;
3559        let args = serde_json::json!({"meeting_id": "m1"});
3560        let result = dispatch_meeting_tool("get_meeting_transcript", &args, &provider)
3561            .await
3562            .unwrap();
3563        match result {
3564            ToolOutput::MeetingTranscript(transcript) => {
3565                assert_eq!(transcript.meeting_id, "m1");
3566                assert_eq!(transcript.sentences.len(), 1);
3567                assert_eq!(transcript.sentences[0].speaker_name, Some("Alice".into()));
3568            }
3569            other => panic!("Expected MeetingTranscript, got {:?}", other),
3570        }
3571    }
3572
3573    #[tokio::test]
3574    async fn test_dispatch_search_meeting_notes() {
3575        let provider = MockMeetingProvider;
3576        let args = serde_json::json!({"query": "sprint", "limit": 5});
3577        let result = dispatch_meeting_tool("search_meeting_notes", &args, &provider)
3578            .await
3579            .unwrap();
3580        match result {
3581            ToolOutput::MeetingNotes(meetings, _) => {
3582                assert_eq!(meetings.len(), 1);
3583                assert_eq!(meetings[0].title, "Search Result Meeting");
3584            }
3585            other => panic!("Expected MeetingNotes, got {:?}", other),
3586        }
3587    }
3588
3589    #[tokio::test]
3590    async fn test_dispatch_unknown_meeting_tool() {
3591        let provider = MockMeetingProvider;
3592        let result = dispatch_meeting_tool("nonexistent_tool", &Value::Null, &provider).await;
3593        assert!(result.is_err());
3594    }
3595
3596    // =========================================================================
3597    // Structure tool dispatch tests
3598    // =========================================================================
3599
3600    fn sample_structure() -> devboy_core::Structure {
3601        devboy_core::Structure {
3602            id: 1,
3603            name: "Q1 Plan".into(),
3604            description: Some("Quarter 1 planning".into()),
3605        }
3606    }
3607
3608    fn sample_forest(structure_id: u64) -> devboy_core::StructureForest {
3609        devboy_core::StructureForest {
3610            version: 1,
3611            structure_id,
3612            tree: vec![devboy_core::StructureNode {
3613                row_id: 100,
3614                item_id: Some("PROJ-1".into()),
3615                item_type: Some("issue".into()),
3616                children: vec![],
3617            }],
3618            total_count: Some(1),
3619        }
3620    }
3621
3622    fn sample_view(structure_id: u64) -> devboy_core::StructureView {
3623        devboy_core::StructureView {
3624            id: 10,
3625            name: "Default".into(),
3626            structure_id,
3627            ..Default::default()
3628        }
3629    }
3630
3631    #[tokio::test]
3632    async fn test_dispatch_get_structures() {
3633        let provider = MockProvider;
3634        let result = dispatch_tool("get_structures", &Value::Null, &provider, None)
3635            .await
3636            .unwrap();
3637        assert!(matches!(result, ToolOutput::Structures(ref items, _) if items.len() == 1));
3638        assert_eq!(result.type_name(), "structures");
3639    }
3640
3641    #[tokio::test]
3642    async fn test_dispatch_get_structure_forest() {
3643        let provider = MockProvider;
3644        let args = serde_json::json!({"structureId": 1});
3645        let result = dispatch_tool("get_structure_forest", &args, &provider, None)
3646            .await
3647            .unwrap();
3648        assert!(matches!(result, ToolOutput::StructureForest(_)));
3649        assert_eq!(result.type_name(), "structure_forest");
3650    }
3651
3652    #[tokio::test]
3653    async fn test_dispatch_get_structure_forest_missing_id() {
3654        let provider = MockProvider;
3655        let result = dispatch_tool("get_structure_forest", &Value::Null, &provider, None).await;
3656        assert!(result.is_err());
3657    }
3658
3659    #[tokio::test]
3660    async fn test_dispatch_add_structure_rows() {
3661        let provider = MockProvider;
3662        let args = serde_json::json!({
3663            "structureId": 1,
3664            "items": ["PROJ-1", "PROJ-2"],
3665            "under": 100
3666        });
3667        let result = dispatch_tool("add_structure_rows", &args, &provider, None)
3668            .await
3669            .unwrap();
3670        match result {
3671            ToolOutput::ForestModified(r) => {
3672                assert_eq!(r.version, 2);
3673                assert_eq!(r.affected_count, 2);
3674            }
3675            _ => panic!("expected ForestModified"),
3676        }
3677    }
3678
3679    #[tokio::test]
3680    async fn test_dispatch_move_structure_rows() {
3681        let provider = MockProvider;
3682        let args = serde_json::json!({
3683            "structureId": 1,
3684            "rowIds": [100, 101],
3685            "under": 200
3686        });
3687        let result = dispatch_tool("move_structure_rows", &args, &provider, None)
3688            .await
3689            .unwrap();
3690        assert!(matches!(result, ToolOutput::ForestModified(_)));
3691    }
3692
3693    #[tokio::test]
3694    async fn test_dispatch_remove_structure_row() {
3695        let provider = MockProvider;
3696        let args = serde_json::json!({"structureId": 1, "rowId": 100});
3697        let result = dispatch_tool("remove_structure_row", &args, &provider, None)
3698            .await
3699            .unwrap();
3700        assert!(matches!(result, ToolOutput::Text(_)));
3701    }
3702
3703    #[tokio::test]
3704    async fn test_dispatch_get_structure_values() {
3705        let provider = MockProvider;
3706        let args = serde_json::json!({
3707            "structureId": 1,
3708            "rows": [100],
3709            "columns": ["summary", {"field": "status"}]
3710        });
3711        let result = dispatch_tool("get_structure_values", &args, &provider, None)
3712            .await
3713            .unwrap();
3714        assert!(matches!(result, ToolOutput::StructureValues(_)));
3715    }
3716
3717    #[tokio::test]
3718    async fn test_dispatch_get_structure_views() {
3719        let provider = MockProvider;
3720        let args = serde_json::json!({"structureId": 1});
3721        let result = dispatch_tool("get_structure_views", &args, &provider, None)
3722            .await
3723            .unwrap();
3724        assert!(matches!(result, ToolOutput::StructureViews(views, _) if views.len() == 1));
3725    }
3726
3727    #[tokio::test]
3728    async fn test_dispatch_save_structure_view() {
3729        let provider = MockProvider;
3730        let args = serde_json::json!({
3731            "structureId": 1,
3732            "name": "Sprint View"
3733        });
3734        let result = dispatch_tool("save_structure_view", &args, &provider, None)
3735            .await
3736            .unwrap();
3737        assert!(
3738            matches!(result, ToolOutput::StructureViews(views, _) if views[0].name == "Sprint View")
3739        );
3740    }
3741
3742    #[tokio::test]
3743    async fn test_dispatch_create_structure() {
3744        let provider = MockProvider;
3745        let args = serde_json::json!({"name": "New Structure", "description": "Test"});
3746        let result = dispatch_tool("create_structure", &args, &provider, None)
3747            .await
3748            .unwrap();
3749        match result {
3750            ToolOutput::Structures(items, _) => {
3751                assert_eq!(items[0].name, "New Structure");
3752                assert_eq!(items[0].id, 42);
3753            }
3754            _ => panic!("expected Structures"),
3755        }
3756    }
3757
3758    // -------------------------------------------------------------------
3759    // Project versions / fixVersion (issue #238)
3760    // -------------------------------------------------------------------
3761
3762    #[tokio::test]
3763    async fn test_dispatch_list_project_versions_applies_paper_defaults() {
3764        // No filter args → archived defaults to false, limit to 20.
3765        let provider = MockProvider;
3766        let result = dispatch_tool(
3767            "list_project_versions",
3768            &serde_json::json!({}),
3769            &provider,
3770            None,
3771        )
3772        .await
3773        .unwrap();
3774        match result {
3775            ToolOutput::ProjectVersions(items, _) => {
3776                let echoed = &items[0].name;
3777                assert!(echoed.contains("released=None"), "got {echoed}");
3778                assert!(echoed.contains("archived=Some(false)"), "got {echoed}");
3779                assert!(echoed.contains("limit=Some(20)"), "got {echoed}");
3780                assert!(echoed.contains("expand=false"), "got {echoed}");
3781            }
3782            other => panic!("expected ProjectVersions, got {other:?}"),
3783        }
3784    }
3785
3786    #[tokio::test]
3787    async fn test_dispatch_list_project_versions_explicit_filters_override_defaults() {
3788        let provider = MockProvider;
3789        let args = serde_json::json!({
3790            "project": "PROJ",
3791            "released": "true",
3792            "archived": "all",
3793            "limit": 5,
3794            "includeIssueCount": true,
3795        });
3796        let result = dispatch_tool("list_project_versions", &args, &provider, None)
3797            .await
3798            .unwrap();
3799        match result {
3800            ToolOutput::ProjectVersions(items, _) => {
3801                let echoed = &items[0].name;
3802                assert!(echoed.contains("released=Some(true)"), "got {echoed}");
3803                assert!(echoed.contains("archived=None"), "got {echoed}");
3804                assert!(echoed.contains("limit=Some(5)"), "got {echoed}");
3805                assert!(echoed.contains("expand=true"), "got {echoed}");
3806                assert_eq!(items[0].project, "PROJ");
3807            }
3808            other => panic!("expected ProjectVersions, got {other:?}"),
3809        }
3810    }
3811
3812    #[tokio::test]
3813    async fn test_dispatch_list_project_versions_rejects_unknown_filter() {
3814        let provider = MockProvider;
3815        let err = dispatch_tool(
3816            "list_project_versions",
3817            &serde_json::json!({"released": "maybe"}),
3818            &provider,
3819            None,
3820        )
3821        .await
3822        .unwrap_err();
3823        assert!(
3824            matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("'maybe'")),
3825            "expected InvalidData about 'maybe', got {err:?}"
3826        );
3827    }
3828
3829    #[tokio::test]
3830    async fn test_dispatch_upsert_project_version_returns_single() {
3831        let provider = MockProvider;
3832        let args = serde_json::json!({
3833            "project": "PROJ",
3834            "name": "3.18.0",
3835            "description": "release notes",
3836            "released": true,
3837            "releaseDate": "2026-05-01",
3838        });
3839        let result = dispatch_tool("upsert_project_version", &args, &provider, None)
3840            .await
3841            .unwrap();
3842        match result {
3843            ToolOutput::SingleProjectVersion(v) => {
3844                assert_eq!(v.name, "3.18.0");
3845                assert_eq!(v.project, "PROJ");
3846                assert!(v.released);
3847                assert_eq!(v.release_date.as_deref(), Some("2026-05-01"));
3848                assert_eq!(v.description.as_deref(), Some("release notes"));
3849            }
3850            other => panic!("expected SingleProjectVersion, got {other:?}"),
3851        }
3852    }
3853
3854    #[tokio::test]
3855    async fn test_dispatch_upsert_project_version_requires_name() {
3856        let provider = MockProvider;
3857        let err = dispatch_tool(
3858            "upsert_project_version",
3859            &serde_json::json!({"project": "PROJ"}),
3860            &provider,
3861            None,
3862        )
3863        .await
3864        .unwrap_err();
3865        assert!(matches!(err, devboy_core::Error::InvalidData(_)));
3866    }
3867
3868    #[test]
3869    fn parse_tri_filter_accepts_canonical_strings() {
3870        assert_eq!(parse_tri_filter(None).unwrap(), None);
3871        assert_eq!(parse_tri_filter(Some("all")).unwrap(), None);
3872        assert_eq!(parse_tri_filter(Some("True")).unwrap(), Some(true));
3873        assert_eq!(parse_tri_filter(Some("false")).unwrap(), Some(false));
3874        assert_eq!(parse_tri_filter(Some("yes")).unwrap(), Some(true));
3875        assert_eq!(parse_tri_filter(Some("0")).unwrap(), Some(false));
3876        assert!(parse_tri_filter(Some("maybe")).is_err());
3877    }
3878
3879    #[test]
3880    fn validate_iso_date_accepts_yyyy_mm_dd() {
3881        assert!(validate_iso_date("releaseDate", "2026-05-04").is_ok());
3882        assert!(validate_iso_date("releaseDate", "2026-12-31").is_ok());
3883    }
3884
3885    #[test]
3886    fn validate_iso_date_rejects_other_shapes() {
3887        // Wrong shape
3888        assert!(validate_iso_date("releaseDate", "2026/05/04").is_err());
3889        assert!(validate_iso_date("releaseDate", "2026-5-4").is_err());
3890        assert!(validate_iso_date("releaseDate", "2026-05-04T00:00:00Z").is_err());
3891        assert!(validate_iso_date("releaseDate", "tomorrow").is_err());
3892        // Out-of-range month / day
3893        assert!(validate_iso_date("releaseDate", "2026-13-01").is_err());
3894        assert!(validate_iso_date("releaseDate", "2026-00-15").is_err());
3895        assert!(validate_iso_date("releaseDate", "2026-05-32").is_err());
3896    }
3897
3898    #[tokio::test]
3899    async fn test_dispatch_upsert_project_version_rejects_bad_date() {
3900        let provider = MockProvider;
3901        let err = dispatch_tool(
3902            "upsert_project_version",
3903            &serde_json::json!({"name": "3.18.0", "releaseDate": "next friday"}),
3904            &provider,
3905            None,
3906        )
3907        .await
3908        .unwrap_err();
3909        assert!(
3910            matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("releaseDate")),
3911            "expected InvalidData about releaseDate, got {err:?}"
3912        );
3913    }
3914
3915    #[tokio::test]
3916    async fn test_dispatch_list_project_versions_rejects_zero_limit() {
3917        let provider = MockProvider;
3918        let err = dispatch_tool(
3919            "list_project_versions",
3920            &serde_json::json!({"limit": 0}),
3921            &provider,
3922            None,
3923        )
3924        .await
3925        .unwrap_err();
3926        assert!(
3927            matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("limit")),
3928            "expected InvalidData about limit, got {err:?}"
3929        );
3930    }
3931
3932    // -------------------------------------------------------------------
3933    // Regression: structure-specific arg parsing
3934    // -------------------------------------------------------------------
3935
3936    #[test]
3937    fn parse_row_item_bare_string_becomes_item_id() {
3938        let item = parse_structure_row_item(serde_json::json!("PROJ-1")).unwrap();
3939        assert_eq!(item.item_id, "PROJ-1");
3940        assert!(item.item_type.is_none());
3941    }
3942
3943    #[test]
3944    fn parse_row_item_json_object_string_parses_fields() {
3945        let item = parse_structure_row_item(serde_json::json!(
3946            "{\"item_id\":\"PROJ-2\",\"item_type\":\"issue\"}"
3947        ))
3948        .unwrap();
3949        assert_eq!(item.item_id, "PROJ-2");
3950        assert_eq!(item.item_type.as_deref(), Some("issue"));
3951    }
3952
3953    #[test]
3954    fn parse_row_item_malformed_json_object_is_error() {
3955        // Valid JSON object but fields do not match StructureRowItem.
3956        let err = parse_structure_row_item(serde_json::json!("{\"wrong\":true}")).unwrap_err();
3957        assert!(matches!(err, Error::InvalidData(_)));
3958    }
3959
3960    #[test]
3961    fn parse_column_spec_bare_string_sets_field() {
3962        let col = parse_structure_column_spec(serde_json::json!("summary")).unwrap();
3963        assert_eq!(col.field.as_deref(), Some("summary"));
3964        assert!(col.formula.is_none());
3965    }
3966
3967    #[test]
3968    fn parse_column_spec_formula_json_string_parses() {
3969        let col = parse_structure_column_spec(serde_json::json!(
3970            "{\"formula\":\"SUM(\\\"Story Points\\\")\"}"
3971        ))
3972        .unwrap();
3973        assert!(col.field.is_none());
3974        assert_eq!(col.formula.as_deref(), Some("SUM(\"Story Points\")"));
3975    }
3976
3977    #[test]
3978    fn parse_column_spec_object_value_is_deserialised() {
3979        // A real JSON object (not a stringified one) should also work.
3980        let col = parse_structure_column_spec(serde_json::json!({"field": "status", "width": 120}))
3981            .unwrap();
3982        assert_eq!(col.field.as_deref(), Some("status"));
3983        assert_eq!(col.width, Some(120));
3984    }
3985}