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