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