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