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