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
23const MAX_FILE_SIZE: usize = 10 * 1024 * 1024;
25
26fn 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
46fn 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
62pub 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 pub fn with_asset_manager(mut self, mgr: devboy_assets::AssetManager) -> Self {
81 self.asset_manager = Some(mgr);
82 self
83 }
84
85 pub fn add_enricher(&mut self, enricher: Box<dyn ToolEnricher>) {
88 self.enrichers.push(enricher);
89 }
90
91 pub fn list_tools(&self) -> Vec<crate::tools::ToolDefinition> {
97 let mut tools = crate::tools::base_tool_definitions();
98
99 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 tools.retain(|t| supported_categories.contains(&t.category));
108
109 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 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 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 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 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 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 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 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 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 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
270async 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
291async 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(¶ms.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
461async 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#[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
598async 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 "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 "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 "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 "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 "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 "update_merge_request" => execute_update_merge_request(provider, args).await,
647
648 "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 "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 "list_project_versions" => execute_list_project_versions(provider, args).await,
667 "upsert_project_version" => execute_upsert_project_version(provider, args).await,
668
669 "get_board_sprints" => execute_get_board_sprints(provider, args).await,
671 "assign_to_sprint" => execute_assign_to_sprint(provider, args).await,
672
673 "get_custom_fields" => execute_get_custom_fields(provider, args).await,
675
676 _ => Err(Error::NotFound(format!("unknown tool: {tool}"))),
677 }
678}
679
680async 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#[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(¶ms.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(¶ms.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#[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 #[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 #[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(¶ms.key).await?;
861
862 if !params.include_comments && !params.include_relations {
864 return Ok(ToolOutput::SingleIssue(Box::new(issue)));
865 }
866
867 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(¶ms.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(¶ms.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 !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(¶ms.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(¶ms.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 #[serde(default)]
945 components: Vec<String>,
946 #[serde(default, rename = "fixVersions")]
948 fix_versions: Vec<String>,
949 #[serde(default, rename = "epicKey")]
952 epic_key: Option<String>,
953 #[serde(default, rename = "sprintId")]
955 sprint_id: Option<i64>,
956 #[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 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 #[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 #[serde(default)]
1021 components: Option<Vec<String>>,
1022 #[serde(default, rename = "fixVersions")]
1024 fix_versions: Option<Vec<String>>,
1025 #[serde(default, rename = "epicKey")]
1027 epic_key: Option<String>,
1028 #[serde(default, rename = "sprintId")]
1030 sprint_id: Option<i64>,
1031 #[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 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 #[serde(rename = "fileData")]
1085 file_data: String,
1086 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 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 for att in ¶ms.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(¶ms.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, ¶ms.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#[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 #[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(¶ms.key).await?;
1214 Ok(ToolOutput::SingleMergeRequest(Box::new(mr)))
1215}
1216
1217#[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 #[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(¶ms.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 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(¶ms.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, ¶ms.key, input).await?;
1359 Ok(ToolOutput::Text(format!(
1360 "Comment added to {} (id: {})",
1361 params.key, comment.id
1362 )))
1363}
1364
1365#[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 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, ¶ms.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
1468async 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 ¶ms.source_key,
1531 ¶ms.target_key,
1532 ¶ms.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 ¶ms.source_key,
1550 ¶ms.target_key,
1551 ¶ms.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#[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
1573fn 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
1588fn 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 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 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 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 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 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 #[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 let labels = if let Some(ref new_goal) = params.goal_id {
1745 let current = provider.get_issue(¶ms.key).await?;
1747 let mut labels: Vec<String> = current
1748 .labels
1749 .iter()
1750 .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 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 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 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
1813pub 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 "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 "get_messenger_chats",
1851 "get_chat_messages",
1852 "search_chat_messages",
1853 "send_message",
1854 "get_assets",
1856 "upload_asset",
1857 "download_asset",
1858 "delete_asset",
1859];
1860
1861#[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, ¶ms.key, input).await?;
1896 Ok(ToolOutput::SingleMergeRequest(Box::new(mr)))
1897}
1898
1899#[derive(Deserialize)]
1904struct GetAssetsParams {
1905 context_type: String,
1907 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, ¶ms.key).await?,
1920 "mr" | "merge_request" | "pull_request" => {
1921 MergeRequestProvider::get_mr_attachments(provider, ¶ms.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 context_type: String,
1948 key: String,
1949 filename: String,
1950 #[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(¶ms.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, ¶ms.key, ¶ms.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 context_type: String,
1996 key: String,
1997 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 if let Some(mgr) = asset_manager
2011 && let Ok(Some(resolved)) = mgr.get(¶ms.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 let bytes = match params.context_type.as_str() {
2024 "issue" => {
2025 IssueProvider::download_attachment(provider, ¶ms.key, ¶ms.asset_id).await?
2026 }
2027 "mr" | "merge_request" | "pull_request" => {
2028 MergeRequestProvider::download_mr_attachment(provider, ¶ms.key, ¶ms.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 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(¶ms.asset_id);
2049 match mgr.store(devboy_assets::StoreRequest {
2050 context,
2051 asset_id: Some(¶ms.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 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, ¶ms.key, ¶ms.asset_id).await?;
2107
2108 if let Some(mgr) = asset_manager
2110 && let Err(e) = mgr.delete(¶ms.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
2125const MAX_BASE64_LEN: usize = (MAX_FILE_SIZE / 3 + 1) * 4 + 4;
2127
2128fn 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
2146fn base64_encode(data: &[u8]) -> String {
2148 use base64::Engine;
2149 base64::engine::general_purpose::STANDARD.encode(data)
2150}
2151
2152async 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
2201fn 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
2229fn 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
2444fn 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
2461fn 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 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 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 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 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 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 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 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 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 #[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 #[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 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 #[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 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 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 #[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 let provider = MockProvider;
3540
3541 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 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 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 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 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 #[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 #[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 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 #[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 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 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 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 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 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 #[tokio::test]
4213 async fn test_dispatch_list_project_versions_applies_paper_defaults() {
4214 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 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 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 #[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 #[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 #[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 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 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}