Skip to main content

devboy_executor/
executor.rs

1use devboy_core::types::ChatType;
2use devboy_core::{
3    AddStructureRowsInput, AssignToSprintInput, CreateCommentInput, CreateIssueInput,
4    CreateMergeRequestInput, CreatePageParams, CreateStructureInput, Error, GetChatsParams,
5    GetForestOptions, GetMessagesParams, GetPipelineInput, GetStructureValuesInput,
6    GetUsersOptions, IssueFilter, IssueProvider, JobLogMode, JobLogOptions, KnowledgeBaseProvider,
7    ListCustomFieldsParams, ListPagesParams, ListProjectVersionsParams, MeetingFilter,
8    MeetingNotesProvider, MergeRequestProvider, MessengerProvider, MoveStructureRowsInput,
9    MrFilter, PipelineProvider, Result, SaveStructureViewInput, SearchKbParams,
10    SearchMessagesParams, SendMessageParams, SprintState, StructureRowItem, StructureViewColumn,
11    ToolCategory, UpdateIssueInput, UpdatePageParams, 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        // Agile / Sprint (issue #198)
668        "get_board_sprints" => execute_get_board_sprints(provider, args).await,
669        "assign_to_sprint" => execute_assign_to_sprint(provider, args).await,
670
671        // Custom-field discovery
672        "get_custom_fields" => execute_get_custom_fields(provider, args).await,
673
674        _ => Err(Error::NotFound(format!("unknown tool: {tool}"))),
675    }
676}
677
678/// Dispatch a meeting notes tool call.
679async fn dispatch_meeting_tool(
680    tool: &str,
681    args: &Value,
682    provider: &dyn MeetingNotesProvider,
683) -> Result<ToolOutput> {
684    match tool {
685        "get_meeting_notes" => execute_get_meeting_notes(provider, args).await,
686        "get_meeting_transcript" => execute_get_meeting_transcript(provider, args).await,
687        "search_meeting_notes" => execute_search_meeting_notes(provider, args).await,
688        _ => Err(Error::NotFound(format!("unknown meeting tool: {tool}"))),
689    }
690}
691
692// --- Meeting notes tool handlers ---
693
694#[derive(Deserialize, Default)]
695struct GetMeetingNotesParams {
696    from_date: Option<String>,
697    to_date: Option<String>,
698    participants: Option<Vec<String>>,
699    host_email: Option<String>,
700    limit: Option<u32>,
701    offset: Option<u32>,
702}
703
704async fn execute_get_meeting_notes(
705    provider: &dyn MeetingNotesProvider,
706    args: &Value,
707) -> Result<ToolOutput> {
708    let params: GetMeetingNotesParams = parse_tool_params(args, "get_meeting_notes")?;
709    let filter = MeetingFilter {
710        keyword: None,
711        from_date: params.from_date,
712        to_date: params.to_date,
713        participants: params.participants,
714        host_email: params.host_email,
715        limit: params.limit,
716        skip: params.offset,
717    };
718    let result = provider.get_meetings(filter).await?;
719    let meta = ResultMeta {
720        pagination: result.pagination,
721        sort_info: result.sort_info,
722    };
723    Ok(ToolOutput::MeetingNotes(result.items, Some(meta)))
724}
725
726#[derive(Deserialize)]
727struct GetMeetingTranscriptParams {
728    meeting_id: String,
729}
730
731async fn execute_get_meeting_transcript(
732    provider: &dyn MeetingNotesProvider,
733    args: &Value,
734) -> Result<ToolOutput> {
735    let params: GetMeetingTranscriptParams = serde_json::from_value(args.clone())
736        .map_err(|e| Error::InvalidData(format!("invalid params: {e}")))?;
737    let transcript = provider.get_transcript(&params.meeting_id).await?;
738    Ok(ToolOutput::MeetingTranscript(Box::new(transcript)))
739}
740
741#[derive(Deserialize)]
742struct SearchMeetingNotesParams {
743    query: String,
744    from_date: Option<String>,
745    to_date: Option<String>,
746    participants: Option<Vec<String>>,
747    host_email: Option<String>,
748    limit: Option<u32>,
749    offset: Option<u32>,
750}
751
752async fn execute_search_meeting_notes(
753    provider: &dyn MeetingNotesProvider,
754    args: &Value,
755) -> Result<ToolOutput> {
756    let params: SearchMeetingNotesParams = serde_json::from_value(args.clone())
757        .map_err(|e| Error::InvalidData(format!("invalid params: {e}")))?;
758    let filter = MeetingFilter {
759        keyword: None,
760        from_date: params.from_date,
761        to_date: params.to_date,
762        participants: params.participants,
763        host_email: params.host_email,
764        limit: params.limit,
765        skip: params.offset,
766    };
767    let result = provider.search_meetings(&params.query, filter).await?;
768    let meta = ResultMeta {
769        pagination: result.pagination,
770        sort_info: result.sort_info,
771    };
772    Ok(ToolOutput::MeetingNotes(result.items, Some(meta)))
773}
774
775// --- Issue tool handlers ---
776
777#[derive(Deserialize, Default)]
778struct GetIssuesParams {
779    state: Option<String>,
780    #[serde(rename = "stateCategory")]
781    state_category: Option<String>,
782    search: Option<String>,
783    labels: Option<Vec<String>>,
784    #[serde(rename = "labelsOperator")]
785    labels_operator: Option<String>,
786    assignee: Option<String>,
787    limit: Option<u32>,
788    offset: Option<u32>,
789    sort_by: Option<String>,
790    sort_order: Option<String>,
791    #[serde(rename = "projectKey")]
792    project_key: Option<String>,
793    #[serde(rename = "nativeQuery")]
794    native_query: Option<String>,
795    /// Token budget for response size control (consumed by format layer via execute_and_format).
796    #[allow(dead_code)]
797    budget: Option<usize>,
798}
799
800async fn execute_get_issues(
801    provider: &dyn devboy_core::Provider,
802    args: &Value,
803) -> Result<ToolOutput> {
804    let params: GetIssuesParams = parse_tool_params(args, "get_issues")?;
805    let filter = IssueFilter {
806        state: params.state,
807        state_category: params.state_category,
808        search: params.search,
809        labels: params.labels,
810        labels_operator: params.labels_operator,
811        assignee: params.assignee,
812        limit: params.limit.or(Some(20)),
813        offset: params.offset,
814        sort_by: params.sort_by,
815        sort_order: params.sort_order,
816        project_key: params.project_key,
817        native_query: params.native_query,
818    };
819    let result = provider.get_issues(filter).await?;
820    let meta = ResultMeta {
821        pagination: result.pagination,
822        sort_info: result.sort_info,
823    };
824    Ok(ToolOutput::Issues(result.items, Some(meta)))
825}
826
827#[derive(Deserialize)]
828struct KeyParam {
829    key: String,
830    /// Token budget for response size control (consumed by format layer via execute_and_format).
831    #[serde(default)]
832    #[allow(dead_code)]
833    budget: Option<usize>,
834}
835
836#[derive(Deserialize)]
837struct GetIssueParams {
838    key: String,
839    #[serde(default = "default_true", rename = "includeComments")]
840    include_comments: bool,
841    #[serde(default = "default_true", rename = "includeRelations")]
842    include_relations: bool,
843    #[serde(default)]
844    #[allow(dead_code)]
845    budget: Option<usize>,
846}
847
848fn default_true() -> bool {
849    true
850}
851
852async fn execute_get_issue(
853    provider: &dyn devboy_core::Provider,
854    args: &Value,
855) -> Result<ToolOutput> {
856    let params: GetIssueParams = serde_json::from_value(args.clone())
857        .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
858    let issue = provider.get_issue(&params.key).await?;
859
860    // If no extras requested, return just the issue
861    if !params.include_comments && !params.include_relations {
862        return Ok(ToolOutput::SingleIssue(Box::new(issue)));
863    }
864
865    // Build a composite JSON with issue + optional comments/relations
866    let mut result = serde_json::to_value(&issue).unwrap_or_default();
867    let mut has_extras = false;
868
869    if params.include_comments
870        && let Ok(comments_result) = provider.get_comments(&params.key).await
871    {
872        result["comments"] = serde_json::to_value(&comments_result.items).unwrap_or_default();
873        result["comments_count"] = serde_json::json!(comments_result.items.len());
874        has_extras = true;
875    }
876
877    if params.include_relations
878        && let Ok(relations) = provider.get_issue_relations(&params.key).await
879    {
880        result["relations"] = serde_json::to_value(&relations).unwrap_or_default();
881        if issue.subtasks.is_empty() && !relations.subtasks.is_empty() {
882            result["subtasks"] = serde_json::to_value(&relations.subtasks).unwrap_or_default();
883        }
884        result["subtasks_count"] =
885            serde_json::json!(issue.subtasks.len().max(relations.subtasks.len()));
886        has_extras = true;
887    }
888
889    // If no extras were actually fetched, return simple issue
890    if !has_extras {
891        return Ok(ToolOutput::SingleIssue(Box::new(issue)));
892    }
893
894    Ok(ToolOutput::Text(
895        serde_json::to_string_pretty(&result).unwrap_or_default(),
896    ))
897}
898
899async fn execute_get_issue_comments(
900    provider: &dyn devboy_core::Provider,
901    args: &Value,
902) -> Result<ToolOutput> {
903    let params: KeyParam = serde_json::from_value(args.clone())
904        .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
905    let result = provider.get_comments(&params.key).await?;
906    let meta = ResultMeta {
907        pagination: result.pagination,
908        sort_info: result.sort_info,
909    };
910    Ok(ToolOutput::Comments(result.items, Some(meta)))
911}
912
913async fn execute_get_issue_relations(
914    provider: &dyn devboy_core::Provider,
915    args: &Value,
916) -> Result<ToolOutput> {
917    let params: KeyParam = serde_json::from_value(args.clone())
918        .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
919    let relations = provider.get_issue_relations(&params.key).await?;
920    Ok(ToolOutput::Relations(Box::new(relations)))
921}
922
923#[derive(Deserialize)]
924struct CreateIssueParams {
925    title: String,
926    description: Option<String>,
927    #[serde(default)]
928    labels: Vec<String>,
929    #[serde(default)]
930    assignees: Vec<String>,
931    #[serde(default, deserialize_with = "deserialize_string_or_number")]
932    priority: Option<String>,
933    #[serde(alias = "parentId")]
934    parent: Option<String>,
935    markdown: Option<bool>,
936    #[serde(rename = "projectId")]
937    project_id: Option<String>,
938    #[serde(rename = "issueType")]
939    issue_type: Option<String>,
940    /// Jira component names (issue #197). Serde rejects non-string-array
941    /// input instead of silently dropping entries (Copilot review on PR #205).
942    #[serde(default)]
943    components: Vec<String>,
944    /// Jira fix-version names. Same shape and semantics as `components`.
945    #[serde(default, rename = "fixVersions")]
946    fix_versions: Vec<String>,
947    /// Jira parent epic key. Resolved via the field-id lookup so callers
948    /// don't need to know the instance's `customfield_*` number.
949    #[serde(default, rename = "epicKey")]
950    epic_key: Option<String>,
951    /// Jira sprint id. Numeric agile-board sprint id.
952    #[serde(default, rename = "sprintId")]
953    sprint_id: Option<i64>,
954    /// Jira Epic Name. Required by Server/DC + Cloud company-managed
955    /// when `issueType == "Epic"`.
956    #[serde(default, rename = "epicName")]
957    epic_name: Option<String>,
958}
959
960async fn execute_create_issue(
961    provider: &dyn devboy_core::Provider,
962    args: &Value,
963) -> Result<ToolOutput> {
964    let params: CreateIssueParams = serde_json::from_value(args.clone())
965        .map_err(|e| Error::InvalidData(format!("invalid create_issue params: {e}")))?;
966    let custom_fields = args.get("customFields").cloned();
967    let input = CreateIssueInput {
968        title: params.title,
969        description: params.description,
970        labels: params.labels,
971        assignees: params.assignees,
972        priority: params.priority,
973        parent: params.parent,
974        markdown: params.markdown.unwrap_or(true),
975        project_id: params.project_id,
976        issue_type: params.issue_type,
977        custom_fields,
978        components: params.components,
979        fix_versions: params.fix_versions,
980        epic_key: params.epic_key,
981        sprint_id: params.sprint_id,
982        epic_name: params.epic_name,
983    };
984    let issue = provider.create_issue(input).await?;
985
986    // Set custom fields via separate API call (ClickUp uses Array format)
987    if let Some(cf) = args.get("customFields").and_then(|v| v.as_array())
988        && !cf.is_empty()
989        && let Err(e) = provider.set_custom_fields(&issue.key, cf).await
990    {
991        tracing::warn!(error = %e, "Failed to set custom fields on created issue");
992    }
993
994    Ok(ToolOutput::SingleIssue(Box::new(issue)))
995}
996
997#[derive(Deserialize)]
998struct UpdateIssueParams {
999    key: String,
1000    title: Option<String>,
1001    description: Option<String>,
1002    state: Option<String>,
1003    /// Provider-specific status name (#288). For ClickUp: any custom
1004    /// status from `get_available_statuses` (e.g. "in progress",
1005    /// "review"). Currently a no-op for other providers.
1006    #[serde(default)]
1007    status: Option<String>,
1008    labels: Option<Vec<String>>,
1009    assignees: Option<Vec<String>>,
1010    #[serde(default, deserialize_with = "deserialize_string_or_number")]
1011    priority: Option<String>,
1012    #[serde(rename = "parentId")]
1013    parent_id: Option<String>,
1014    markdown: Option<bool>,
1015    /// Jira component names (issue #197). `None` (key absent) leaves
1016    /// components untouched; `Some([])` clears all; `Some([...])` replaces.
1017    /// Serde-parsed so non-array / non-string input errors fast.
1018    #[serde(default)]
1019    components: Option<Vec<String>>,
1020    /// Jira fix-version names. Same shape and semantics as `components`.
1021    #[serde(default, rename = "fixVersions")]
1022    fix_versions: Option<Vec<String>>,
1023    /// Jira parent epic key.
1024    #[serde(default, rename = "epicKey")]
1025    epic_key: Option<String>,
1026    /// Jira sprint id.
1027    #[serde(default, rename = "sprintId")]
1028    sprint_id: Option<i64>,
1029    /// Jira Epic Name (Epic-typed issues).
1030    #[serde(default, rename = "epicName")]
1031    epic_name: Option<String>,
1032}
1033
1034async fn execute_update_issue(
1035    provider: &dyn devboy_core::Provider,
1036    args: &Value,
1037) -> Result<ToolOutput> {
1038    let params: UpdateIssueParams = serde_json::from_value(args.clone())
1039        .map_err(|e| Error::InvalidData(format!("invalid update_issue params: {e}")))?;
1040    let custom_fields = args.get("customFields").cloned();
1041    let input = UpdateIssueInput {
1042        title: params.title,
1043        description: params.description,
1044        state: params.state,
1045        status: params.status,
1046        labels: params.labels,
1047        assignees: params.assignees,
1048        priority: params.priority,
1049        parent_id: params.parent_id,
1050        markdown: params.markdown.unwrap_or(true),
1051        custom_fields,
1052        components: params.components,
1053        fix_versions: params.fix_versions,
1054        epic_key: params.epic_key,
1055        sprint_id: params.sprint_id,
1056        epic_name: params.epic_name,
1057    };
1058    let key = params.key;
1059    let issue = provider.update_issue(&key, input).await?;
1060
1061    // Set custom fields via separate API call (ClickUp uses Array format)
1062    if let Some(cf) = args.get("customFields").and_then(|v| v.as_array())
1063        && !cf.is_empty()
1064        && let Err(e) = provider.set_custom_fields(&key, cf).await
1065    {
1066        tracing::warn!(error = %e, "Failed to set custom fields on updated issue");
1067    }
1068    Ok(ToolOutput::SingleIssue(Box::new(issue)))
1069}
1070
1071#[derive(Deserialize)]
1072struct AddCommentParams {
1073    key: String,
1074    body: String,
1075    #[serde(default)]
1076    attachments: Vec<AttachmentParam>,
1077}
1078
1079#[derive(Deserialize)]
1080struct AttachmentParam {
1081    /// Base64-encoded file content
1082    #[serde(rename = "fileData")]
1083    file_data: String,
1084    /// Filename (e.g., "screenshot.png")
1085    filename: String,
1086}
1087
1088async fn execute_add_issue_comment(
1089    provider: &dyn devboy_core::Provider,
1090    args: &Value,
1091) -> Result<ToolOutput> {
1092    let params: AddCommentParams = serde_json::from_value(args.clone())
1093        .map_err(|e| Error::InvalidData(format!("invalid add_issue_comment params: {e}")))?;
1094
1095    let mut body = params.body.clone();
1096    let mut uploaded = 0;
1097    let mut upload_errors = Vec::new();
1098
1099    // Validate attachment limits
1100    const MAX_ATTACHMENTS: usize = 10;
1101
1102    if params.attachments.len() > MAX_ATTACHMENTS {
1103        return Err(Error::InvalidData(format!(
1104            "Too many attachments: {} (max {})",
1105            params.attachments.len(),
1106            MAX_ATTACHMENTS
1107        )));
1108    }
1109
1110    // Upload attachments and append links to comment body
1111    for att in &params.attachments {
1112        use base64::Engine;
1113        let data = match base64::engine::general_purpose::STANDARD.decode(&att.file_data) {
1114            Ok(d) => d,
1115            Err(e) => {
1116                upload_errors.push(format!("{}: decode error: {}", att.filename, e));
1117                continue;
1118            }
1119        };
1120
1121        if data.len() > MAX_FILE_SIZE {
1122            upload_errors.push(format!(
1123                "{}: file too large ({} bytes, max {})",
1124                att.filename,
1125                data.len(),
1126                MAX_FILE_SIZE
1127            ));
1128            continue;
1129        }
1130
1131        match provider
1132            .upload_attachment(&params.key, &att.filename, &data)
1133            .await
1134        {
1135            Ok(url) => {
1136                if !url.is_empty() {
1137                    body.push_str(&format!("\n\n[{}]({})", att.filename, url));
1138                }
1139                uploaded += 1;
1140            }
1141            Err(e) => {
1142                upload_errors.push(format!("{}: {}", att.filename, e));
1143            }
1144        }
1145    }
1146
1147    let comment = devboy_core::IssueProvider::add_comment(provider, &params.key, &body).await?;
1148
1149    let mut msg = format!("Comment added to {} (id: {})", params.key, comment.id);
1150    if uploaded > 0 {
1151        msg.push_str(&format!(", {} attachment(s) uploaded", uploaded));
1152    }
1153    if !upload_errors.is_empty() {
1154        msg.push_str(&format!(
1155            ", {} attachment error(s): {}",
1156            upload_errors.len(),
1157            upload_errors.join("; ")
1158        ));
1159    }
1160    Ok(ToolOutput::Text(msg))
1161}
1162
1163// --- Merge request tool handlers ---
1164
1165#[derive(Deserialize, Default)]
1166struct GetMergeRequestsParams {
1167    state: Option<String>,
1168    author: Option<String>,
1169    labels: Option<Vec<String>>,
1170    source_branch: Option<String>,
1171    target_branch: Option<String>,
1172    limit: Option<u32>,
1173    offset: Option<u32>,
1174    sort_by: Option<String>,
1175    sort_order: Option<String>,
1176    /// Token budget for response size control (consumed by format layer via execute_and_format).
1177    #[allow(dead_code)]
1178    budget: Option<usize>,
1179}
1180
1181async fn execute_get_merge_requests(
1182    provider: &dyn devboy_core::Provider,
1183    args: &Value,
1184) -> Result<ToolOutput> {
1185    let params: GetMergeRequestsParams = parse_tool_params(args, "get_merge_requests")?;
1186    let filter = MrFilter {
1187        state: params.state,
1188        source_branch: params.source_branch,
1189        target_branch: params.target_branch,
1190        author: params.author,
1191        labels: params.labels,
1192        limit: params.limit.or(Some(20)),
1193        offset: params.offset,
1194        sort_by: params.sort_by,
1195        sort_order: params.sort_order,
1196    };
1197    let result = provider.get_merge_requests(filter).await?;
1198    let meta = ResultMeta {
1199        pagination: result.pagination,
1200        sort_info: result.sort_info,
1201    };
1202    Ok(ToolOutput::MergeRequests(result.items, Some(meta)))
1203}
1204
1205async fn execute_get_merge_request(
1206    provider: &dyn devboy_core::Provider,
1207    args: &Value,
1208) -> Result<ToolOutput> {
1209    let params: KeyParam = serde_json::from_value(args.clone())
1210        .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
1211    let mr = provider.get_merge_request(&params.key).await?;
1212    Ok(ToolOutput::SingleMergeRequest(Box::new(mr)))
1213}
1214
1215async fn execute_get_merge_request_discussions(
1216    provider: &dyn devboy_core::Provider,
1217    args: &Value,
1218) -> Result<ToolOutput> {
1219    let params: KeyParam = serde_json::from_value(args.clone())
1220        .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
1221    let result = provider.get_discussions(&params.key).await?;
1222    let meta = ResultMeta {
1223        pagination: result.pagination,
1224        sort_info: result.sort_info,
1225    };
1226    Ok(ToolOutput::Discussions(result.items, Some(meta)))
1227}
1228
1229async fn execute_get_merge_request_diffs(
1230    provider: &dyn devboy_core::Provider,
1231    args: &Value,
1232) -> Result<ToolOutput> {
1233    let params: KeyParam = serde_json::from_value(args.clone())
1234        .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
1235    let result = provider.get_diffs(&params.key).await?;
1236    let meta = ResultMeta {
1237        pagination: result.pagination,
1238        sort_info: result.sort_info,
1239    };
1240    Ok(ToolOutput::Diffs(result.items, Some(meta)))
1241}
1242
1243#[derive(Deserialize)]
1244struct CreateMergeRequestParams {
1245    title: String,
1246    description: Option<String>,
1247    source_branch: String,
1248    target_branch: String,
1249    #[serde(default)]
1250    draft: bool,
1251    #[serde(default)]
1252    labels: Vec<String>,
1253    #[serde(default)]
1254    reviewers: Vec<String>,
1255}
1256
1257async fn execute_create_merge_request(
1258    provider: &dyn devboy_core::Provider,
1259    args: &Value,
1260) -> Result<ToolOutput> {
1261    let params: CreateMergeRequestParams = serde_json::from_value(args.clone())
1262        .map_err(|e| Error::InvalidData(format!("invalid create_merge_request params: {e}")))?;
1263    let input = CreateMergeRequestInput {
1264        title: params.title,
1265        description: params.description,
1266        source_branch: params.source_branch,
1267        target_branch: params.target_branch,
1268        draft: params.draft,
1269        labels: params.labels,
1270        reviewers: params.reviewers,
1271    };
1272    let mr = provider.create_merge_request(input).await?;
1273    Ok(ToolOutput::SingleMergeRequest(Box::new(mr)))
1274}
1275
1276#[derive(Deserialize)]
1277struct CreateMrCommentParams {
1278    #[serde(alias = "mrKey")]
1279    key: String,
1280    body: String,
1281    #[serde(alias = "filePath")]
1282    file_path: Option<String>,
1283    line: Option<u32>,
1284    #[serde(alias = "lineType")]
1285    line_type: Option<String>,
1286    #[serde(alias = "commitSha")]
1287    commit_sha: Option<String>,
1288    #[serde(alias = "discussionId")]
1289    discussion_id: Option<String>,
1290}
1291
1292async fn execute_create_merge_request_comment(
1293    provider: &dyn devboy_core::Provider,
1294    args: &Value,
1295) -> Result<ToolOutput> {
1296    let params: CreateMrCommentParams = serde_json::from_value(args.clone()).map_err(|e| {
1297        Error::InvalidData(format!("invalid create_merge_request_comment params: {e}"))
1298    })?;
1299
1300    let position = params.file_path.map(|fp| devboy_core::CodePosition {
1301        file_path: fp,
1302        line: params.line.unwrap_or(1),
1303        line_type: params.line_type.unwrap_or_else(|| "new".into()),
1304        commit_sha: params.commit_sha,
1305    });
1306
1307    let input = CreateCommentInput {
1308        body: params.body,
1309        position,
1310        discussion_id: params.discussion_id,
1311    };
1312
1313    let comment = MergeRequestProvider::add_comment(provider, &params.key, input).await?;
1314    Ok(ToolOutput::Text(format!(
1315        "Comment added to {} (id: {})",
1316        params.key, comment.id
1317    )))
1318}
1319
1320// --- Pipeline tool handlers ---
1321
1322#[derive(Deserialize, Default)]
1323struct GetPipelineParams {
1324    branch: Option<String>,
1325    #[serde(rename = "mrKey")]
1326    mr_key: Option<String>,
1327    #[serde(rename = "includeFailedLogs")]
1328    include_failed_logs: Option<bool>,
1329}
1330
1331async fn execute_get_pipeline(
1332    provider: &dyn devboy_core::Provider,
1333    args: &Value,
1334) -> Result<ToolOutput> {
1335    let params: GetPipelineParams = parse_tool_params(args, "get_pipeline")?;
1336    let input = GetPipelineInput {
1337        branch: params.branch,
1338        mr_key: params.mr_key,
1339        include_failed_logs: params.include_failed_logs.unwrap_or(true),
1340    };
1341    let pipeline = PipelineProvider::get_pipeline(provider, input).await?;
1342    Ok(ToolOutput::Pipeline(Box::new(pipeline)))
1343}
1344
1345#[derive(Deserialize)]
1346struct GetJobLogsParams {
1347    #[serde(rename = "jobId")]
1348    job_id: String,
1349    pattern: Option<String>,
1350    context: Option<usize>,
1351    #[serde(rename = "maxMatches")]
1352    max_matches: Option<usize>,
1353    offset: Option<usize>,
1354    limit: Option<usize>,
1355    full: Option<bool>,
1356}
1357
1358async fn execute_get_job_logs(
1359    provider: &dyn devboy_core::Provider,
1360    args: &Value,
1361) -> Result<ToolOutput> {
1362    let params: GetJobLogsParams = serde_json::from_value(args.clone())
1363        .map_err(|e| Error::InvalidData(format!("invalid get_job_logs params: {e}")))?;
1364
1365    // Clamp limit to max 1000 as declared in schema
1366    let clamped_limit = params.limit.map(|l| l.min(1000));
1367
1368    let mode = if let Some(pattern) = params.pattern {
1369        JobLogMode::Search {
1370            pattern,
1371            context: params.context.unwrap_or(5).min(50),
1372            max_matches: params.max_matches.unwrap_or(20).min(100),
1373        }
1374    } else if let Some(true) = params.full {
1375        JobLogMode::Full {
1376            max_lines: clamped_limit.unwrap_or(1000),
1377        }
1378    } else if params.offset.is_some() || clamped_limit.is_some() {
1379        JobLogMode::Paginated {
1380            offset: params.offset.unwrap_or(0),
1381            limit: clamped_limit.unwrap_or(200),
1382        }
1383    } else {
1384        JobLogMode::Smart
1385    };
1386
1387    let options = JobLogOptions { mode };
1388    let log_output = PipelineProvider::get_job_logs(provider, &params.job_id, options).await?;
1389    Ok(ToolOutput::JobLog(Box::new(log_output)))
1390}
1391
1392// --- Status / User / Link tool handlers ---
1393
1394async fn execute_get_available_statuses(
1395    provider: &dyn devboy_core::Provider,
1396) -> Result<ToolOutput> {
1397    let result = IssueProvider::get_statuses(provider).await?;
1398    let meta = ResultMeta {
1399        pagination: result.pagination,
1400        sort_info: result.sort_info,
1401    };
1402    Ok(ToolOutput::Statuses(result.items, Some(meta)))
1403}
1404
1405#[derive(Deserialize, Default)]
1406struct GetUsersParams {
1407    user_id: Option<String>,
1408    project_key: Option<String>,
1409    search: Option<String>,
1410    include_inactive: Option<bool>,
1411    start_at: Option<u32>,
1412    max_results: Option<u32>,
1413}
1414
1415async fn execute_get_users(
1416    provider: &dyn devboy_core::Provider,
1417    args: &Value,
1418) -> Result<ToolOutput> {
1419    let params: GetUsersParams = parse_tool_params(args, "get_users")?;
1420    let options = GetUsersOptions {
1421        user_id: params.user_id,
1422        project_key: params.project_key,
1423        search: params.search,
1424        include_inactive: params.include_inactive,
1425        start_at: params.start_at,
1426        max_results: params.max_results,
1427    };
1428    let result = IssueProvider::get_users(provider, options).await?;
1429    let meta = ResultMeta {
1430        pagination: result.pagination,
1431        sort_info: result.sort_info,
1432    };
1433    Ok(ToolOutput::Users(result.items, Some(meta)))
1434}
1435
1436#[derive(Deserialize)]
1437struct LinkIssuesParams {
1438    #[serde(alias = "sourceIssueKey", alias = "issueKey1")]
1439    source_key: String,
1440    #[serde(alias = "targetIssueKey", alias = "issueKey2")]
1441    target_key: String,
1442    #[serde(alias = "linkType")]
1443    link_type: String,
1444}
1445
1446async fn execute_link_issues(
1447    provider: &dyn devboy_core::Provider,
1448    args: &Value,
1449) -> Result<ToolOutput> {
1450    let params: LinkIssuesParams = serde_json::from_value(args.clone())
1451        .map_err(|e| Error::InvalidData(format!("invalid link_issues params: {e}")))?;
1452    IssueProvider::link_issues(
1453        provider,
1454        &params.source_key,
1455        &params.target_key,
1456        &params.link_type,
1457    )
1458    .await?;
1459    Ok(ToolOutput::Text(format!(
1460        "Linked {} -> {} (type: {})",
1461        params.source_key, params.target_key, params.link_type
1462    )))
1463}
1464
1465async fn execute_unlink_issues(
1466    provider: &dyn devboy_core::Provider,
1467    args: &Value,
1468) -> Result<ToolOutput> {
1469    let params: LinkIssuesParams = serde_json::from_value(args.clone())
1470        .map_err(|e| Error::InvalidData(format!("invalid unlink_issues params: {e}")))?;
1471    IssueProvider::unlink_issues(
1472        provider,
1473        &params.source_key,
1474        &params.target_key,
1475        &params.link_type,
1476    )
1477    .await?;
1478    Ok(ToolOutput::Text(format!(
1479        "Unlinked {} -> {} (type: {})",
1480        params.source_key, params.target_key, params.link_type
1481    )))
1482}
1483
1484// --- Epic tool handlers ---
1485
1486#[derive(Deserialize, Default)]
1487struct GetEpicsParams {
1488    state: Option<String>,
1489    search: Option<String>,
1490    assignee: Option<String>,
1491    #[serde(rename = "goalId")]
1492    goal_id: Option<String>,
1493    limit: Option<u32>,
1494    offset: Option<u32>,
1495}
1496
1497/// Extract goal ID (G1-G9) from issue labels/tags.
1498fn extract_goal_id(labels: &[String]) -> Option<String> {
1499    labels.iter().find_map(|l| {
1500        let lower = l.to_lowercase();
1501        if lower.len() == 2
1502            && lower.starts_with('g')
1503            && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1504        {
1505            Some(lower.to_uppercase())
1506        } else {
1507            None
1508        }
1509    })
1510}
1511
1512/// Calculate epic progress from subtasks.
1513fn epic_progress(subtasks: &[devboy_core::Issue]) -> serde_json::Value {
1514    let total = subtasks.len();
1515    let completed = subtasks.iter().filter(|s| s.state == "closed").count();
1516    let percentage = if total > 0 {
1517        (completed as f64 / total as f64 * 100.0).round() as u32
1518    } else {
1519        0
1520    };
1521    serde_json::json!({
1522        "total_subtasks": total,
1523        "completed_subtasks": completed,
1524        "percentage": percentage,
1525    })
1526}
1527
1528async fn execute_get_epics(
1529    provider: &dyn devboy_core::Provider,
1530    args: &Value,
1531) -> Result<ToolOutput> {
1532    let params: GetEpicsParams = parse_tool_params(args, "get_epics")?;
1533    let filter = IssueFilter {
1534        state: params.state,
1535        state_category: None,
1536        search: params.search,
1537        labels: Some(vec!["epic".to_string()]),
1538        labels_operator: None,
1539        assignee: params.assignee,
1540        limit: params.limit.or(Some(50)),
1541        offset: params.offset,
1542        sort_by: None,
1543        sort_order: None,
1544        project_key: None,
1545        native_query: None,
1546    };
1547    let result = provider.get_issues(filter).await?;
1548    let mut epics = result.items;
1549
1550    // Filter by goalId if provided
1551    if let Some(ref goal) = params.goal_id {
1552        let goal_lower = goal.to_lowercase();
1553        epics.retain(|e| e.labels.iter().any(|l| l.to_lowercase() == goal_lower));
1554    }
1555
1556    // Enrich each epic with goal ID and progress
1557    let enriched: Vec<serde_json::Value> = epics
1558        .iter()
1559        .map(|epic| {
1560            let mut v = serde_json::to_value(epic).unwrap_or_default();
1561            v["goal_id"] = serde_json::json!(extract_goal_id(&epic.labels));
1562            v["progress"] = epic_progress(&epic.subtasks);
1563            v
1564        })
1565        .collect();
1566
1567    Ok(ToolOutput::Text(
1568        serde_json::to_string_pretty(&enriched).unwrap_or_default(),
1569    ))
1570}
1571
1572#[derive(Deserialize)]
1573struct CreateEpicParams {
1574    title: String,
1575    description: Option<String>,
1576    #[serde(rename = "goalId")]
1577    goal_id: Option<String>,
1578    #[serde(default)]
1579    labels: Vec<String>,
1580    #[serde(default)]
1581    assignees: Vec<String>,
1582    #[serde(default, deserialize_with = "deserialize_string_or_number")]
1583    priority: Option<String>,
1584    markdown: Option<bool>,
1585}
1586
1587async fn execute_create_epic(
1588    provider: &dyn devboy_core::Provider,
1589    args: &Value,
1590) -> Result<ToolOutput> {
1591    let params: CreateEpicParams = serde_json::from_value(args.clone())
1592        .map_err(|e| Error::InvalidData(format!("invalid create_epic params: {e}")))?;
1593
1594    // Ensure "epic" label is included
1595    let mut labels = params.labels;
1596    if !labels.iter().any(|l| l.eq_ignore_ascii_case("epic")) {
1597        labels.push("epic".to_string());
1598    }
1599
1600    // Add goal tag if goalId provided (e.g., "G1" → tag "g1")
1601    if let Some(ref goal) = params.goal_id {
1602        let goal_tag = goal.to_lowercase();
1603        if !labels.iter().any(|l| l.to_lowercase() == goal_tag) {
1604            labels.push(goal_tag);
1605        }
1606    }
1607
1608    let input = CreateIssueInput {
1609        title: params.title,
1610        description: params.description,
1611        labels,
1612        assignees: params.assignees,
1613        priority: params.priority,
1614        parent: None,
1615        markdown: params.markdown.unwrap_or(true),
1616        project_id: None,
1617        issue_type: None,
1618        custom_fields: args.get("customFields").cloned(),
1619        components: Vec::new(),
1620        fix_versions: Vec::new(),
1621        epic_key: None,
1622        sprint_id: None,
1623        epic_name: None,
1624    };
1625    let issue = provider.create_issue(input).await?;
1626
1627    // Set custom fields via separate API call (ClickUp uses Array format)
1628    if let Some(cf) = args.get("customFields").and_then(|v| v.as_array())
1629        && !cf.is_empty()
1630        && let Err(e) = provider.set_custom_fields(&issue.key, cf).await
1631    {
1632        tracing::warn!(error = %e, "Failed to set custom fields on created epic");
1633    }
1634
1635    Ok(ToolOutput::SingleIssue(Box::new(issue)))
1636}
1637
1638#[derive(Deserialize)]
1639struct UpdateEpicParams {
1640    #[serde(alias = "epicKey")]
1641    key: String,
1642    title: Option<String>,
1643    description: Option<String>,
1644    state: Option<String>,
1645    /// Provider-specific status name (#288). Same semantics as
1646    /// `UpdateIssueParams::status` — epics are stored as ClickUp tasks,
1647    /// so the same custom-status workflow applies (e.g. "in progress",
1648    /// "review", "complete").
1649    #[serde(default)]
1650    status: Option<String>,
1651    #[serde(rename = "goalId")]
1652    goal_id: Option<String>,
1653    labels: Option<Vec<String>>,
1654    assignees: Option<Vec<String>>,
1655    #[serde(default, deserialize_with = "deserialize_string_or_number")]
1656    priority: Option<String>,
1657    markdown: Option<bool>,
1658}
1659
1660async fn execute_update_epic(
1661    provider: &dyn devboy_core::Provider,
1662    args: &Value,
1663) -> Result<ToolOutput> {
1664    let params: UpdateEpicParams = serde_json::from_value(args.clone())
1665        .map_err(|e| Error::InvalidData(format!("invalid update_epic params: {e}")))?;
1666
1667    // Handle goal tag transition: if goalId is changing, update labels
1668    let labels = if let Some(ref new_goal) = params.goal_id {
1669        // Fetch current issue to get existing labels
1670        let current = provider.get_issue(&params.key).await?;
1671        let mut labels: Vec<String> = current
1672            .labels
1673            .iter()
1674            // Remove old goal tags (g1-g9)
1675            .filter(|l| {
1676                let lower = l.to_lowercase();
1677                !(lower.len() == 2
1678                    && lower.starts_with('g')
1679                    && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit()))
1680            })
1681            .cloned()
1682            .collect();
1683
1684        // Add new goal tag
1685        let goal_tag = new_goal.to_lowercase();
1686        if !labels.iter().any(|l| l.to_lowercase() == goal_tag) {
1687            labels.push(goal_tag);
1688        }
1689
1690        // Merge with explicitly provided labels
1691        if let Some(extra) = params.labels {
1692            for l in extra {
1693                if !labels
1694                    .iter()
1695                    .any(|existing| existing.eq_ignore_ascii_case(&l))
1696                {
1697                    labels.push(l);
1698                }
1699            }
1700        }
1701        Some(labels)
1702    } else {
1703        params.labels
1704    };
1705
1706    let input = UpdateIssueInput {
1707        title: params.title,
1708        description: params.description,
1709        state: params.state,
1710        status: params.status,
1711        labels,
1712        assignees: params.assignees,
1713        priority: params.priority,
1714        parent_id: None,
1715        markdown: params.markdown.unwrap_or(true),
1716        custom_fields: args.get("customFields").cloned(),
1717        components: None,
1718        fix_versions: None,
1719        epic_key: None,
1720        sprint_id: None,
1721        epic_name: None,
1722    };
1723    let key = params.key;
1724    let issue = provider.update_issue(&key, input).await?;
1725
1726    // Set custom fields via separate API call (ClickUp uses Array format)
1727    if let Some(cf) = args.get("customFields").and_then(|v| v.as_array())
1728        && !cf.is_empty()
1729        && let Err(e) = provider.set_custom_fields(&key, cf).await
1730    {
1731        tracing::warn!(error = %e, "Failed to set custom fields on updated epic");
1732    }
1733
1734    Ok(ToolOutput::SingleIssue(Box::new(issue)))
1735}
1736
1737/// List of all tool names supported by the executor.
1738pub const SUPPORTED_TOOLS: &[&str] = &[
1739    "get_issues",
1740    "get_issue",
1741    "get_issue_comments",
1742    "get_issue_relations",
1743    "create_issue",
1744    "update_issue",
1745    "add_issue_comment",
1746    "get_merge_requests",
1747    "get_merge_request",
1748    "get_merge_request_discussions",
1749    "get_merge_request_diffs",
1750    "create_merge_request",
1751    "create_merge_request_comment",
1752    "update_merge_request",
1753    "get_pipeline",
1754    "get_job_logs",
1755    "get_available_statuses",
1756    "get_users",
1757    "link_issues",
1758    "unlink_issues",
1759    "get_epics",
1760    "create_epic",
1761    "update_epic",
1762    "get_meeting_notes",
1763    "get_meeting_transcript",
1764    "search_meeting_notes",
1765    // Knowledge base tools
1766    "get_knowledge_base_spaces",
1767    "list_knowledge_base_pages",
1768    "get_knowledge_base_page",
1769    "create_knowledge_base_page",
1770    "update_knowledge_base_page",
1771    "search_knowledge_base",
1772    // Messenger tools
1773    "get_messenger_chats",
1774    "get_chat_messages",
1775    "search_chat_messages",
1776    "send_message",
1777    // Asset tools
1778    "get_assets",
1779    "upload_asset",
1780    "download_asset",
1781    "delete_asset",
1782];
1783
1784// =============================================================================
1785// Update Merge Request handler
1786// =============================================================================
1787
1788#[derive(Deserialize)]
1789struct UpdateMergeRequestParams {
1790    key: String,
1791    #[serde(default)]
1792    title: Option<String>,
1793    #[serde(default)]
1794    description: Option<String>,
1795    #[serde(default)]
1796    state: Option<String>,
1797    #[serde(default)]
1798    labels: Option<Vec<String>>,
1799    #[serde(default)]
1800    draft: Option<bool>,
1801}
1802
1803async fn execute_update_merge_request(
1804    provider: &dyn devboy_core::Provider,
1805    args: &Value,
1806) -> Result<ToolOutput> {
1807    let params: UpdateMergeRequestParams = serde_json::from_value(args.clone())?;
1808    debug!(key = %params.key, "update_merge_request");
1809
1810    let input = devboy_core::UpdateMergeRequestInput {
1811        title: params.title,
1812        description: params.description,
1813        state: params.state,
1814        labels: params.labels,
1815        draft: params.draft,
1816    };
1817
1818    let mr = MergeRequestProvider::update_merge_request(provider, &params.key, input).await?;
1819    Ok(ToolOutput::SingleMergeRequest(Box::new(mr)))
1820}
1821
1822// =============================================================================
1823// Asset tool handlers
1824// =============================================================================
1825
1826#[derive(Deserialize)]
1827struct GetAssetsParams {
1828    /// "issue" or "mr"
1829    context_type: String,
1830    /// Issue key (e.g. "DEV-123") or MR key (e.g. "mr#42")
1831    key: String,
1832}
1833
1834async fn execute_get_assets(
1835    provider: &dyn devboy_core::Provider,
1836    args: &Value,
1837) -> Result<ToolOutput> {
1838    let params: GetAssetsParams = serde_json::from_value(args.clone())?;
1839    debug!(context_type = %params.context_type, key = %params.key, "get_assets");
1840
1841    let assets = match params.context_type.as_str() {
1842        "issue" => IssueProvider::get_issue_attachments(provider, &params.key).await?,
1843        "mr" | "merge_request" | "pull_request" => {
1844            MergeRequestProvider::get_mr_attachments(provider, &params.key).await?
1845        }
1846        other => {
1847            return Err(Error::InvalidData(format!(
1848                "unsupported context_type: '{other}', expected 'issue' or 'mr'"
1849            )));
1850        }
1851    };
1852
1853    let capabilities =
1854        serde_json::to_value(IssueProvider::asset_capabilities(provider)).unwrap_or_default();
1855    let count = assets.len();
1856    let attachments: Vec<serde_json::Value> = assets
1857        .into_iter()
1858        .map(|a| serde_json::to_value(a).unwrap_or_default())
1859        .collect();
1860    Ok(ToolOutput::AssetList {
1861        attachments,
1862        count,
1863        capabilities,
1864    })
1865}
1866
1867#[derive(Deserialize)]
1868struct UploadAssetParams {
1869    /// "issue" or "mr"
1870    context_type: String,
1871    key: String,
1872    filename: String,
1873    /// Base64-encoded file data
1874    #[serde(rename = "fileData")]
1875    file_data: String,
1876}
1877
1878async fn execute_upload_asset(
1879    provider: &dyn devboy_core::Provider,
1880    args: &Value,
1881) -> Result<ToolOutput> {
1882    let params: UploadAssetParams = serde_json::from_value(args.clone())?;
1883    debug!(context_type = %params.context_type, key = %params.key, filename = %params.filename, "upload_asset");
1884
1885    let data = base64_decode(&params.file_data)?;
1886
1887    if data.len() > MAX_FILE_SIZE {
1888        return Err(Error::InvalidData(format!(
1889            "file '{}' is {} bytes, max allowed is {} bytes",
1890            params.filename,
1891            data.len(),
1892            MAX_FILE_SIZE,
1893        )));
1894    }
1895
1896    let size = data.len();
1897    let url = match params.context_type.as_str() {
1898        "issue" => {
1899            IssueProvider::upload_attachment(provider, &params.key, &params.filename, &data).await?
1900        }
1901        other => {
1902            return Err(Error::InvalidData(format!(
1903                "upload not supported for context_type: '{other}', use 'issue'"
1904            )));
1905        }
1906    };
1907
1908    Ok(ToolOutput::AssetUploaded {
1909        url,
1910        filename: params.filename,
1911        size,
1912    })
1913}
1914
1915#[derive(Deserialize)]
1916struct DownloadAssetParams {
1917    /// "issue" or "mr"
1918    context_type: String,
1919    key: String,
1920    /// Asset identifier (provider-specific)
1921    asset_id: String,
1922}
1923
1924async fn execute_download_asset(
1925    provider: &dyn devboy_core::Provider,
1926    args: &Value,
1927    asset_manager: Option<&devboy_assets::AssetManager>,
1928) -> Result<ToolOutput> {
1929    let params: DownloadAssetParams = serde_json::from_value(args.clone())?;
1930    debug!(context_type = %params.context_type, key = %params.key, asset_id = %params.asset_id, "download_asset");
1931
1932    // Check local cache first.
1933    if let Some(mgr) = asset_manager
1934        && let Ok(Some(resolved)) = mgr.get(&params.asset_id)
1935    {
1936        return Ok(ToolOutput::AssetDownloaded {
1937            asset_id: params.asset_id,
1938            size: resolved.asset.size as usize,
1939            local_path: Some(resolved.absolute_path.to_string_lossy().into_owned()),
1940            data: None,
1941            cached: true,
1942        });
1943    }
1944
1945    // Not cached — download from provider.
1946    let bytes = match params.context_type.as_str() {
1947        "issue" => {
1948            IssueProvider::download_attachment(provider, &params.key, &params.asset_id).await?
1949        }
1950        "mr" | "merge_request" | "pull_request" => {
1951            MergeRequestProvider::download_mr_attachment(provider, &params.key, &params.asset_id)
1952                .await?
1953        }
1954        other => {
1955            return Err(Error::InvalidData(format!(
1956                "unsupported context_type: '{other}', expected 'issue' or 'mr'"
1957            )));
1958        }
1959    };
1960
1961    // Store in cache if available.
1962    if let Some(mgr) = asset_manager {
1963        let context = match params.context_type.as_str() {
1964            "mr" | "merge_request" | "pull_request" => devboy_core::AssetContext::MergeRequest {
1965                mr_id: params.key.clone(),
1966            },
1967            _ => devboy_core::AssetContext::Issue {
1968                key: params.key.clone(),
1969            },
1970        };
1971        let filename = devboy_core::filename_from_url(&params.asset_id);
1972        match mgr.store(devboy_assets::StoreRequest {
1973            context,
1974            asset_id: Some(&params.asset_id),
1975            filename: &filename,
1976            mime_type: None,
1977            remote_url: None,
1978            data: &bytes,
1979        }) {
1980            Ok(cached) => {
1981                let abs = mgr.cache_dir().join(&cached.local_path);
1982                return Ok(ToolOutput::AssetDownloaded {
1983                    asset_id: cached.id,
1984                    size: cached.size as usize,
1985                    local_path: Some(abs.to_string_lossy().into_owned()),
1986                    data: None,
1987                    cached: true,
1988                });
1989            }
1990            Err(e) => {
1991                tracing::warn!(?e, "failed to cache asset, returning base64 fallback");
1992            }
1993        }
1994    }
1995
1996    // Fallback: return base64-encoded content.
1997    if bytes.len() > MAX_FILE_SIZE {
1998        return Err(Error::InvalidData(format!(
1999            "downloaded attachment is {} bytes, max allowed for base64 response is {} bytes",
2000            bytes.len(),
2001            MAX_FILE_SIZE,
2002        )));
2003    }
2004
2005    let encoded = base64_encode(&bytes);
2006    Ok(ToolOutput::AssetDownloaded {
2007        asset_id: params.asset_id,
2008        size: bytes.len(),
2009        local_path: None,
2010        data: Some(encoded),
2011        cached: false,
2012    })
2013}
2014
2015#[derive(Deserialize)]
2016struct DeleteAssetParams {
2017    key: String,
2018    asset_id: String,
2019}
2020
2021async fn execute_delete_asset(
2022    provider: &dyn devboy_core::Provider,
2023    args: &Value,
2024    asset_manager: Option<&devboy_assets::AssetManager>,
2025) -> Result<ToolOutput> {
2026    let params: DeleteAssetParams = serde_json::from_value(args.clone())?;
2027    debug!(key = %params.key, asset_id = %params.asset_id, "delete_asset");
2028
2029    IssueProvider::delete_attachment(provider, &params.key, &params.asset_id).await?;
2030
2031    // Evict from local cache so stale files aren't served.
2032    if let Some(mgr) = asset_manager
2033        && let Err(e) = mgr.delete(&params.asset_id)
2034    {
2035        tracing::warn!(?e, asset_id = %params.asset_id, "failed to evict deleted asset from cache");
2036    }
2037
2038    let message = format!(
2039        "Attachment '{}' deleted from {}",
2040        params.asset_id, params.key
2041    );
2042    Ok(ToolOutput::AssetDeleted {
2043        asset_id: params.asset_id,
2044        message,
2045    })
2046}
2047
2048/// Maximum base64 encoded length for MAX_FILE_SIZE bytes.
2049const MAX_BASE64_LEN: usize = (MAX_FILE_SIZE / 3 + 1) * 4 + 4;
2050
2051/// Decode base64 with standard or URL-safe alphabet, rejecting
2052/// oversized inputs *before* allocating the decoded buffer.
2053fn base64_decode(input: &str) -> Result<Vec<u8>> {
2054    let trimmed = input.trim();
2055    if trimmed.len() > MAX_BASE64_LEN {
2056        return Err(Error::InvalidData(format!(
2057            "base64 input too large ({} chars), max decoded size is {} bytes",
2058            trimmed.len(),
2059            MAX_FILE_SIZE,
2060        )));
2061    }
2062    use base64::Engine;
2063    base64::engine::general_purpose::STANDARD
2064        .decode(trimmed)
2065        .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(trimmed))
2066        .map_err(|e| Error::InvalidData(format!("invalid base64: {e}")))
2067}
2068
2069/// Encode bytes as standard base64.
2070fn base64_encode(data: &[u8]) -> String {
2071    use base64::Engine;
2072    base64::engine::general_purpose::STANDARD.encode(data)
2073}
2074
2075// =============================================================================
2076// Jira Structure tool handlers
2077// =============================================================================
2078
2079async fn execute_get_structures(provider: &dyn devboy_core::Provider) -> Result<ToolOutput> {
2080    let result = provider.get_structures().await?;
2081    let meta = ResultMeta {
2082        pagination: result.pagination,
2083        sort_info: result.sort_info,
2084    };
2085    Ok(ToolOutput::Structures(result.items, Some(meta)))
2086}
2087
2088#[derive(Deserialize)]
2089#[serde(rename_all = "camelCase")]
2090struct GetStructureForestParams {
2091    structure_id: u64,
2092    offset: Option<u64>,
2093    limit: Option<u64>,
2094}
2095
2096async fn execute_get_structure_forest(
2097    provider: &dyn devboy_core::Provider,
2098    args: &Value,
2099) -> Result<ToolOutput> {
2100    let params: GetStructureForestParams = serde_json::from_value(args.clone())
2101        .map_err(|e| Error::InvalidData(format!("missing 'structureId': {e}")))?;
2102    let forest = provider
2103        .get_structure_forest(
2104            params.structure_id,
2105            GetForestOptions {
2106                offset: params.offset,
2107                limit: Some(params.limit.unwrap_or(200)),
2108            },
2109        )
2110        .await?;
2111    Ok(ToolOutput::StructureForest(Box::new(forest)))
2112}
2113
2114#[derive(Deserialize)]
2115#[serde(rename_all = "camelCase")]
2116struct AddStructureRowsParams {
2117    structure_id: u64,
2118    items: Vec<Value>,
2119    under: Option<u64>,
2120    after: Option<u64>,
2121    forest_version: Option<u64>,
2122}
2123
2124/// Turn a single `items[]` entry from `add_structure_rows` into a
2125/// `StructureRowItem`. The tool schema can only express a list of
2126/// strings, so callers wanting to set `item_type` or nested fields
2127/// are forced to pass JSON inside a string. Accept both:
2128///
2129/// - bare string → `{ item_id: s, item_type: None }`
2130/// - JSON object (either as a real object or a string that parses as
2131///   one) → `serde_json::from_value`
2132///
2133/// Malformed input surfaces as `InvalidData` rather than silently
2134/// dropping values through `unwrap_or_default()`.
2135fn parse_structure_row_item(v: Value) -> Result<StructureRowItem> {
2136    if let Some(s) = v.as_str() {
2137        if let Ok(parsed) = serde_json::from_str::<Value>(s)
2138            && parsed.is_object()
2139        {
2140            return serde_json::from_value(parsed)
2141                .map_err(|e| Error::InvalidData(format!("invalid structure row item JSON: {e}")));
2142        }
2143        return Ok(StructureRowItem {
2144            item_id: s.to_string(),
2145            item_type: None,
2146        });
2147    }
2148    serde_json::from_value(v)
2149        .map_err(|e| Error::InvalidData(format!("invalid structure row item: {e}")))
2150}
2151
2152/// Turn a `columns[]` entry from `get_structure_values` /
2153/// `save_structure_view` into a `StructureViewColumn`. Same dual
2154/// shape as `parse_structure_row_item`: bare string means
2155/// `{ field: Some(s) }`, anything else (or a JSON-object string) is
2156/// deserialised as a full spec. Errors propagate.
2157fn parse_structure_column_spec(v: Value) -> Result<StructureViewColumn> {
2158    if let Some(s) = v.as_str() {
2159        if let Ok(parsed) = serde_json::from_str::<Value>(s)
2160            && parsed.is_object()
2161        {
2162            return serde_json::from_value(parsed).map_err(|e| {
2163                Error::InvalidData(format!("invalid structure column spec JSON: {e}"))
2164            });
2165        }
2166        return Ok(StructureViewColumn {
2167            field: Some(s.to_string()),
2168            ..Default::default()
2169        });
2170    }
2171    serde_json::from_value(v)
2172        .map_err(|e| Error::InvalidData(format!("invalid structure column spec: {e}")))
2173}
2174
2175async fn execute_add_structure_rows(
2176    provider: &dyn devboy_core::Provider,
2177    args: &Value,
2178) -> Result<ToolOutput> {
2179    let params: AddStructureRowsParams = serde_json::from_value(args.clone())
2180        .map_err(|e| Error::InvalidData(format!("invalid add_structure_rows params: {e}")))?;
2181
2182    let items: Vec<StructureRowItem> = params
2183        .items
2184        .into_iter()
2185        .map(parse_structure_row_item)
2186        .collect::<Result<Vec<_>>>()?;
2187
2188    let result = provider
2189        .add_structure_rows(
2190            params.structure_id,
2191            AddStructureRowsInput {
2192                items,
2193                under: params.under,
2194                after: params.after,
2195                forest_version: params.forest_version,
2196            },
2197        )
2198        .await?;
2199    Ok(ToolOutput::ForestModified(result))
2200}
2201
2202#[derive(Deserialize)]
2203#[serde(rename_all = "camelCase")]
2204struct MoveStructureRowsParams {
2205    structure_id: u64,
2206    row_ids: Vec<u64>,
2207    under: Option<u64>,
2208    after: Option<u64>,
2209    forest_version: Option<u64>,
2210}
2211
2212async fn execute_move_structure_rows(
2213    provider: &dyn devboy_core::Provider,
2214    args: &Value,
2215) -> Result<ToolOutput> {
2216    let params: MoveStructureRowsParams = serde_json::from_value(args.clone())
2217        .map_err(|e| Error::InvalidData(format!("invalid move_structure_rows params: {e}")))?;
2218    let result = provider
2219        .move_structure_rows(
2220            params.structure_id,
2221            MoveStructureRowsInput {
2222                row_ids: params.row_ids,
2223                under: params.under,
2224                after: params.after,
2225                forest_version: params.forest_version,
2226            },
2227        )
2228        .await?;
2229    Ok(ToolOutput::ForestModified(result))
2230}
2231
2232#[derive(Deserialize)]
2233#[serde(rename_all = "camelCase")]
2234struct RemoveStructureRowParams {
2235    structure_id: u64,
2236    row_id: u64,
2237}
2238
2239async fn execute_remove_structure_row(
2240    provider: &dyn devboy_core::Provider,
2241    args: &Value,
2242) -> Result<ToolOutput> {
2243    let params: RemoveStructureRowParams = serde_json::from_value(args.clone())
2244        .map_err(|e| Error::InvalidData(format!("invalid remove_structure_row params: {e}")))?;
2245    provider
2246        .remove_structure_row(params.structure_id, params.row_id)
2247        .await?;
2248    Ok(ToolOutput::Text(format!(
2249        "Row {} removed from structure {}",
2250        params.row_id, params.structure_id
2251    )))
2252}
2253
2254#[derive(Deserialize)]
2255#[serde(rename_all = "camelCase")]
2256struct GetStructureValuesParams {
2257    structure_id: u64,
2258    rows: Vec<u64>,
2259    columns: Vec<Value>,
2260}
2261
2262async fn execute_get_structure_values(
2263    provider: &dyn devboy_core::Provider,
2264    args: &Value,
2265) -> Result<ToolOutput> {
2266    let params: GetStructureValuesParams = serde_json::from_value(args.clone())
2267        .map_err(|e| Error::InvalidData(format!("invalid get_structure_values params: {e}")))?;
2268
2269    let columns: Vec<StructureViewColumn> = params
2270        .columns
2271        .into_iter()
2272        .map(parse_structure_column_spec)
2273        .collect::<Result<Vec<_>>>()?;
2274
2275    let result = provider
2276        .get_structure_values(GetStructureValuesInput {
2277            structure_id: params.structure_id,
2278            rows: params.rows,
2279            columns,
2280        })
2281        .await?;
2282    Ok(ToolOutput::StructureValues(Box::new(result)))
2283}
2284
2285#[derive(Deserialize)]
2286#[serde(rename_all = "camelCase")]
2287struct GetStructureViewsParams {
2288    structure_id: u64,
2289    view_id: Option<u64>,
2290}
2291
2292async fn execute_get_structure_views(
2293    provider: &dyn devboy_core::Provider,
2294    args: &Value,
2295) -> Result<ToolOutput> {
2296    let params: GetStructureViewsParams = serde_json::from_value(args.clone())
2297        .map_err(|e| Error::InvalidData(format!("invalid get_structure_views params: {e}")))?;
2298    let views = provider
2299        .get_structure_views(params.structure_id, params.view_id)
2300        .await?;
2301    Ok(ToolOutput::StructureViews(views, None))
2302}
2303
2304#[derive(Deserialize)]
2305#[serde(rename_all = "camelCase")]
2306struct SaveStructureViewParams {
2307    id: Option<u64>,
2308    structure_id: u64,
2309    name: String,
2310    columns: Option<Vec<Value>>,
2311    group_by: Option<String>,
2312    sort_by: Option<String>,
2313    filter: Option<String>,
2314}
2315
2316async fn execute_save_structure_view(
2317    provider: &dyn devboy_core::Provider,
2318    args: &Value,
2319) -> Result<ToolOutput> {
2320    let params: SaveStructureViewParams = serde_json::from_value(args.clone())
2321        .map_err(|e| Error::InvalidData(format!("invalid save_structure_view params: {e}")))?;
2322
2323    let columns: Option<Vec<StructureViewColumn>> = params
2324        .columns
2325        .map(|cols| {
2326            cols.into_iter()
2327                .map(parse_structure_column_spec)
2328                .collect::<Result<Vec<_>>>()
2329        })
2330        .transpose()?;
2331
2332    let view = provider
2333        .save_structure_view(SaveStructureViewInput {
2334            id: params.id,
2335            structure_id: params.structure_id,
2336            name: params.name,
2337            columns,
2338            group_by: params.group_by,
2339            sort_by: params.sort_by,
2340            filter: params.filter,
2341        })
2342        .await?;
2343    Ok(ToolOutput::StructureViews(vec![view], None))
2344}
2345
2346#[derive(Deserialize)]
2347struct CreateStructureParams {
2348    name: String,
2349    description: Option<String>,
2350}
2351
2352async fn execute_create_structure(
2353    provider: &dyn devboy_core::Provider,
2354    args: &Value,
2355) -> Result<ToolOutput> {
2356    let params: CreateStructureParams = serde_json::from_value(args.clone())
2357        .map_err(|e| Error::InvalidData(format!("missing 'name': {e}")))?;
2358    let structure = provider
2359        .create_structure(CreateStructureInput {
2360            name: params.name,
2361            description: params.description,
2362        })
2363        .await?;
2364    Ok(ToolOutput::Structures(vec![structure], None))
2365}
2366
2367// =============================================================================
2368// Project versions / fixVersion handlers (issue #238)
2369// =============================================================================
2370
2371/// Tri-state filter for `released` / `archived` — accepts the strings
2372/// `"true"`, `"false"`, `"all"` (default `"all"` → no filter).
2373fn parse_tri_filter(s: Option<&str>) -> Result<Option<bool>> {
2374    match s.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
2375        None | Some("") | Some("all") | Some("any") => Ok(None),
2376        Some("true") | Some("yes") | Some("1") => Ok(Some(true)),
2377        Some("false") | Some("no") | Some("0") => Ok(Some(false)),
2378        Some(other) => Err(Error::InvalidData(format!(
2379            "expected 'true' | 'false' | 'all', got '{other}'"
2380        ))),
2381    }
2382}
2383
2384/// Validate that a string is an ISO 8601 calendar date in `YYYY-MM-DD`
2385/// form. Jira accepts that exact shape on `releaseDate`/`startDate`
2386/// payloads; anything else (timestamps, slashes, locale formats) gets
2387/// rejected with a 400 by the server, so catch it client-side with a
2388/// clear error pointing at the offending field.
2389fn validate_iso_date(field: &str, value: &str) -> Result<()> {
2390    let bytes = value.as_bytes();
2391    let shape_ok = bytes.len() == 10
2392        && bytes[4] == b'-'
2393        && bytes[7] == b'-'
2394        && bytes[..4].iter().all(u8::is_ascii_digit)
2395        && bytes[5..7].iter().all(u8::is_ascii_digit)
2396        && bytes[8..].iter().all(u8::is_ascii_digit);
2397    if !shape_ok {
2398        return Err(Error::InvalidData(format!(
2399            "{field} must be an ISO 8601 calendar date (YYYY-MM-DD), got '{value}'"
2400        )));
2401    }
2402    let month: u32 = value[5..7].parse().unwrap();
2403    let day: u32 = value[8..10].parse().unwrap();
2404    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
2405        return Err(Error::InvalidData(format!(
2406            "{field} = '{value}' is not a valid calendar date"
2407        )));
2408    }
2409    Ok(())
2410}
2411
2412#[derive(Deserialize, Default)]
2413#[serde(rename_all = "camelCase")]
2414struct ListProjectVersionsArgs {
2415    project: Option<String>,
2416    released: Option<String>,
2417    archived: Option<String>,
2418    limit: Option<u32>,
2419    include_issue_count: Option<bool>,
2420}
2421
2422async fn execute_list_project_versions(
2423    provider: &dyn devboy_core::Provider,
2424    args: &Value,
2425) -> Result<ToolOutput> {
2426    let params: ListProjectVersionsArgs = parse_tool_params(args, "list_project_versions")?;
2427
2428    // Paper 1 / TrimTree defaults: hide archived noise + cap at 20 most
2429    // recent. Defaults only apply when the caller omits the field —
2430    // explicit `"all"` must round-trip as `None` (no filter).
2431    let archived = match params.archived.as_deref() {
2432        None => Some(false),
2433        Some(s) => parse_tri_filter(Some(s))?,
2434    };
2435    let released = match params.released.as_deref() {
2436        None => None,
2437        Some(s) => parse_tri_filter(Some(s))?,
2438    };
2439    // `limit: 0` would round-trip to a useless empty list — reject up
2440    // front instead of letting the schema's `min: 1` get bypassed by a
2441    // raw call site (Codex review on PR #239).
2442    if let Some(0) = params.limit {
2443        return Err(Error::InvalidData(
2444            "limit must be at least 1 (use the default by omitting the field)".into(),
2445        ));
2446    }
2447    let limit = params.limit.unwrap_or(20).min(200);
2448
2449    let result = provider
2450        .list_project_versions(ListProjectVersionsParams {
2451            project: params.project.unwrap_or_default(),
2452            released,
2453            archived,
2454            limit: Some(limit),
2455            include_issue_count: params.include_issue_count.unwrap_or(false),
2456        })
2457        .await?;
2458
2459    let meta = ResultMeta {
2460        pagination: result.pagination,
2461        sort_info: result.sort_info,
2462    };
2463    Ok(ToolOutput::ProjectVersions(result.items, Some(meta)))
2464}
2465
2466#[derive(Deserialize)]
2467#[serde(rename_all = "camelCase")]
2468struct UpsertProjectVersionArgs {
2469    project: Option<String>,
2470    name: String,
2471    description: Option<String>,
2472    start_date: Option<String>,
2473    release_date: Option<String>,
2474    released: Option<bool>,
2475    archived: Option<bool>,
2476}
2477
2478async fn execute_upsert_project_version(
2479    provider: &dyn devboy_core::Provider,
2480    args: &Value,
2481) -> Result<ToolOutput> {
2482    let params: UpsertProjectVersionArgs = serde_json::from_value(args.clone())
2483        .map_err(|e| Error::InvalidData(format!("invalid upsert_project_version params: {e}")))?;
2484
2485    // Codex review on PR #239 — validate inputs before they cross the
2486    // wire so the failure points at the parameter, not at "Jira said 400".
2487    if let Some(ref d) = params.start_date {
2488        validate_iso_date("startDate", d)?;
2489    }
2490    if let Some(ref d) = params.release_date {
2491        validate_iso_date("releaseDate", d)?;
2492    }
2493
2494    let version = provider
2495        .upsert_project_version(UpsertProjectVersionInput {
2496            project: params.project.unwrap_or_default(),
2497            name: params.name,
2498            description: params.description,
2499            start_date: params.start_date,
2500            release_date: params.release_date,
2501            released: params.released,
2502            archived: params.archived,
2503        })
2504        .await?;
2505
2506    Ok(ToolOutput::SingleProjectVersion(Box::new(version)))
2507}
2508
2509#[derive(Deserialize, Default)]
2510#[serde(rename_all = "camelCase")]
2511struct GetBoardSprintsArgs {
2512    board_id: u64,
2513    /// Optional state filter: `active`, `future`, `closed`, or `all`
2514    /// (default `all`).
2515    state: Option<String>,
2516}
2517
2518async fn execute_get_board_sprints(
2519    provider: &dyn devboy_core::Provider,
2520    args: &Value,
2521) -> Result<ToolOutput> {
2522    let params: GetBoardSprintsArgs = parse_tool_params(args, "get_board_sprints")?;
2523    let state = match params.state.as_deref() {
2524        None | Some("all") => SprintState::All,
2525        Some("active") => SprintState::Active,
2526        Some("future") => SprintState::Future,
2527        Some("closed") => SprintState::Closed,
2528        Some(other) => {
2529            return Err(Error::InvalidData(format!(
2530                "invalid sprint state `{other}` — expected one of: active, future, closed, all"
2531            )));
2532        }
2533    };
2534
2535    let result = provider.get_board_sprints(params.board_id, state).await?;
2536    let meta = ResultMeta {
2537        pagination: result.pagination,
2538        sort_info: result.sort_info,
2539    };
2540    Ok(ToolOutput::Sprints(result.items, Some(meta)))
2541}
2542
2543#[derive(Deserialize, Default)]
2544#[serde(rename_all = "camelCase")]
2545struct AssignToSprintArgs {
2546    sprint_id: u64,
2547    issue_keys: Vec<String>,
2548}
2549
2550async fn execute_assign_to_sprint(
2551    provider: &dyn devboy_core::Provider,
2552    args: &Value,
2553) -> Result<ToolOutput> {
2554    let params: AssignToSprintArgs = parse_tool_params(args, "assign_to_sprint")?;
2555    if params.issue_keys.is_empty() {
2556        return Err(Error::InvalidData(
2557            "issueKeys must contain at least one issue key".into(),
2558        ));
2559    }
2560    let count = params.issue_keys.len();
2561    provider
2562        .assign_to_sprint(AssignToSprintInput {
2563            sprint_id: params.sprint_id,
2564            issue_keys: params.issue_keys,
2565        })
2566        .await?;
2567    Ok(ToolOutput::Text(format!(
2568        "Moved {count} issue(s) to sprint {}.",
2569        params.sprint_id
2570    )))
2571}
2572
2573#[derive(Deserialize, Default)]
2574#[serde(rename_all = "camelCase")]
2575struct GetCustomFieldsArgs {
2576    project: Option<String>,
2577    issue_type: Option<String>,
2578    search: Option<String>,
2579    limit: Option<u32>,
2580}
2581
2582async fn execute_get_custom_fields(
2583    provider: &dyn devboy_core::Provider,
2584    args: &Value,
2585) -> Result<ToolOutput> {
2586    let params: GetCustomFieldsArgs = parse_tool_params(args, "get_custom_fields")?;
2587    if let Some(0) = params.limit {
2588        return Err(Error::InvalidData(
2589            "limit must be at least 1 (use the default by omitting the field)".into(),
2590        ));
2591    }
2592    let result = provider
2593        .list_custom_fields(ListCustomFieldsParams {
2594            project: params.project,
2595            issue_type: params.issue_type,
2596            search: params.search,
2597            limit: params.limit,
2598        })
2599        .await?;
2600    let meta = ResultMeta {
2601        pagination: result.pagination,
2602        sort_info: result.sort_info,
2603    };
2604    Ok(ToolOutput::CustomFields(result.items, Some(meta)))
2605}
2606
2607#[cfg(test)]
2608mod tests {
2609    use super::*;
2610    use async_trait::async_trait;
2611    use devboy_core::{
2612        Comment, CreateMergeRequestInput, Discussion, FileDiff, Issue, IssueLink, IssueProvider,
2613        IssueRelations, KbPage, KbPageContent, KbSpace, KnowledgeBaseProvider, MergeRequest,
2614        MergeRequestProvider, Provider, User,
2615    };
2616
2617    // --- Mock Provider ---
2618
2619    struct MockProvider;
2620
2621    fn sample_issue() -> Issue {
2622        Issue {
2623            key: "gh#1".into(),
2624            title: "Test Issue".into(),
2625            description: Some("Body".into()),
2626            state: "open".into(),
2627            source: "mock".into(),
2628            priority: None,
2629            labels: vec!["bug".into()],
2630            author: None,
2631            assignees: vec![],
2632            url: Some("https://example.com/1".into()),
2633            created_at: Some("2024-01-01T00:00:00Z".into()),
2634            updated_at: Some("2024-01-02T00:00:00Z".into()),
2635            attachments_count: None,
2636            parent: None,
2637            subtasks: vec![],
2638            custom_fields: std::collections::HashMap::new(),
2639            ..Default::default()
2640        }
2641    }
2642
2643    fn sample_mr() -> MergeRequest {
2644        MergeRequest {
2645            key: "pr#1".into(),
2646            title: "Test PR".into(),
2647            description: Some("PR body".into()),
2648            state: "open".into(),
2649            source: "mock".into(),
2650            source_branch: "feature".into(),
2651            target_branch: "main".into(),
2652            author: None,
2653            assignees: vec![],
2654            reviewers: vec![],
2655            labels: vec![],
2656            draft: false,
2657            url: Some("https://example.com/pr/1".into()),
2658            created_at: Some("2024-01-01T00:00:00Z".into()),
2659            updated_at: Some("2024-01-02T00:00:00Z".into()),
2660        }
2661    }
2662
2663    fn sample_comment() -> Comment {
2664        Comment {
2665            id: "c1".into(),
2666            body: "Test comment".into(),
2667            author: None,
2668            created_at: None,
2669            updated_at: None,
2670            position: None,
2671        }
2672    }
2673
2674    fn sample_discussion() -> Discussion {
2675        Discussion {
2676            id: "d1".into(),
2677            resolved: false,
2678            resolved_by: None,
2679            comments: vec![sample_comment()],
2680            position: None,
2681        }
2682    }
2683
2684    fn sample_diff() -> FileDiff {
2685        FileDiff {
2686            file_path: "src/main.rs".into(),
2687            old_path: None,
2688            new_file: false,
2689            deleted_file: false,
2690            renamed_file: false,
2691            diff: "+added\n-removed".into(),
2692            additions: Some(1),
2693            deletions: Some(1),
2694        }
2695    }
2696
2697    fn sample_kb_space() -> KbSpace {
2698        KbSpace {
2699            id: "space-1".into(),
2700            key: "ENG".into(),
2701            name: "Engineering".into(),
2702            ..Default::default()
2703        }
2704    }
2705
2706    fn sample_kb_page() -> KbPage {
2707        KbPage {
2708            id: "page-1".into(),
2709            title: "Architecture".into(),
2710            space_key: Some("ENG".into()),
2711            ..Default::default()
2712        }
2713    }
2714
2715    fn sample_kb_page_content() -> KbPageContent {
2716        KbPageContent {
2717            page: sample_kb_page(),
2718            content: "<p>body</p>".into(),
2719            content_type: "storage".into(),
2720            ancestors: vec![],
2721            labels: vec!["docs".into()],
2722        }
2723    }
2724
2725    #[async_trait]
2726    impl IssueProvider for MockProvider {
2727        async fn get_issues(
2728            &self,
2729            _filter: IssueFilter,
2730        ) -> devboy_core::Result<devboy_core::ProviderResult<Issue>> {
2731            Ok(vec![sample_issue()].into())
2732        }
2733        async fn get_issue(&self, _key: &str) -> devboy_core::Result<Issue> {
2734            Ok(sample_issue())
2735        }
2736        async fn create_issue(
2737            &self,
2738            _input: devboy_core::CreateIssueInput,
2739        ) -> devboy_core::Result<Issue> {
2740            Ok(sample_issue())
2741        }
2742        async fn update_issue(
2743            &self,
2744            _key: &str,
2745            _input: devboy_core::UpdateIssueInput,
2746        ) -> devboy_core::Result<Issue> {
2747            Ok(sample_issue())
2748        }
2749        async fn get_comments(
2750            &self,
2751            _key: &str,
2752        ) -> devboy_core::Result<devboy_core::ProviderResult<Comment>> {
2753            Ok(vec![sample_comment()].into())
2754        }
2755        async fn add_comment(&self, _key: &str, _body: &str) -> devboy_core::Result<Comment> {
2756            Ok(sample_comment())
2757        }
2758        async fn get_issue_relations(&self, _key: &str) -> devboy_core::Result<IssueRelations> {
2759            Ok(IssueRelations {
2760                parent: Some(sample_issue()),
2761                subtasks: vec![sample_issue()],
2762                blocks: vec![IssueLink {
2763                    issue: sample_issue(),
2764                    link_type: "Blocks".into(),
2765                }],
2766                ..Default::default()
2767            })
2768        }
2769        async fn get_structures(
2770            &self,
2771        ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::Structure>> {
2772            Ok(vec![sample_structure()].into())
2773        }
2774        async fn get_structure_forest(
2775            &self,
2776            structure_id: u64,
2777            _options: devboy_core::GetForestOptions,
2778        ) -> devboy_core::Result<devboy_core::StructureForest> {
2779            Ok(sample_forest(structure_id))
2780        }
2781        async fn add_structure_rows(
2782            &self,
2783            _structure_id: u64,
2784            input: devboy_core::AddStructureRowsInput,
2785        ) -> devboy_core::Result<devboy_core::ForestModifyResult> {
2786            Ok(devboy_core::ForestModifyResult {
2787                version: 2,
2788                affected_count: input.items.len(),
2789            })
2790        }
2791        async fn move_structure_rows(
2792            &self,
2793            _structure_id: u64,
2794            input: devboy_core::MoveStructureRowsInput,
2795        ) -> devboy_core::Result<devboy_core::ForestModifyResult> {
2796            Ok(devboy_core::ForestModifyResult {
2797                version: 3,
2798                affected_count: input.row_ids.len(),
2799            })
2800        }
2801        async fn remove_structure_row(
2802            &self,
2803            _structure_id: u64,
2804            _row_id: u64,
2805        ) -> devboy_core::Result<()> {
2806            Ok(())
2807        }
2808        async fn get_structure_values(
2809            &self,
2810            input: devboy_core::GetStructureValuesInput,
2811        ) -> devboy_core::Result<devboy_core::StructureValues> {
2812            Ok(devboy_core::StructureValues {
2813                structure_id: input.structure_id,
2814                values: vec![],
2815            })
2816        }
2817        async fn get_structure_views(
2818            &self,
2819            structure_id: u64,
2820            _view_id: Option<u64>,
2821        ) -> devboy_core::Result<Vec<devboy_core::StructureView>> {
2822            Ok(vec![sample_view(structure_id)])
2823        }
2824        async fn save_structure_view(
2825            &self,
2826            input: devboy_core::SaveStructureViewInput,
2827        ) -> devboy_core::Result<devboy_core::StructureView> {
2828            Ok(devboy_core::StructureView {
2829                id: input.id.unwrap_or(99),
2830                name: input.name,
2831                structure_id: input.structure_id,
2832                ..Default::default()
2833            })
2834        }
2835        async fn create_structure(
2836            &self,
2837            input: devboy_core::CreateStructureInput,
2838        ) -> devboy_core::Result<devboy_core::Structure> {
2839            Ok(devboy_core::Structure {
2840                id: 42,
2841                name: input.name,
2842                description: input.description,
2843            })
2844        }
2845        async fn list_project_versions(
2846            &self,
2847            params: devboy_core::ListProjectVersionsParams,
2848        ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::ProjectVersion>> {
2849            // Echo applied filters back through the data so dispatch
2850            // tests can pin behaviour without sniffing call args.
2851            let mut name = format!(
2852                "v-released={:?}-archived={:?}-limit={:?}-expand={}",
2853                params.released, params.archived, params.limit, params.include_issue_count
2854            );
2855            if !params.project.is_empty() {
2856                name.push_str(&format!("-project={}", params.project));
2857            }
2858            Ok(vec![devboy_core::ProjectVersion {
2859                id: "1".into(),
2860                project: if params.project.is_empty() {
2861                    "MOCK".into()
2862                } else {
2863                    params.project
2864                },
2865                name,
2866                description: Some("desc".into()),
2867                start_date: None,
2868                release_date: Some("2026-01-01".into()),
2869                released: false,
2870                archived: false,
2871                overdue: None,
2872                issue_count: Some(0),
2873                unresolved_issue_count: None,
2874                source: "mock".into(),
2875            }]
2876            .into())
2877        }
2878        async fn upsert_project_version(
2879            &self,
2880            input: devboy_core::UpsertProjectVersionInput,
2881        ) -> devboy_core::Result<devboy_core::ProjectVersion> {
2882            Ok(devboy_core::ProjectVersion {
2883                id: "777".into(),
2884                project: if input.project.is_empty() {
2885                    "MOCK".into()
2886                } else {
2887                    input.project
2888                },
2889                name: input.name,
2890                description: input.description,
2891                start_date: input.start_date,
2892                release_date: input.release_date,
2893                released: input.released.unwrap_or(false),
2894                archived: input.archived.unwrap_or(false),
2895                overdue: None,
2896                issue_count: None,
2897                unresolved_issue_count: None,
2898                source: "mock".into(),
2899            })
2900        }
2901        async fn get_board_sprints(
2902            &self,
2903            board_id: u64,
2904            state: devboy_core::SprintState,
2905        ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::Sprint>> {
2906            // Echo applied filters into the name so dispatch tests can
2907            // pin behaviour without sniffing call args.
2908            Ok(vec![devboy_core::Sprint {
2909                id: 1,
2910                name: format!("sprint-board={board_id}-state={state:?}"),
2911                state: "active".into(),
2912                origin_board_id: Some(board_id),
2913                start_date: None,
2914                end_date: None,
2915                goal: None,
2916            }]
2917            .into())
2918        }
2919        async fn assign_to_sprint(
2920            &self,
2921            _input: devboy_core::AssignToSprintInput,
2922        ) -> devboy_core::Result<()> {
2923            Ok(())
2924        }
2925        async fn list_custom_fields(
2926            &self,
2927            params: devboy_core::ListCustomFieldsParams,
2928        ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::CustomFieldDescriptor>>
2929        {
2930            // Return a few fixed entries so dispatch tests can pin
2931            // filter and limit behaviour.
2932            let mut all = vec![
2933                devboy_core::CustomFieldDescriptor {
2934                    id: "customfield_10014".into(),
2935                    name: "Epic Link".into(),
2936                    field_type: "any".into(),
2937                    description: None,
2938                    native: None,
2939                },
2940                devboy_core::CustomFieldDescriptor {
2941                    id: "customfield_10011".into(),
2942                    name: "Epic Name".into(),
2943                    field_type: "string".into(),
2944                    description: None,
2945                    native: None,
2946                },
2947                devboy_core::CustomFieldDescriptor {
2948                    id: "customfield_10020".into(),
2949                    name: "Sprint".into(),
2950                    field_type: "array".into(),
2951                    description: None,
2952                    native: None,
2953                },
2954            ];
2955            if let Some(needle) = params.search.as_deref().map(str::to_lowercase) {
2956                all.retain(|f| f.name.to_lowercase().contains(&needle));
2957            }
2958            let total = all.len() as u32;
2959            let limit = params.limit.unwrap_or(50);
2960            if (limit as usize) < all.len() {
2961                all.truncate(limit as usize);
2962            }
2963            let pagination = devboy_core::Pagination {
2964                offset: 0,
2965                limit,
2966                total: Some(total),
2967                has_more: (all.len() as u32) < total,
2968                next_cursor: None,
2969            };
2970            Ok(devboy_core::ProviderResult::new(all).with_pagination(pagination))
2971        }
2972        fn provider_name(&self) -> &'static str {
2973            "mock"
2974        }
2975    }
2976
2977    #[async_trait]
2978    impl MergeRequestProvider for MockProvider {
2979        async fn get_merge_requests(
2980            &self,
2981            _filter: MrFilter,
2982        ) -> devboy_core::Result<devboy_core::ProviderResult<MergeRequest>> {
2983            Ok(vec![sample_mr()].into())
2984        }
2985        async fn get_merge_request(&self, _key: &str) -> devboy_core::Result<MergeRequest> {
2986            Ok(sample_mr())
2987        }
2988        async fn get_discussions(
2989            &self,
2990            _key: &str,
2991        ) -> devboy_core::Result<devboy_core::ProviderResult<Discussion>> {
2992            Ok(vec![sample_discussion()].into())
2993        }
2994        async fn get_diffs(
2995            &self,
2996            _key: &str,
2997        ) -> devboy_core::Result<devboy_core::ProviderResult<FileDiff>> {
2998            Ok(vec![sample_diff()].into())
2999        }
3000        async fn add_comment(
3001            &self,
3002            _key: &str,
3003            _input: CreateCommentInput,
3004        ) -> devboy_core::Result<Comment> {
3005            Ok(sample_comment())
3006        }
3007        async fn create_merge_request(
3008            &self,
3009            _input: CreateMergeRequestInput,
3010        ) -> devboy_core::Result<MergeRequest> {
3011            Ok(sample_mr())
3012        }
3013        fn provider_name(&self) -> &'static str {
3014            "mock"
3015        }
3016    }
3017
3018    #[async_trait]
3019    impl devboy_core::PipelineProvider for MockProvider {
3020        fn provider_name(&self) -> &'static str {
3021            "mock"
3022        }
3023    }
3024
3025    #[async_trait]
3026    impl KnowledgeBaseProvider for MockProvider {
3027        fn provider_name(&self) -> &'static str {
3028            "mock"
3029        }
3030
3031        async fn get_spaces(&self) -> devboy_core::Result<devboy_core::ProviderResult<KbSpace>> {
3032            Ok(vec![sample_kb_space()].into())
3033        }
3034
3035        async fn list_pages(
3036            &self,
3037            _params: ListPagesParams,
3038        ) -> devboy_core::Result<devboy_core::ProviderResult<KbPage>> {
3039            Ok(vec![sample_kb_page()].into())
3040        }
3041
3042        async fn get_page(&self, _page_id: &str) -> devboy_core::Result<KbPageContent> {
3043            Ok(sample_kb_page_content())
3044        }
3045
3046        async fn create_page(
3047            &self,
3048            _params: devboy_core::CreatePageParams,
3049        ) -> devboy_core::Result<KbPage> {
3050            Ok(sample_kb_page())
3051        }
3052
3053        async fn update_page(
3054            &self,
3055            _params: devboy_core::UpdatePageParams,
3056        ) -> devboy_core::Result<KbPage> {
3057            Ok(sample_kb_page())
3058        }
3059
3060        async fn search(
3061            &self,
3062            _params: SearchKbParams,
3063        ) -> devboy_core::Result<devboy_core::ProviderResult<KbPage>> {
3064            Ok(vec![sample_kb_page()].into())
3065        }
3066    }
3067
3068    #[async_trait]
3069    impl Provider for MockProvider {
3070        async fn get_current_user(&self) -> devboy_core::Result<User> {
3071            Ok(User {
3072                id: "1".into(),
3073                username: "test".into(),
3074                name: None,
3075                email: None,
3076                avatar_url: None,
3077            })
3078        }
3079    }
3080
3081    // --- Tests ---
3082
3083    #[test]
3084    fn test_executor_new() {
3085        let executor = Executor::new();
3086        assert!(executor.enrichers.is_empty());
3087    }
3088
3089    #[test]
3090    fn test_supported_tools_contains_all() {
3091        assert!(SUPPORTED_TOOLS.contains(&"get_issues"));
3092        assert!(SUPPORTED_TOOLS.contains(&"get_merge_requests"));
3093        assert!(SUPPORTED_TOOLS.contains(&"create_merge_request_comment"));
3094        assert!(SUPPORTED_TOOLS.contains(&"get_meeting_notes"));
3095        assert!(SUPPORTED_TOOLS.contains(&"get_meeting_transcript"));
3096        assert!(SUPPORTED_TOOLS.contains(&"search_meeting_notes"));
3097        assert!(SUPPORTED_TOOLS.contains(&"get_knowledge_base_spaces"));
3098        assert!(SUPPORTED_TOOLS.contains(&"list_knowledge_base_pages"));
3099        assert!(SUPPORTED_TOOLS.contains(&"get_knowledge_base_page"));
3100        assert!(SUPPORTED_TOOLS.contains(&"create_knowledge_base_page"));
3101        assert!(SUPPORTED_TOOLS.contains(&"update_knowledge_base_page"));
3102        assert!(SUPPORTED_TOOLS.contains(&"search_knowledge_base"));
3103        assert!(SUPPORTED_TOOLS.contains(&"get_messenger_chats"));
3104        assert!(SUPPORTED_TOOLS.contains(&"get_chat_messages"));
3105        assert!(SUPPORTED_TOOLS.contains(&"search_chat_messages"));
3106        assert!(SUPPORTED_TOOLS.contains(&"send_message"));
3107        assert_eq!(SUPPORTED_TOOLS.len(), 40);
3108    }
3109
3110    #[tokio::test]
3111    async fn test_dispatch_get_knowledge_base_spaces() {
3112        let provider = MockProvider;
3113        let result =
3114            dispatch_knowledge_base_tool("get_knowledge_base_spaces", &Value::Null, &provider)
3115                .await
3116                .unwrap();
3117        assert!(matches!(result, ToolOutput::KnowledgeBaseSpaces(v, _) if v.len() == 1));
3118    }
3119
3120    #[tokio::test]
3121    async fn test_dispatch_list_knowledge_base_pages() {
3122        let provider = MockProvider;
3123        let args = serde_json::json!({"spaceKey": "ENG", "limit": 10});
3124        let result = dispatch_knowledge_base_tool("list_knowledge_base_pages", &args, &provider)
3125            .await
3126            .unwrap();
3127        assert!(matches!(result, ToolOutput::KnowledgeBasePages(v, _) if v.len() == 1));
3128    }
3129
3130    #[tokio::test]
3131    async fn test_dispatch_get_knowledge_base_page() {
3132        let provider = MockProvider;
3133        let args = serde_json::json!({"pageId": "page-1"});
3134        let result = dispatch_knowledge_base_tool("get_knowledge_base_page", &args, &provider)
3135            .await
3136            .unwrap();
3137        assert!(matches!(result, ToolOutput::KnowledgeBasePage(_)));
3138    }
3139
3140    #[tokio::test]
3141    async fn test_dispatch_create_knowledge_base_page() {
3142        let provider = MockProvider;
3143        let args = serde_json::json!({
3144            "spaceKey": "ENG",
3145            "title": "New Page",
3146            "content": "<p>body</p>",
3147            "contentType": "storage",
3148            "labels": ["docs"]
3149        });
3150        let result = dispatch_knowledge_base_tool("create_knowledge_base_page", &args, &provider)
3151            .await
3152            .unwrap();
3153        assert!(matches!(result, ToolOutput::KnowledgeBasePageSummary(_)));
3154    }
3155
3156    #[tokio::test]
3157    async fn test_dispatch_update_knowledge_base_page() {
3158        let provider = MockProvider;
3159        let args = serde_json::json!({
3160            "pageId": "page-1",
3161            "title": "Updated",
3162            "content": "<p>new body</p>",
3163            "version": 2
3164        });
3165        let result = dispatch_knowledge_base_tool("update_knowledge_base_page", &args, &provider)
3166            .await
3167            .unwrap();
3168        assert!(matches!(result, ToolOutput::KnowledgeBasePageSummary(_)));
3169    }
3170
3171    #[tokio::test]
3172    async fn test_dispatch_search_knowledge_base() {
3173        let provider = MockProvider;
3174        let args = serde_json::json!({"query": "architecture", "spaceKey": "ENG"});
3175        let result = dispatch_knowledge_base_tool("search_knowledge_base", &args, &provider)
3176            .await
3177            .unwrap();
3178        assert!(matches!(result, ToolOutput::KnowledgeBasePages(v, _) if v.len() == 1));
3179    }
3180
3181    // --- Issue tool dispatch tests ---
3182
3183    #[tokio::test]
3184    async fn test_dispatch_get_issues() {
3185        let provider = MockProvider;
3186        let args = serde_json::json!({"state": "open", "limit": 10});
3187        let result = dispatch_tool("get_issues", &args, &provider, None)
3188            .await
3189            .unwrap();
3190        assert!(matches!(result, ToolOutput::Issues(v, _) if v.len() == 1));
3191    }
3192
3193    #[tokio::test]
3194    async fn test_dispatch_get_issues_empty_args() {
3195        let provider = MockProvider;
3196        let result = dispatch_tool("get_issues", &Value::Null, &provider, None)
3197            .await
3198            .unwrap();
3199        assert!(matches!(result, ToolOutput::Issues(_, _)));
3200    }
3201
3202    #[tokio::test]
3203    async fn test_dispatch_get_issues_invalid_params_are_rejected() {
3204        // Regression for #188: before parse_tool_params the executor
3205        // silently accepted `{"state": 42}` by falling through to
3206        // default() and ran the tool without any filter. Now it must
3207        // surface the deserialisation error instead.
3208        let provider = MockProvider;
3209        let args = serde_json::json!({"state": 42});
3210        let err = dispatch_tool("get_issues", &args, &provider, None)
3211            .await
3212            .unwrap_err();
3213        assert!(
3214            matches!(err, devboy_core::Error::InvalidData(ref msg) if msg.contains("get_issues")),
3215            "expected InvalidData referencing get_issues, got {err:?}"
3216        );
3217    }
3218
3219    #[tokio::test]
3220    async fn test_dispatch_get_merge_requests_invalid_params_rejected() {
3221        let provider = MockProvider;
3222        let args = serde_json::json!({"limit": "not-a-number"});
3223        let err = dispatch_tool("get_merge_requests", &args, &provider, None)
3224            .await
3225            .unwrap_err();
3226        assert!(
3227            matches!(err, devboy_core::Error::InvalidData(ref msg) if msg.contains("get_merge_requests")),
3228            "expected InvalidData referencing get_merge_requests, got {err:?}"
3229        );
3230    }
3231
3232    #[tokio::test]
3233    async fn test_dispatch_get_pipeline_invalid_params_rejected() {
3234        let provider = MockProvider;
3235        let args = serde_json::json!({"includeFailedLogs": "yes"});
3236        let err = dispatch_tool("get_pipeline", &args, &provider, None)
3237            .await
3238            .unwrap_err();
3239        assert!(
3240            matches!(err, devboy_core::Error::InvalidData(ref msg) if msg.contains("get_pipeline")),
3241            "expected InvalidData referencing get_pipeline, got {err:?}"
3242        );
3243    }
3244
3245    #[test]
3246    fn parse_tool_params_null_yields_default() {
3247        #[derive(Debug, Default, serde::Deserialize)]
3248        struct P {
3249            #[allow(dead_code)]
3250            x: Option<String>,
3251        }
3252        let _: P = parse_tool_params(&Value::Null, "test").expect("null → default");
3253    }
3254
3255    #[test]
3256    fn parse_tool_params_empty_object_yields_default() {
3257        // MCP clients often send `{}` for "no arguments"; the helper
3258        // must accept it alongside `null`.
3259        #[derive(Debug, Default, serde::Deserialize)]
3260        struct P {
3261            #[allow(dead_code)]
3262            x: Option<String>,
3263        }
3264        let _: P = parse_tool_params(&serde_json::json!({}), "test").expect("{} → default");
3265    }
3266
3267    #[test]
3268    fn parse_tool_params_invalid_maps_to_invalid_data() {
3269        #[derive(Debug, Default, serde::Deserialize)]
3270        struct P {
3271            #[allow(dead_code)]
3272            n: u32,
3273        }
3274        let err = parse_tool_params::<P>(&serde_json::json!({"n": "nope"}), "tool-x").unwrap_err();
3275        assert!(
3276            matches!(err, devboy_core::Error::InvalidData(ref msg) if msg.contains("tool-x")),
3277            "expected InvalidData(tool-x), got {err:?}"
3278        );
3279    }
3280
3281    #[tokio::test]
3282    async fn test_dispatch_get_issue() {
3283        let provider = MockProvider;
3284        // With includeComments/includeRelations defaulting to true, returns composite Text
3285        let args = serde_json::json!({"key": "gh#1"});
3286        let result = dispatch_tool("get_issue", &args, &provider, None)
3287            .await
3288            .unwrap();
3289        assert!(matches!(result, ToolOutput::Text(_)));
3290
3291        // Without extras, returns SingleIssue
3292        let args =
3293            serde_json::json!({"key": "gh#1", "includeComments": false, "includeRelations": false});
3294        let result = dispatch_tool("get_issue", &args, &provider, None)
3295            .await
3296            .unwrap();
3297        assert!(matches!(result, ToolOutput::SingleIssue(_)));
3298    }
3299
3300    #[tokio::test]
3301    async fn test_dispatch_get_issue_missing_key() {
3302        let provider = MockProvider;
3303        let result = dispatch_tool("get_issue", &serde_json::json!({}), &provider, None).await;
3304        assert!(result.is_err());
3305    }
3306
3307    #[tokio::test]
3308    async fn test_dispatch_get_issue_comments() {
3309        let provider = MockProvider;
3310        let args = serde_json::json!({"key": "gh#1"});
3311        let result = dispatch_tool("get_issue_comments", &args, &provider, None)
3312            .await
3313            .unwrap();
3314        assert!(matches!(result, ToolOutput::Comments(v, _) if v.len() == 1));
3315    }
3316
3317    #[tokio::test]
3318    async fn test_dispatch_create_issue() {
3319        let provider = MockProvider;
3320        let args =
3321            serde_json::json!({"title": "New issue", "description": "Body", "labels": ["bug"]});
3322        let result = dispatch_tool("create_issue", &args, &provider, None)
3323            .await
3324            .unwrap();
3325        assert!(matches!(result, ToolOutput::SingleIssue(_)));
3326    }
3327
3328    #[test]
3329    fn create_issue_params_accepts_parent_id_alias() {
3330        let args = serde_json::json!({ "title": "t", "parentId": "DEV-799" });
3331        let params: CreateIssueParams = serde_json::from_value(args).unwrap();
3332        assert_eq!(params.parent.as_deref(), Some("DEV-799"));
3333    }
3334
3335    #[test]
3336    fn create_issue_params_still_accepts_parent() {
3337        let args = serde_json::json!({ "title": "t", "parent": "DEV-799" });
3338        let params: CreateIssueParams = serde_json::from_value(args).unwrap();
3339        assert_eq!(params.parent.as_deref(), Some("DEV-799"));
3340    }
3341
3342    #[tokio::test]
3343    async fn test_dispatch_update_issue() {
3344        let provider = MockProvider;
3345        let args = serde_json::json!({"key": "gh#1", "title": "Updated"});
3346        let result = dispatch_tool("update_issue", &args, &provider, None)
3347            .await
3348            .unwrap();
3349        assert!(matches!(result, ToolOutput::SingleIssue(_)));
3350    }
3351
3352    #[tokio::test]
3353    async fn test_dispatch_add_issue_comment() {
3354        let provider = MockProvider;
3355        let args = serde_json::json!({"key": "gh#1", "body": "A comment"});
3356        let result = dispatch_tool("add_issue_comment", &args, &provider, None)
3357            .await
3358            .unwrap();
3359        assert!(matches!(result, ToolOutput::Text(ref t) if t.contains("Comment added")));
3360    }
3361
3362    #[tokio::test]
3363    async fn test_dispatch_get_issue_relations() {
3364        let provider = MockProvider;
3365        let args = serde_json::json!({"key": "gh#1"});
3366        let result = dispatch_tool("get_issue_relations", &args, &provider, None)
3367            .await
3368            .unwrap();
3369        match result {
3370            ToolOutput::Relations(relations) => {
3371                assert!(relations.parent.is_some());
3372                assert_eq!(relations.subtasks.len(), 1);
3373                assert_eq!(relations.blocks.len(), 1);
3374            }
3375            other => panic!("Expected Relations, got {:?}", other),
3376        }
3377    }
3378
3379    #[tokio::test]
3380    async fn test_dispatch_get_issue_relations_missing_key() {
3381        let provider = MockProvider;
3382        let result = dispatch_tool(
3383            "get_issue_relations",
3384            &serde_json::json!({}),
3385            &provider,
3386            None,
3387        )
3388        .await;
3389        assert!(result.is_err());
3390    }
3391
3392    // --- MR tool dispatch tests ---
3393
3394    #[tokio::test]
3395    async fn test_dispatch_get_merge_requests() {
3396        let provider = MockProvider;
3397        let args = serde_json::json!({"state": "open", "limit": 5});
3398        let result = dispatch_tool("get_merge_requests", &args, &provider, None)
3399            .await
3400            .unwrap();
3401        assert!(matches!(result, ToolOutput::MergeRequests(v, _) if v.len() == 1));
3402    }
3403
3404    #[tokio::test]
3405    async fn test_dispatch_get_merge_requests_empty_args() {
3406        let provider = MockProvider;
3407        let result = dispatch_tool("get_merge_requests", &Value::Null, &provider, None)
3408            .await
3409            .unwrap();
3410        assert!(matches!(result, ToolOutput::MergeRequests(_, _)));
3411    }
3412
3413    #[tokio::test]
3414    async fn test_dispatch_get_merge_request() {
3415        let provider = MockProvider;
3416        let args = serde_json::json!({"key": "pr#1"});
3417        let result = dispatch_tool("get_merge_request", &args, &provider, None)
3418            .await
3419            .unwrap();
3420        assert!(matches!(result, ToolOutput::SingleMergeRequest(_)));
3421    }
3422
3423    #[tokio::test]
3424    async fn test_dispatch_get_merge_request_discussions() {
3425        let provider = MockProvider;
3426        let args = serde_json::json!({"key": "pr#1"});
3427        let result = dispatch_tool("get_merge_request_discussions", &args, &provider, None)
3428            .await
3429            .unwrap();
3430        assert!(matches!(result, ToolOutput::Discussions(v, _) if v.len() == 1));
3431    }
3432
3433    #[tokio::test]
3434    async fn test_dispatch_get_merge_request_diffs() {
3435        let provider = MockProvider;
3436        let args = serde_json::json!({"key": "pr#1"});
3437        let result = dispatch_tool("get_merge_request_diffs", &args, &provider, None)
3438            .await
3439            .unwrap();
3440        assert!(matches!(result, ToolOutput::Diffs(v, _) if v.len() == 1));
3441    }
3442
3443    #[tokio::test]
3444    async fn test_dispatch_create_merge_request() {
3445        let provider = MockProvider;
3446        let args = serde_json::json!({
3447            "title": "New PR",
3448            "source_branch": "feature",
3449            "target_branch": "main",
3450            "draft": false
3451        });
3452        let result = dispatch_tool("create_merge_request", &args, &provider, None)
3453            .await
3454            .unwrap();
3455        assert!(matches!(result, ToolOutput::SingleMergeRequest(_)));
3456    }
3457
3458    #[tokio::test]
3459    async fn test_dispatch_create_merge_request_comment_general() {
3460        let provider = MockProvider;
3461        let args = serde_json::json!({"key": "pr#1", "body": "LGTM"});
3462        let result = dispatch_tool("create_merge_request_comment", &args, &provider, None)
3463            .await
3464            .unwrap();
3465        assert!(matches!(result, ToolOutput::Text(ref t) if t.contains("Comment added")));
3466    }
3467
3468    #[tokio::test]
3469    async fn test_dispatch_create_merge_request_comment_inline() {
3470        let provider = MockProvider;
3471        let args = serde_json::json!({
3472            "key": "pr#1",
3473            "body": "Fix this line",
3474            "file_path": "src/main.rs",
3475            "line": 42,
3476            "line_type": "new",
3477            "commit_sha": "abc123"
3478        });
3479        let result = dispatch_tool("create_merge_request_comment", &args, &provider, None)
3480            .await
3481            .unwrap();
3482        assert!(matches!(result, ToolOutput::Text(ref t) if t.contains("Comment added")));
3483    }
3484
3485    #[test]
3486    fn test_create_merge_request_comment_params_accept_camel_case() {
3487        let args = serde_json::json!({
3488            "mrKey": "mr#566",
3489            "body": "reply",
3490            "filePath": "src/main.rs",
3491            "line": 12,
3492            "lineType": "new",
3493            "commitSha": "abc123",
3494            "discussionId": "788adb16c57805c9a5d59272c944cddea381a605"
3495        });
3496
3497        let params: CreateMrCommentParams = serde_json::from_value(args).unwrap();
3498        assert_eq!(params.key, "mr#566");
3499        assert_eq!(params.file_path.as_deref(), Some("src/main.rs"));
3500        assert_eq!(params.line_type.as_deref(), Some("new"));
3501        assert_eq!(params.commit_sha.as_deref(), Some("abc123"));
3502        assert_eq!(
3503            params.discussion_id.as_deref(),
3504            Some("788adb16c57805c9a5d59272c944cddea381a605")
3505        );
3506    }
3507
3508    #[test]
3509    fn test_create_merge_request_comment_params_still_accept_snake_case() {
3510        // Belt-and-suspenders: the camelCase aliases must not break the
3511        // original snake_case payload shape, which the MCP schema
3512        // declares and which some callers (our own skills included)
3513        // send today.
3514        let args = serde_json::json!({
3515            "key": "mr#566",
3516            "body": "reply",
3517            "file_path": "src/main.rs",
3518            "line": 12,
3519            "line_type": "new",
3520            "commit_sha": "abc123",
3521            "discussion_id": "788adb16c57805c9a5d59272c944cddea381a605"
3522        });
3523
3524        let params: CreateMrCommentParams = serde_json::from_value(args).unwrap();
3525        assert_eq!(params.key, "mr#566");
3526        assert_eq!(params.file_path.as_deref(), Some("src/main.rs"));
3527        assert_eq!(params.line_type.as_deref(), Some("new"));
3528        assert_eq!(params.commit_sha.as_deref(), Some("abc123"));
3529        assert_eq!(
3530            params.discussion_id.as_deref(),
3531            Some("788adb16c57805c9a5d59272c944cddea381a605")
3532        );
3533    }
3534
3535    #[tokio::test]
3536    async fn test_dispatch_create_merge_request_comment_accepts_camel_case_args() {
3537        // End-to-end: the executor's `dispatch_tool` path must accept
3538        // the same camelCase payload that real MCP clients (and our
3539        // skills) send, otherwise the alias would only help direct
3540        // `from_value` callers.
3541        let provider = MockProvider;
3542        let args = serde_json::json!({
3543            "mrKey": "mr#1",
3544            "body": "threaded reply",
3545            "discussionId": "abc123"
3546        });
3547        let result = dispatch_tool("create_merge_request_comment", &args, &provider, None)
3548            .await
3549            .unwrap();
3550        assert!(matches!(result, ToolOutput::Text(ref t) if t.contains("Comment added")));
3551    }
3552
3553    #[tokio::test]
3554    async fn test_dispatch_unknown_tool() {
3555        let provider = MockProvider;
3556        let result = dispatch_tool("nonexistent_tool", &Value::Null, &provider, None).await;
3557        assert!(result.is_err());
3558    }
3559
3560    // --- Executor enricher integration ---
3561
3562    #[tokio::test]
3563    async fn test_executor_enricher_transforms_args() {
3564        use devboy_core::{ToolEnricher, ToolSchema};
3565
3566        struct TestEnricher;
3567        impl ToolEnricher for TestEnricher {
3568            fn supported_categories(&self) -> &[devboy_core::ToolCategory] {
3569                &[devboy_core::ToolCategory::IssueTracker]
3570            }
3571            fn enrich_schema(&self, _tool: &str, _schema: &mut ToolSchema) {}
3572            fn transform_args(&self, _tool: &str, args: &mut Value) {
3573                if let Some(obj) = args.as_object_mut() {
3574                    obj.insert("transformed".into(), Value::Bool(true));
3575                }
3576            }
3577        }
3578
3579        let mut executor = Executor::new();
3580        executor.add_enricher(Box::new(TestEnricher));
3581        assert_eq!(executor.enrichers.len(), 1);
3582    }
3583
3584    // --- Pipeline dispatch tests ---
3585
3586    #[tokio::test]
3587    async fn test_dispatch_get_pipeline_unsupported() {
3588        let provider = MockProvider;
3589        let args = serde_json::json!({"branch": "main"});
3590        let result = dispatch_tool("get_pipeline", &args, &provider, None).await;
3591        // MockProvider doesn't implement get_pipeline → ProviderUnsupported
3592        assert!(result.is_err());
3593    }
3594
3595    #[tokio::test]
3596    async fn test_dispatch_get_job_logs_unsupported() {
3597        let provider = MockProvider;
3598        let args = serde_json::json!({"jobId": "123"});
3599        let result = dispatch_tool("get_job_logs", &args, &provider, None).await;
3600        assert!(result.is_err());
3601    }
3602
3603    #[tokio::test]
3604    async fn test_dispatch_get_pipeline_with_mr_key() {
3605        let provider = MockProvider;
3606        let args = serde_json::json!({"mrKey": "pr#1", "includeFailedLogs": false});
3607        let result = dispatch_tool("get_pipeline", &args, &provider, None).await;
3608        assert!(result.is_err());
3609    }
3610
3611    #[tokio::test]
3612    async fn test_dispatch_get_job_logs_with_pattern() {
3613        let provider = MockProvider;
3614        let args = serde_json::json!({"jobId": "123", "pattern": "ERROR", "context": 3});
3615        let result = dispatch_tool("get_job_logs", &args, &provider, None).await;
3616        assert!(result.is_err());
3617    }
3618
3619    #[tokio::test]
3620    async fn test_dispatch_get_job_logs_paginated() {
3621        let provider = MockProvider;
3622        let args = serde_json::json!({"jobId": "123", "offset": 10, "limit": 50});
3623        let result = dispatch_tool("get_job_logs", &args, &provider, None).await;
3624        assert!(result.is_err());
3625    }
3626
3627    #[tokio::test]
3628    async fn test_dispatch_get_job_logs_full() {
3629        let provider = MockProvider;
3630        let args = serde_json::json!({"jobId": "123", "full": true});
3631        let result = dispatch_tool("get_job_logs", &args, &provider, None).await;
3632        assert!(result.is_err());
3633    }
3634
3635    #[test]
3636    fn test_executor_default() {
3637        let executor = Executor::default();
3638        assert!(executor.enrichers.is_empty());
3639    }
3640
3641    // --- Status / User / Link / Epic dispatch tests ---
3642
3643    #[tokio::test]
3644    async fn test_dispatch_get_available_statuses_unsupported() {
3645        let provider = MockProvider;
3646        let result = dispatch_tool("get_available_statuses", &Value::Null, &provider, None).await;
3647        // MockProvider returns ProviderUnsupported for get_statuses
3648        assert!(result.is_err());
3649    }
3650
3651    #[tokio::test]
3652    async fn test_dispatch_get_users_unsupported() {
3653        let provider = MockProvider;
3654        let args = serde_json::json!({"search": "test"});
3655        let result = dispatch_tool("get_users", &args, &provider, None).await;
3656        // MockProvider uses default impl which returns ProviderUnsupported
3657        assert!(result.is_err());
3658    }
3659
3660    #[tokio::test]
3661    async fn test_dispatch_link_issues_unsupported() {
3662        let provider = MockProvider;
3663        let args = serde_json::json!({
3664            "source_key": "gh#1",
3665            "target_key": "gh#2",
3666            "link_type": "blocks"
3667        });
3668        let result = dispatch_tool("link_issues", &args, &provider, None).await;
3669        assert!(result.is_err());
3670    }
3671
3672    #[tokio::test]
3673    async fn test_dispatch_get_epics() {
3674        let provider = MockProvider;
3675        let args = serde_json::json!({"state": "open", "limit": 10});
3676        let result = dispatch_tool("get_epics", &args, &provider, None)
3677            .await
3678            .unwrap();
3679        // Returns enriched JSON with goal_id and progress
3680        assert!(matches!(result, ToolOutput::Text(_)));
3681    }
3682
3683    #[tokio::test]
3684    async fn test_dispatch_get_epics_empty_args() {
3685        let provider = MockProvider;
3686        let result = dispatch_tool("get_epics", &Value::Null, &provider, None)
3687            .await
3688            .unwrap();
3689        assert!(matches!(result, ToolOutput::Text(_)));
3690    }
3691
3692    #[tokio::test]
3693    async fn test_dispatch_create_epic() {
3694        let provider = MockProvider;
3695        let args = serde_json::json!({"title": "New Epic", "description": "Epic description"});
3696        let result = dispatch_tool("create_epic", &args, &provider, None)
3697            .await
3698            .unwrap();
3699        assert!(matches!(result, ToolOutput::SingleIssue(_)));
3700    }
3701
3702    #[tokio::test]
3703    async fn test_dispatch_update_epic() {
3704        let provider = MockProvider;
3705        let args = serde_json::json!({"key": "gh#1", "title": "Updated Epic"});
3706        let result = dispatch_tool("update_epic", &args, &provider, None)
3707            .await
3708            .unwrap();
3709        assert!(matches!(result, ToolOutput::SingleIssue(_)));
3710    }
3711
3712    #[tokio::test]
3713    async fn test_dispatch_link_issues_missing_params() {
3714        let provider = MockProvider;
3715        let args = serde_json::json!({"source_key": "gh#1"});
3716        let result = dispatch_tool("link_issues", &args, &provider, None).await;
3717        assert!(result.is_err());
3718    }
3719
3720    // --- Mock MeetingNotesProvider tests ---
3721
3722    struct MockMeetingProvider;
3723
3724    #[async_trait]
3725    impl MeetingNotesProvider for MockMeetingProvider {
3726        fn provider_name(&self) -> &'static str {
3727            "mock_meetings"
3728        }
3729
3730        async fn get_meetings(
3731            &self,
3732            _filter: MeetingFilter,
3733        ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::MeetingNote>> {
3734            Ok(vec![devboy_core::MeetingNote {
3735                id: "m1".into(),
3736                title: "Test Meeting".into(),
3737                ..Default::default()
3738            }]
3739            .into())
3740        }
3741
3742        async fn get_transcript(
3743            &self,
3744            meeting_id: &str,
3745        ) -> devboy_core::Result<devboy_core::MeetingTranscript> {
3746            Ok(devboy_core::MeetingTranscript {
3747                meeting_id: meeting_id.to_string(),
3748                title: Some("Test Transcript".into()),
3749                sentences: vec![devboy_core::TranscriptSentence {
3750                    speaker_id: "s1".into(),
3751                    speaker_name: Some("Alice".into()),
3752                    text: "Hello".into(),
3753                    start_time: 0.0,
3754                    end_time: 1.0,
3755                }],
3756            })
3757        }
3758
3759        async fn search_meetings(
3760            &self,
3761            _query: &str,
3762            _filter: MeetingFilter,
3763        ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::MeetingNote>> {
3764            Ok(vec![devboy_core::MeetingNote {
3765                id: "m2".into(),
3766                title: "Search Result Meeting".into(),
3767                ..Default::default()
3768            }]
3769            .into())
3770        }
3771    }
3772
3773    #[tokio::test]
3774    async fn test_dispatch_get_meeting_notes() {
3775        let provider = MockMeetingProvider;
3776        let args = serde_json::json!({"from_date": "2025-01-01", "limit": 10});
3777        let result = dispatch_meeting_tool("get_meeting_notes", &args, &provider)
3778            .await
3779            .unwrap();
3780        match result {
3781            ToolOutput::MeetingNotes(meetings, _) => {
3782                assert_eq!(meetings.len(), 1);
3783                assert_eq!(meetings[0].title, "Test Meeting");
3784            }
3785            other => panic!("Expected MeetingNotes, got {:?}", other),
3786        }
3787    }
3788
3789    #[tokio::test]
3790    async fn test_dispatch_get_meeting_transcript() {
3791        let provider = MockMeetingProvider;
3792        let args = serde_json::json!({"meeting_id": "m1"});
3793        let result = dispatch_meeting_tool("get_meeting_transcript", &args, &provider)
3794            .await
3795            .unwrap();
3796        match result {
3797            ToolOutput::MeetingTranscript(transcript) => {
3798                assert_eq!(transcript.meeting_id, "m1");
3799                assert_eq!(transcript.sentences.len(), 1);
3800                assert_eq!(transcript.sentences[0].speaker_name, Some("Alice".into()));
3801            }
3802            other => panic!("Expected MeetingTranscript, got {:?}", other),
3803        }
3804    }
3805
3806    #[tokio::test]
3807    async fn test_dispatch_search_meeting_notes() {
3808        let provider = MockMeetingProvider;
3809        let args = serde_json::json!({"query": "sprint", "limit": 5});
3810        let result = dispatch_meeting_tool("search_meeting_notes", &args, &provider)
3811            .await
3812            .unwrap();
3813        match result {
3814            ToolOutput::MeetingNotes(meetings, _) => {
3815                assert_eq!(meetings.len(), 1);
3816                assert_eq!(meetings[0].title, "Search Result Meeting");
3817            }
3818            other => panic!("Expected MeetingNotes, got {:?}", other),
3819        }
3820    }
3821
3822    #[tokio::test]
3823    async fn test_dispatch_unknown_meeting_tool() {
3824        let provider = MockMeetingProvider;
3825        let result = dispatch_meeting_tool("nonexistent_tool", &Value::Null, &provider).await;
3826        assert!(result.is_err());
3827    }
3828
3829    // =========================================================================
3830    // Structure tool dispatch tests
3831    // =========================================================================
3832
3833    fn sample_structure() -> devboy_core::Structure {
3834        devboy_core::Structure {
3835            id: 1,
3836            name: "Q1 Plan".into(),
3837            description: Some("Quarter 1 planning".into()),
3838        }
3839    }
3840
3841    fn sample_forest(structure_id: u64) -> devboy_core::StructureForest {
3842        devboy_core::StructureForest {
3843            version: 1,
3844            structure_id,
3845            tree: vec![devboy_core::StructureNode {
3846                row_id: 100,
3847                item_id: Some("PROJ-1".into()),
3848                item_type: Some("issue".into()),
3849                children: vec![],
3850            }],
3851            total_count: Some(1),
3852        }
3853    }
3854
3855    fn sample_view(structure_id: u64) -> devboy_core::StructureView {
3856        devboy_core::StructureView {
3857            id: 10,
3858            name: "Default".into(),
3859            structure_id,
3860            ..Default::default()
3861        }
3862    }
3863
3864    #[tokio::test]
3865    async fn test_dispatch_get_structures() {
3866        let provider = MockProvider;
3867        let result = dispatch_tool("get_structures", &Value::Null, &provider, None)
3868            .await
3869            .unwrap();
3870        assert!(matches!(result, ToolOutput::Structures(ref items, _) if items.len() == 1));
3871        assert_eq!(result.type_name(), "structures");
3872    }
3873
3874    #[tokio::test]
3875    async fn test_dispatch_get_structure_forest() {
3876        let provider = MockProvider;
3877        let args = serde_json::json!({"structureId": 1});
3878        let result = dispatch_tool("get_structure_forest", &args, &provider, None)
3879            .await
3880            .unwrap();
3881        assert!(matches!(result, ToolOutput::StructureForest(_)));
3882        assert_eq!(result.type_name(), "structure_forest");
3883    }
3884
3885    #[tokio::test]
3886    async fn test_dispatch_get_structure_forest_missing_id() {
3887        let provider = MockProvider;
3888        let result = dispatch_tool("get_structure_forest", &Value::Null, &provider, None).await;
3889        assert!(result.is_err());
3890    }
3891
3892    #[tokio::test]
3893    async fn test_dispatch_add_structure_rows() {
3894        let provider = MockProvider;
3895        let args = serde_json::json!({
3896            "structureId": 1,
3897            "items": ["PROJ-1", "PROJ-2"],
3898            "under": 100
3899        });
3900        let result = dispatch_tool("add_structure_rows", &args, &provider, None)
3901            .await
3902            .unwrap();
3903        match result {
3904            ToolOutput::ForestModified(r) => {
3905                assert_eq!(r.version, 2);
3906                assert_eq!(r.affected_count, 2);
3907            }
3908            _ => panic!("expected ForestModified"),
3909        }
3910    }
3911
3912    #[tokio::test]
3913    async fn test_dispatch_move_structure_rows() {
3914        let provider = MockProvider;
3915        let args = serde_json::json!({
3916            "structureId": 1,
3917            "rowIds": [100, 101],
3918            "under": 200
3919        });
3920        let result = dispatch_tool("move_structure_rows", &args, &provider, None)
3921            .await
3922            .unwrap();
3923        assert!(matches!(result, ToolOutput::ForestModified(_)));
3924    }
3925
3926    #[tokio::test]
3927    async fn test_dispatch_remove_structure_row() {
3928        let provider = MockProvider;
3929        let args = serde_json::json!({"structureId": 1, "rowId": 100});
3930        let result = dispatch_tool("remove_structure_row", &args, &provider, None)
3931            .await
3932            .unwrap();
3933        assert!(matches!(result, ToolOutput::Text(_)));
3934    }
3935
3936    #[tokio::test]
3937    async fn test_dispatch_get_structure_values() {
3938        let provider = MockProvider;
3939        let args = serde_json::json!({
3940            "structureId": 1,
3941            "rows": [100],
3942            "columns": ["summary", {"field": "status"}]
3943        });
3944        let result = dispatch_tool("get_structure_values", &args, &provider, None)
3945            .await
3946            .unwrap();
3947        assert!(matches!(result, ToolOutput::StructureValues(_)));
3948    }
3949
3950    #[tokio::test]
3951    async fn test_dispatch_get_structure_views() {
3952        let provider = MockProvider;
3953        let args = serde_json::json!({"structureId": 1});
3954        let result = dispatch_tool("get_structure_views", &args, &provider, None)
3955            .await
3956            .unwrap();
3957        assert!(matches!(result, ToolOutput::StructureViews(views, _) if views.len() == 1));
3958    }
3959
3960    #[tokio::test]
3961    async fn test_dispatch_save_structure_view() {
3962        let provider = MockProvider;
3963        let args = serde_json::json!({
3964            "structureId": 1,
3965            "name": "Sprint View"
3966        });
3967        let result = dispatch_tool("save_structure_view", &args, &provider, None)
3968            .await
3969            .unwrap();
3970        assert!(
3971            matches!(result, ToolOutput::StructureViews(views, _) if views[0].name == "Sprint View")
3972        );
3973    }
3974
3975    #[tokio::test]
3976    async fn test_dispatch_create_structure() {
3977        let provider = MockProvider;
3978        let args = serde_json::json!({"name": "New Structure", "description": "Test"});
3979        let result = dispatch_tool("create_structure", &args, &provider, None)
3980            .await
3981            .unwrap();
3982        match result {
3983            ToolOutput::Structures(items, _) => {
3984                assert_eq!(items[0].name, "New Structure");
3985                assert_eq!(items[0].id, 42);
3986            }
3987            _ => panic!("expected Structures"),
3988        }
3989    }
3990
3991    // -------------------------------------------------------------------
3992    // Project versions / fixVersion (issue #238)
3993    // -------------------------------------------------------------------
3994
3995    #[tokio::test]
3996    async fn test_dispatch_list_project_versions_applies_paper_defaults() {
3997        // No filter args → archived defaults to false, limit to 20.
3998        let provider = MockProvider;
3999        let result = dispatch_tool(
4000            "list_project_versions",
4001            &serde_json::json!({}),
4002            &provider,
4003            None,
4004        )
4005        .await
4006        .unwrap();
4007        match result {
4008            ToolOutput::ProjectVersions(items, _) => {
4009                let echoed = &items[0].name;
4010                assert!(echoed.contains("released=None"), "got {echoed}");
4011                assert!(echoed.contains("archived=Some(false)"), "got {echoed}");
4012                assert!(echoed.contains("limit=Some(20)"), "got {echoed}");
4013                assert!(echoed.contains("expand=false"), "got {echoed}");
4014            }
4015            other => panic!("expected ProjectVersions, got {other:?}"),
4016        }
4017    }
4018
4019    #[tokio::test]
4020    async fn test_dispatch_list_project_versions_explicit_filters_override_defaults() {
4021        let provider = MockProvider;
4022        let args = serde_json::json!({
4023            "project": "PROJ",
4024            "released": "true",
4025            "archived": "all",
4026            "limit": 5,
4027            "includeIssueCount": true,
4028        });
4029        let result = dispatch_tool("list_project_versions", &args, &provider, None)
4030            .await
4031            .unwrap();
4032        match result {
4033            ToolOutput::ProjectVersions(items, _) => {
4034                let echoed = &items[0].name;
4035                assert!(echoed.contains("released=Some(true)"), "got {echoed}");
4036                assert!(echoed.contains("archived=None"), "got {echoed}");
4037                assert!(echoed.contains("limit=Some(5)"), "got {echoed}");
4038                assert!(echoed.contains("expand=true"), "got {echoed}");
4039                assert_eq!(items[0].project, "PROJ");
4040            }
4041            other => panic!("expected ProjectVersions, got {other:?}"),
4042        }
4043    }
4044
4045    #[tokio::test]
4046    async fn test_dispatch_list_project_versions_rejects_unknown_filter() {
4047        let provider = MockProvider;
4048        let err = dispatch_tool(
4049            "list_project_versions",
4050            &serde_json::json!({"released": "maybe"}),
4051            &provider,
4052            None,
4053        )
4054        .await
4055        .unwrap_err();
4056        assert!(
4057            matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("'maybe'")),
4058            "expected InvalidData about 'maybe', got {err:?}"
4059        );
4060    }
4061
4062    #[tokio::test]
4063    async fn test_dispatch_upsert_project_version_returns_single() {
4064        let provider = MockProvider;
4065        let args = serde_json::json!({
4066            "project": "PROJ",
4067            "name": "3.18.0",
4068            "description": "release notes",
4069            "released": true,
4070            "releaseDate": "2026-05-01",
4071        });
4072        let result = dispatch_tool("upsert_project_version", &args, &provider, None)
4073            .await
4074            .unwrap();
4075        match result {
4076            ToolOutput::SingleProjectVersion(v) => {
4077                assert_eq!(v.name, "3.18.0");
4078                assert_eq!(v.project, "PROJ");
4079                assert!(v.released);
4080                assert_eq!(v.release_date.as_deref(), Some("2026-05-01"));
4081                assert_eq!(v.description.as_deref(), Some("release notes"));
4082            }
4083            other => panic!("expected SingleProjectVersion, got {other:?}"),
4084        }
4085    }
4086
4087    #[tokio::test]
4088    async fn test_dispatch_upsert_project_version_requires_name() {
4089        let provider = MockProvider;
4090        let err = dispatch_tool(
4091            "upsert_project_version",
4092            &serde_json::json!({"project": "PROJ"}),
4093            &provider,
4094            None,
4095        )
4096        .await
4097        .unwrap_err();
4098        assert!(matches!(err, devboy_core::Error::InvalidData(_)));
4099    }
4100
4101    #[test]
4102    fn parse_tri_filter_accepts_canonical_strings() {
4103        assert_eq!(parse_tri_filter(None).unwrap(), None);
4104        assert_eq!(parse_tri_filter(Some("all")).unwrap(), None);
4105        assert_eq!(parse_tri_filter(Some("True")).unwrap(), Some(true));
4106        assert_eq!(parse_tri_filter(Some("false")).unwrap(), Some(false));
4107        assert_eq!(parse_tri_filter(Some("yes")).unwrap(), Some(true));
4108        assert_eq!(parse_tri_filter(Some("0")).unwrap(), Some(false));
4109        assert!(parse_tri_filter(Some("maybe")).is_err());
4110    }
4111
4112    #[test]
4113    fn validate_iso_date_accepts_yyyy_mm_dd() {
4114        assert!(validate_iso_date("releaseDate", "2026-05-04").is_ok());
4115        assert!(validate_iso_date("releaseDate", "2026-12-31").is_ok());
4116    }
4117
4118    #[test]
4119    fn validate_iso_date_rejects_other_shapes() {
4120        // Wrong shape
4121        assert!(validate_iso_date("releaseDate", "2026/05/04").is_err());
4122        assert!(validate_iso_date("releaseDate", "2026-5-4").is_err());
4123        assert!(validate_iso_date("releaseDate", "2026-05-04T00:00:00Z").is_err());
4124        assert!(validate_iso_date("releaseDate", "tomorrow").is_err());
4125        // Out-of-range month / day
4126        assert!(validate_iso_date("releaseDate", "2026-13-01").is_err());
4127        assert!(validate_iso_date("releaseDate", "2026-00-15").is_err());
4128        assert!(validate_iso_date("releaseDate", "2026-05-32").is_err());
4129    }
4130
4131    #[tokio::test]
4132    async fn test_dispatch_upsert_project_version_rejects_bad_date() {
4133        let provider = MockProvider;
4134        let err = dispatch_tool(
4135            "upsert_project_version",
4136            &serde_json::json!({"name": "3.18.0", "releaseDate": "next friday"}),
4137            &provider,
4138            None,
4139        )
4140        .await
4141        .unwrap_err();
4142        assert!(
4143            matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("releaseDate")),
4144            "expected InvalidData about releaseDate, got {err:?}"
4145        );
4146    }
4147
4148    #[tokio::test]
4149    async fn test_dispatch_list_project_versions_rejects_zero_limit() {
4150        let provider = MockProvider;
4151        let err = dispatch_tool(
4152            "list_project_versions",
4153            &serde_json::json!({"limit": 0}),
4154            &provider,
4155            None,
4156        )
4157        .await
4158        .unwrap_err();
4159        assert!(
4160            matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("limit")),
4161            "expected InvalidData about limit, got {err:?}"
4162        );
4163    }
4164
4165    // -------------------------------------------------------------------
4166    // Agile / Sprint dispatch (issue #198)
4167    // -------------------------------------------------------------------
4168
4169    #[tokio::test]
4170    async fn test_dispatch_get_board_sprints_default_state_is_all() {
4171        let provider = MockProvider;
4172        let result = dispatch_tool(
4173            "get_board_sprints",
4174            &serde_json::json!({"boardId": 7}),
4175            &provider,
4176            None,
4177        )
4178        .await
4179        .unwrap();
4180        match result {
4181            ToolOutput::Sprints(items, _) => {
4182                assert_eq!(items.len(), 1);
4183                assert!(items[0].name.contains("board=7"), "got {}", items[0].name);
4184                assert!(items[0].name.contains("state=All"), "got {}", items[0].name);
4185            }
4186            other => panic!("expected Sprints, got {other:?}"),
4187        }
4188    }
4189
4190    #[tokio::test]
4191    async fn test_dispatch_get_board_sprints_state_filter_round_trips() {
4192        let provider = MockProvider;
4193        let result = dispatch_tool(
4194            "get_board_sprints",
4195            &serde_json::json!({"boardId": 9, "state": "active"}),
4196            &provider,
4197            None,
4198        )
4199        .await
4200        .unwrap();
4201        match result {
4202            ToolOutput::Sprints(items, _) => {
4203                assert!(
4204                    items[0].name.contains("state=Active"),
4205                    "got {}",
4206                    items[0].name
4207                );
4208            }
4209            other => panic!("expected Sprints, got {other:?}"),
4210        }
4211    }
4212
4213    #[tokio::test]
4214    async fn test_dispatch_get_board_sprints_rejects_unknown_state() {
4215        let provider = MockProvider;
4216        let err = dispatch_tool(
4217            "get_board_sprints",
4218            &serde_json::json!({"boardId": 1, "state": "wat"}),
4219            &provider,
4220            None,
4221        )
4222        .await
4223        .unwrap_err();
4224        assert!(
4225            matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("wat")),
4226            "expected InvalidData mentioning the bad value, got {err:?}"
4227        );
4228    }
4229
4230    #[tokio::test]
4231    async fn test_dispatch_assign_to_sprint_returns_text_summary() {
4232        let provider = MockProvider;
4233        let result = dispatch_tool(
4234            "assign_to_sprint",
4235            &serde_json::json!({
4236                "sprintId": 42,
4237                "issueKeys": ["PROJ-1", "PROJ-2"],
4238            }),
4239            &provider,
4240            None,
4241        )
4242        .await
4243        .unwrap();
4244        match result {
4245            ToolOutput::Text(msg) => {
4246                assert!(msg.contains("2 issue"), "got {msg}");
4247                assert!(msg.contains("42"), "got {msg}");
4248            }
4249            other => panic!("expected Text, got {other:?}"),
4250        }
4251    }
4252
4253    #[tokio::test]
4254    async fn test_dispatch_assign_to_sprint_rejects_empty_issue_keys() {
4255        let provider = MockProvider;
4256        let err = dispatch_tool(
4257            "assign_to_sprint",
4258            &serde_json::json!({"sprintId": 1, "issueKeys": []}),
4259            &provider,
4260            None,
4261        )
4262        .await
4263        .unwrap_err();
4264        assert!(
4265            matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("issueKeys")),
4266            "expected InvalidData about issueKeys, got {err:?}"
4267        );
4268    }
4269
4270    // -------------------------------------------------------------------
4271    // get_custom_fields dispatch
4272    // -------------------------------------------------------------------
4273
4274    #[tokio::test]
4275    async fn test_dispatch_get_custom_fields_returns_all_entries_by_default() {
4276        let provider = MockProvider;
4277        let result = dispatch_tool("get_custom_fields", &serde_json::json!({}), &provider, None)
4278            .await
4279            .unwrap();
4280        match result {
4281            ToolOutput::CustomFields(items, _) => {
4282                assert_eq!(items.len(), 3);
4283                let names: Vec<_> = items.iter().map(|f| f.name.as_str()).collect();
4284                assert!(names.contains(&"Epic Link"));
4285                assert!(names.contains(&"Sprint"));
4286            }
4287            other => panic!("expected CustomFields, got {other:?}"),
4288        }
4289    }
4290
4291    #[tokio::test]
4292    async fn test_dispatch_get_custom_fields_search_filters_by_substring() {
4293        let provider = MockProvider;
4294        let result = dispatch_tool(
4295            "get_custom_fields",
4296            &serde_json::json!({"search": "epic"}),
4297            &provider,
4298            None,
4299        )
4300        .await
4301        .unwrap();
4302        match result {
4303            ToolOutput::CustomFields(items, _) => {
4304                assert_eq!(items.len(), 2);
4305                for f in items {
4306                    assert!(f.name.to_lowercase().contains("epic"));
4307                }
4308            }
4309            other => panic!("expected CustomFields, got {other:?}"),
4310        }
4311    }
4312
4313    #[tokio::test]
4314    async fn test_dispatch_get_custom_fields_rejects_zero_limit() {
4315        let provider = MockProvider;
4316        let err = dispatch_tool(
4317            "get_custom_fields",
4318            &serde_json::json!({"limit": 0}),
4319            &provider,
4320            None,
4321        )
4322        .await
4323        .unwrap_err();
4324        assert!(
4325            matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("limit")),
4326            "expected InvalidData about limit, got {err:?}"
4327        );
4328    }
4329
4330    // -------------------------------------------------------------------
4331    // Regression: structure-specific arg parsing
4332    // -------------------------------------------------------------------
4333
4334    #[test]
4335    fn parse_row_item_bare_string_becomes_item_id() {
4336        let item = parse_structure_row_item(serde_json::json!("PROJ-1")).unwrap();
4337        assert_eq!(item.item_id, "PROJ-1");
4338        assert!(item.item_type.is_none());
4339    }
4340
4341    #[test]
4342    fn parse_row_item_json_object_string_parses_fields() {
4343        let item = parse_structure_row_item(serde_json::json!(
4344            "{\"item_id\":\"PROJ-2\",\"item_type\":\"issue\"}"
4345        ))
4346        .unwrap();
4347        assert_eq!(item.item_id, "PROJ-2");
4348        assert_eq!(item.item_type.as_deref(), Some("issue"));
4349    }
4350
4351    #[test]
4352    fn parse_row_item_malformed_json_object_is_error() {
4353        // Valid JSON object but fields do not match StructureRowItem.
4354        let err = parse_structure_row_item(serde_json::json!("{\"wrong\":true}")).unwrap_err();
4355        assert!(matches!(err, Error::InvalidData(_)));
4356    }
4357
4358    #[test]
4359    fn parse_column_spec_bare_string_sets_field() {
4360        let col = parse_structure_column_spec(serde_json::json!("summary")).unwrap();
4361        assert_eq!(col.field.as_deref(), Some("summary"));
4362        assert!(col.formula.is_none());
4363    }
4364
4365    #[test]
4366    fn parse_column_spec_formula_json_string_parses() {
4367        let col = parse_structure_column_spec(serde_json::json!(
4368            "{\"formula\":\"SUM(\\\"Story Points\\\")\"}"
4369        ))
4370        .unwrap();
4371        assert!(col.field.is_none());
4372        assert_eq!(col.formula.as_deref(), Some("SUM(\"Story Points\")"));
4373    }
4374
4375    #[test]
4376    fn parse_column_spec_object_value_is_deserialised() {
4377        // A real JSON object (not a stringified one) should also work.
4378        let col = parse_structure_column_spec(serde_json::json!({"field": "status", "width": 120}))
4379            .unwrap();
4380        assert_eq!(col.field.as_deref(), Some("status"));
4381        assert_eq!(col.width, Some(120));
4382    }
4383}