1use devboy_core::types::ChatType;
2use devboy_core::{
3 AddStructureRowsInput, CreateCommentInput, CreateIssueInput, CreateMergeRequestInput,
4 CreatePageParams, CreateStructureInput, Error, GetChatsParams, GetForestOptions,
5 GetMessagesParams, GetPipelineInput, GetStructureValuesInput, GetUsersOptions, IssueFilter,
6 IssueProvider, JobLogMode, JobLogOptions, KnowledgeBaseProvider, ListPagesParams,
7 ListProjectVersionsParams, MeetingFilter, MeetingNotesProvider, MergeRequestProvider,
8 MessengerProvider, MoveStructureRowsInput, MrFilter, PipelineProvider, Result,
9 SaveStructureViewInput, SearchKbParams, SearchMessagesParams, SendMessageParams,
10 StructureRowItem, StructureViewColumn, ToolCategory, UpdateIssueInput, UpdatePageParams,
11 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 _ => Err(Error::NotFound(format!("unknown tool: {tool}"))),
668 }
669}
670
671async fn dispatch_meeting_tool(
673 tool: &str,
674 args: &Value,
675 provider: &dyn MeetingNotesProvider,
676) -> Result<ToolOutput> {
677 match tool {
678 "get_meeting_notes" => execute_get_meeting_notes(provider, args).await,
679 "get_meeting_transcript" => execute_get_meeting_transcript(provider, args).await,
680 "search_meeting_notes" => execute_search_meeting_notes(provider, args).await,
681 _ => Err(Error::NotFound(format!("unknown meeting tool: {tool}"))),
682 }
683}
684
685#[derive(Deserialize, Default)]
688struct GetMeetingNotesParams {
689 from_date: Option<String>,
690 to_date: Option<String>,
691 participants: Option<Vec<String>>,
692 host_email: Option<String>,
693 limit: Option<u32>,
694 offset: Option<u32>,
695}
696
697async fn execute_get_meeting_notes(
698 provider: &dyn MeetingNotesProvider,
699 args: &Value,
700) -> Result<ToolOutput> {
701 let params: GetMeetingNotesParams = parse_tool_params(args, "get_meeting_notes")?;
702 let filter = MeetingFilter {
703 keyword: None,
704 from_date: params.from_date,
705 to_date: params.to_date,
706 participants: params.participants,
707 host_email: params.host_email,
708 limit: params.limit,
709 skip: params.offset,
710 };
711 let result = provider.get_meetings(filter).await?;
712 let meta = ResultMeta {
713 pagination: result.pagination,
714 sort_info: result.sort_info,
715 };
716 Ok(ToolOutput::MeetingNotes(result.items, Some(meta)))
717}
718
719#[derive(Deserialize)]
720struct GetMeetingTranscriptParams {
721 meeting_id: String,
722}
723
724async fn execute_get_meeting_transcript(
725 provider: &dyn MeetingNotesProvider,
726 args: &Value,
727) -> Result<ToolOutput> {
728 let params: GetMeetingTranscriptParams = serde_json::from_value(args.clone())
729 .map_err(|e| Error::InvalidData(format!("invalid params: {e}")))?;
730 let transcript = provider.get_transcript(¶ms.meeting_id).await?;
731 Ok(ToolOutput::MeetingTranscript(Box::new(transcript)))
732}
733
734#[derive(Deserialize)]
735struct SearchMeetingNotesParams {
736 query: String,
737 from_date: Option<String>,
738 to_date: Option<String>,
739 participants: Option<Vec<String>>,
740 host_email: Option<String>,
741 limit: Option<u32>,
742 offset: Option<u32>,
743}
744
745async fn execute_search_meeting_notes(
746 provider: &dyn MeetingNotesProvider,
747 args: &Value,
748) -> Result<ToolOutput> {
749 let params: SearchMeetingNotesParams = serde_json::from_value(args.clone())
750 .map_err(|e| Error::InvalidData(format!("invalid params: {e}")))?;
751 let filter = MeetingFilter {
752 keyword: None,
753 from_date: params.from_date,
754 to_date: params.to_date,
755 participants: params.participants,
756 host_email: params.host_email,
757 limit: params.limit,
758 skip: params.offset,
759 };
760 let result = provider.search_meetings(¶ms.query, filter).await?;
761 let meta = ResultMeta {
762 pagination: result.pagination,
763 sort_info: result.sort_info,
764 };
765 Ok(ToolOutput::MeetingNotes(result.items, Some(meta)))
766}
767
768#[derive(Deserialize, Default)]
771struct GetIssuesParams {
772 state: Option<String>,
773 #[serde(rename = "stateCategory")]
774 state_category: Option<String>,
775 search: Option<String>,
776 labels: Option<Vec<String>>,
777 #[serde(rename = "labelsOperator")]
778 labels_operator: Option<String>,
779 assignee: Option<String>,
780 limit: Option<u32>,
781 offset: Option<u32>,
782 sort_by: Option<String>,
783 sort_order: Option<String>,
784 #[serde(rename = "projectKey")]
785 project_key: Option<String>,
786 #[serde(rename = "nativeQuery")]
787 native_query: Option<String>,
788 #[allow(dead_code)]
790 budget: Option<usize>,
791}
792
793async fn execute_get_issues(
794 provider: &dyn devboy_core::Provider,
795 args: &Value,
796) -> Result<ToolOutput> {
797 let params: GetIssuesParams = parse_tool_params(args, "get_issues")?;
798 let filter = IssueFilter {
799 state: params.state,
800 state_category: params.state_category,
801 search: params.search,
802 labels: params.labels,
803 labels_operator: params.labels_operator,
804 assignee: params.assignee,
805 limit: params.limit.or(Some(20)),
806 offset: params.offset,
807 sort_by: params.sort_by,
808 sort_order: params.sort_order,
809 project_key: params.project_key,
810 native_query: params.native_query,
811 };
812 let result = provider.get_issues(filter).await?;
813 let meta = ResultMeta {
814 pagination: result.pagination,
815 sort_info: result.sort_info,
816 };
817 Ok(ToolOutput::Issues(result.items, Some(meta)))
818}
819
820#[derive(Deserialize)]
821struct KeyParam {
822 key: String,
823 #[serde(default)]
825 #[allow(dead_code)]
826 budget: Option<usize>,
827}
828
829#[derive(Deserialize)]
830struct GetIssueParams {
831 key: String,
832 #[serde(default = "default_true", rename = "includeComments")]
833 include_comments: bool,
834 #[serde(default = "default_true", rename = "includeRelations")]
835 include_relations: bool,
836 #[serde(default)]
837 #[allow(dead_code)]
838 budget: Option<usize>,
839}
840
841fn default_true() -> bool {
842 true
843}
844
845async fn execute_get_issue(
846 provider: &dyn devboy_core::Provider,
847 args: &Value,
848) -> Result<ToolOutput> {
849 let params: GetIssueParams = serde_json::from_value(args.clone())
850 .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
851 let issue = provider.get_issue(¶ms.key).await?;
852
853 if !params.include_comments && !params.include_relations {
855 return Ok(ToolOutput::SingleIssue(Box::new(issue)));
856 }
857
858 let mut result = serde_json::to_value(&issue).unwrap_or_default();
860 let mut has_extras = false;
861
862 if params.include_comments
863 && let Ok(comments_result) = provider.get_comments(¶ms.key).await
864 {
865 result["comments"] = serde_json::to_value(&comments_result.items).unwrap_or_default();
866 result["comments_count"] = serde_json::json!(comments_result.items.len());
867 has_extras = true;
868 }
869
870 if params.include_relations
871 && let Ok(relations) = provider.get_issue_relations(¶ms.key).await
872 {
873 result["relations"] = serde_json::to_value(&relations).unwrap_or_default();
874 if issue.subtasks.is_empty() && !relations.subtasks.is_empty() {
875 result["subtasks"] = serde_json::to_value(&relations.subtasks).unwrap_or_default();
876 }
877 result["subtasks_count"] =
878 serde_json::json!(issue.subtasks.len().max(relations.subtasks.len()));
879 has_extras = true;
880 }
881
882 if !has_extras {
884 return Ok(ToolOutput::SingleIssue(Box::new(issue)));
885 }
886
887 Ok(ToolOutput::Text(
888 serde_json::to_string_pretty(&result).unwrap_or_default(),
889 ))
890}
891
892async fn execute_get_issue_comments(
893 provider: &dyn devboy_core::Provider,
894 args: &Value,
895) -> Result<ToolOutput> {
896 let params: KeyParam = serde_json::from_value(args.clone())
897 .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
898 let result = provider.get_comments(¶ms.key).await?;
899 let meta = ResultMeta {
900 pagination: result.pagination,
901 sort_info: result.sort_info,
902 };
903 Ok(ToolOutput::Comments(result.items, Some(meta)))
904}
905
906async fn execute_get_issue_relations(
907 provider: &dyn devboy_core::Provider,
908 args: &Value,
909) -> Result<ToolOutput> {
910 let params: KeyParam = serde_json::from_value(args.clone())
911 .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
912 let relations = provider.get_issue_relations(¶ms.key).await?;
913 Ok(ToolOutput::Relations(Box::new(relations)))
914}
915
916#[derive(Deserialize)]
917struct CreateIssueParams {
918 title: String,
919 description: Option<String>,
920 #[serde(default)]
921 labels: Vec<String>,
922 #[serde(default)]
923 assignees: Vec<String>,
924 #[serde(default, deserialize_with = "deserialize_string_or_number")]
925 priority: Option<String>,
926 #[serde(alias = "parentId")]
927 parent: Option<String>,
928 markdown: Option<bool>,
929 #[serde(rename = "projectId")]
930 project_id: Option<String>,
931 #[serde(rename = "issueType")]
932 issue_type: Option<String>,
933 #[serde(default)]
936 components: Vec<String>,
937}
938
939async fn execute_create_issue(
940 provider: &dyn devboy_core::Provider,
941 args: &Value,
942) -> Result<ToolOutput> {
943 let params: CreateIssueParams = serde_json::from_value(args.clone())
944 .map_err(|e| Error::InvalidData(format!("invalid create_issue params: {e}")))?;
945 let custom_fields = args.get("customFields").cloned();
946 let input = CreateIssueInput {
947 title: params.title,
948 description: params.description,
949 labels: params.labels,
950 assignees: params.assignees,
951 priority: params.priority,
952 parent: params.parent,
953 markdown: params.markdown.unwrap_or(true),
954 project_id: params.project_id,
955 issue_type: params.issue_type,
956 custom_fields,
957 components: params.components,
958 };
959 let issue = provider.create_issue(input).await?;
960
961 if let Some(cf) = args.get("customFields").and_then(|v| v.as_array())
963 && !cf.is_empty()
964 && let Err(e) = provider.set_custom_fields(&issue.key, cf).await
965 {
966 tracing::warn!(error = %e, "Failed to set custom fields on created issue");
967 }
968
969 Ok(ToolOutput::SingleIssue(Box::new(issue)))
970}
971
972#[derive(Deserialize)]
973struct UpdateIssueParams {
974 key: String,
975 title: Option<String>,
976 description: Option<String>,
977 state: Option<String>,
978 labels: Option<Vec<String>>,
979 assignees: Option<Vec<String>>,
980 #[serde(default, deserialize_with = "deserialize_string_or_number")]
981 priority: Option<String>,
982 #[serde(rename = "parentId")]
983 parent_id: Option<String>,
984 markdown: Option<bool>,
985 #[serde(default)]
989 components: Option<Vec<String>>,
990}
991
992async fn execute_update_issue(
993 provider: &dyn devboy_core::Provider,
994 args: &Value,
995) -> Result<ToolOutput> {
996 let params: UpdateIssueParams = serde_json::from_value(args.clone())
997 .map_err(|e| Error::InvalidData(format!("invalid update_issue params: {e}")))?;
998 let custom_fields = args.get("customFields").cloned();
999 let input = UpdateIssueInput {
1000 title: params.title,
1001 description: params.description,
1002 state: params.state,
1003 labels: params.labels,
1004 assignees: params.assignees,
1005 priority: params.priority,
1006 parent_id: params.parent_id,
1007 markdown: params.markdown.unwrap_or(true),
1008 custom_fields,
1009 components: params.components,
1010 };
1011 let key = params.key;
1012 let issue = provider.update_issue(&key, input).await?;
1013
1014 if let Some(cf) = args.get("customFields").and_then(|v| v.as_array())
1016 && !cf.is_empty()
1017 && let Err(e) = provider.set_custom_fields(&key, cf).await
1018 {
1019 tracing::warn!(error = %e, "Failed to set custom fields on updated issue");
1020 }
1021 Ok(ToolOutput::SingleIssue(Box::new(issue)))
1022}
1023
1024#[derive(Deserialize)]
1025struct AddCommentParams {
1026 key: String,
1027 body: String,
1028 #[serde(default)]
1029 attachments: Vec<AttachmentParam>,
1030}
1031
1032#[derive(Deserialize)]
1033struct AttachmentParam {
1034 #[serde(rename = "fileData")]
1036 file_data: String,
1037 filename: String,
1039}
1040
1041async fn execute_add_issue_comment(
1042 provider: &dyn devboy_core::Provider,
1043 args: &Value,
1044) -> Result<ToolOutput> {
1045 let params: AddCommentParams = serde_json::from_value(args.clone())
1046 .map_err(|e| Error::InvalidData(format!("invalid add_issue_comment params: {e}")))?;
1047
1048 let mut body = params.body.clone();
1049 let mut uploaded = 0;
1050 let mut upload_errors = Vec::new();
1051
1052 const MAX_ATTACHMENTS: usize = 10;
1054
1055 if params.attachments.len() > MAX_ATTACHMENTS {
1056 return Err(Error::InvalidData(format!(
1057 "Too many attachments: {} (max {})",
1058 params.attachments.len(),
1059 MAX_ATTACHMENTS
1060 )));
1061 }
1062
1063 for att in ¶ms.attachments {
1065 use base64::Engine;
1066 let data = match base64::engine::general_purpose::STANDARD.decode(&att.file_data) {
1067 Ok(d) => d,
1068 Err(e) => {
1069 upload_errors.push(format!("{}: decode error: {}", att.filename, e));
1070 continue;
1071 }
1072 };
1073
1074 if data.len() > MAX_FILE_SIZE {
1075 upload_errors.push(format!(
1076 "{}: file too large ({} bytes, max {})",
1077 att.filename,
1078 data.len(),
1079 MAX_FILE_SIZE
1080 ));
1081 continue;
1082 }
1083
1084 match provider
1085 .upload_attachment(¶ms.key, &att.filename, &data)
1086 .await
1087 {
1088 Ok(url) => {
1089 if !url.is_empty() {
1090 body.push_str(&format!("\n\n[{}]({})", att.filename, url));
1091 }
1092 uploaded += 1;
1093 }
1094 Err(e) => {
1095 upload_errors.push(format!("{}: {}", att.filename, e));
1096 }
1097 }
1098 }
1099
1100 let comment = devboy_core::IssueProvider::add_comment(provider, ¶ms.key, &body).await?;
1101
1102 let mut msg = format!("Comment added to {} (id: {})", params.key, comment.id);
1103 if uploaded > 0 {
1104 msg.push_str(&format!(", {} attachment(s) uploaded", uploaded));
1105 }
1106 if !upload_errors.is_empty() {
1107 msg.push_str(&format!(
1108 ", {} attachment error(s): {}",
1109 upload_errors.len(),
1110 upload_errors.join("; ")
1111 ));
1112 }
1113 Ok(ToolOutput::Text(msg))
1114}
1115
1116#[derive(Deserialize, Default)]
1119struct GetMergeRequestsParams {
1120 state: Option<String>,
1121 author: Option<String>,
1122 labels: Option<Vec<String>>,
1123 source_branch: Option<String>,
1124 target_branch: Option<String>,
1125 limit: Option<u32>,
1126 offset: Option<u32>,
1127 sort_by: Option<String>,
1128 sort_order: Option<String>,
1129 #[allow(dead_code)]
1131 budget: Option<usize>,
1132}
1133
1134async fn execute_get_merge_requests(
1135 provider: &dyn devboy_core::Provider,
1136 args: &Value,
1137) -> Result<ToolOutput> {
1138 let params: GetMergeRequestsParams = parse_tool_params(args, "get_merge_requests")?;
1139 let filter = MrFilter {
1140 state: params.state,
1141 source_branch: params.source_branch,
1142 target_branch: params.target_branch,
1143 author: params.author,
1144 labels: params.labels,
1145 limit: params.limit.or(Some(20)),
1146 offset: params.offset,
1147 sort_by: params.sort_by,
1148 sort_order: params.sort_order,
1149 };
1150 let result = provider.get_merge_requests(filter).await?;
1151 let meta = ResultMeta {
1152 pagination: result.pagination,
1153 sort_info: result.sort_info,
1154 };
1155 Ok(ToolOutput::MergeRequests(result.items, Some(meta)))
1156}
1157
1158async fn execute_get_merge_request(
1159 provider: &dyn devboy_core::Provider,
1160 args: &Value,
1161) -> Result<ToolOutput> {
1162 let params: KeyParam = serde_json::from_value(args.clone())
1163 .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
1164 let mr = provider.get_merge_request(¶ms.key).await?;
1165 Ok(ToolOutput::SingleMergeRequest(Box::new(mr)))
1166}
1167
1168async fn execute_get_merge_request_discussions(
1169 provider: &dyn devboy_core::Provider,
1170 args: &Value,
1171) -> Result<ToolOutput> {
1172 let params: KeyParam = serde_json::from_value(args.clone())
1173 .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
1174 let result = provider.get_discussions(¶ms.key).await?;
1175 let meta = ResultMeta {
1176 pagination: result.pagination,
1177 sort_info: result.sort_info,
1178 };
1179 Ok(ToolOutput::Discussions(result.items, Some(meta)))
1180}
1181
1182async fn execute_get_merge_request_diffs(
1183 provider: &dyn devboy_core::Provider,
1184 args: &Value,
1185) -> Result<ToolOutput> {
1186 let params: KeyParam = serde_json::from_value(args.clone())
1187 .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
1188 let result = provider.get_diffs(¶ms.key).await?;
1189 let meta = ResultMeta {
1190 pagination: result.pagination,
1191 sort_info: result.sort_info,
1192 };
1193 Ok(ToolOutput::Diffs(result.items, Some(meta)))
1194}
1195
1196#[derive(Deserialize)]
1197struct CreateMergeRequestParams {
1198 title: String,
1199 description: Option<String>,
1200 source_branch: String,
1201 target_branch: String,
1202 #[serde(default)]
1203 draft: bool,
1204 #[serde(default)]
1205 labels: Vec<String>,
1206 #[serde(default)]
1207 reviewers: Vec<String>,
1208}
1209
1210async fn execute_create_merge_request(
1211 provider: &dyn devboy_core::Provider,
1212 args: &Value,
1213) -> Result<ToolOutput> {
1214 let params: CreateMergeRequestParams = serde_json::from_value(args.clone())
1215 .map_err(|e| Error::InvalidData(format!("invalid create_merge_request params: {e}")))?;
1216 let input = CreateMergeRequestInput {
1217 title: params.title,
1218 description: params.description,
1219 source_branch: params.source_branch,
1220 target_branch: params.target_branch,
1221 draft: params.draft,
1222 labels: params.labels,
1223 reviewers: params.reviewers,
1224 };
1225 let mr = provider.create_merge_request(input).await?;
1226 Ok(ToolOutput::SingleMergeRequest(Box::new(mr)))
1227}
1228
1229#[derive(Deserialize)]
1230struct CreateMrCommentParams {
1231 #[serde(alias = "mrKey")]
1232 key: String,
1233 body: String,
1234 #[serde(alias = "filePath")]
1235 file_path: Option<String>,
1236 line: Option<u32>,
1237 #[serde(alias = "lineType")]
1238 line_type: Option<String>,
1239 #[serde(alias = "commitSha")]
1240 commit_sha: Option<String>,
1241 #[serde(alias = "discussionId")]
1242 discussion_id: Option<String>,
1243}
1244
1245async fn execute_create_merge_request_comment(
1246 provider: &dyn devboy_core::Provider,
1247 args: &Value,
1248) -> Result<ToolOutput> {
1249 let params: CreateMrCommentParams = serde_json::from_value(args.clone()).map_err(|e| {
1250 Error::InvalidData(format!("invalid create_merge_request_comment params: {e}"))
1251 })?;
1252
1253 let position = params.file_path.map(|fp| devboy_core::CodePosition {
1254 file_path: fp,
1255 line: params.line.unwrap_or(1),
1256 line_type: params.line_type.unwrap_or_else(|| "new".into()),
1257 commit_sha: params.commit_sha,
1258 });
1259
1260 let input = CreateCommentInput {
1261 body: params.body,
1262 position,
1263 discussion_id: params.discussion_id,
1264 };
1265
1266 let comment = MergeRequestProvider::add_comment(provider, ¶ms.key, input).await?;
1267 Ok(ToolOutput::Text(format!(
1268 "Comment added to {} (id: {})",
1269 params.key, comment.id
1270 )))
1271}
1272
1273#[derive(Deserialize, Default)]
1276struct GetPipelineParams {
1277 branch: Option<String>,
1278 #[serde(rename = "mrKey")]
1279 mr_key: Option<String>,
1280 #[serde(rename = "includeFailedLogs")]
1281 include_failed_logs: Option<bool>,
1282}
1283
1284async fn execute_get_pipeline(
1285 provider: &dyn devboy_core::Provider,
1286 args: &Value,
1287) -> Result<ToolOutput> {
1288 let params: GetPipelineParams = parse_tool_params(args, "get_pipeline")?;
1289 let input = GetPipelineInput {
1290 branch: params.branch,
1291 mr_key: params.mr_key,
1292 include_failed_logs: params.include_failed_logs.unwrap_or(true),
1293 };
1294 let pipeline = PipelineProvider::get_pipeline(provider, input).await?;
1295 Ok(ToolOutput::Pipeline(Box::new(pipeline)))
1296}
1297
1298#[derive(Deserialize)]
1299struct GetJobLogsParams {
1300 #[serde(rename = "jobId")]
1301 job_id: String,
1302 pattern: Option<String>,
1303 context: Option<usize>,
1304 #[serde(rename = "maxMatches")]
1305 max_matches: Option<usize>,
1306 offset: Option<usize>,
1307 limit: Option<usize>,
1308 full: Option<bool>,
1309}
1310
1311async fn execute_get_job_logs(
1312 provider: &dyn devboy_core::Provider,
1313 args: &Value,
1314) -> Result<ToolOutput> {
1315 let params: GetJobLogsParams = serde_json::from_value(args.clone())
1316 .map_err(|e| Error::InvalidData(format!("invalid get_job_logs params: {e}")))?;
1317
1318 let clamped_limit = params.limit.map(|l| l.min(1000));
1320
1321 let mode = if let Some(pattern) = params.pattern {
1322 JobLogMode::Search {
1323 pattern,
1324 context: params.context.unwrap_or(5).min(50),
1325 max_matches: params.max_matches.unwrap_or(20).min(100),
1326 }
1327 } else if let Some(true) = params.full {
1328 JobLogMode::Full {
1329 max_lines: clamped_limit.unwrap_or(1000),
1330 }
1331 } else if params.offset.is_some() || clamped_limit.is_some() {
1332 JobLogMode::Paginated {
1333 offset: params.offset.unwrap_or(0),
1334 limit: clamped_limit.unwrap_or(200),
1335 }
1336 } else {
1337 JobLogMode::Smart
1338 };
1339
1340 let options = JobLogOptions { mode };
1341 let log_output = PipelineProvider::get_job_logs(provider, ¶ms.job_id, options).await?;
1342 Ok(ToolOutput::JobLog(Box::new(log_output)))
1343}
1344
1345async fn execute_get_available_statuses(
1348 provider: &dyn devboy_core::Provider,
1349) -> Result<ToolOutput> {
1350 let result = IssueProvider::get_statuses(provider).await?;
1351 let meta = ResultMeta {
1352 pagination: result.pagination,
1353 sort_info: result.sort_info,
1354 };
1355 Ok(ToolOutput::Statuses(result.items, Some(meta)))
1356}
1357
1358#[derive(Deserialize, Default)]
1359struct GetUsersParams {
1360 user_id: Option<String>,
1361 project_key: Option<String>,
1362 search: Option<String>,
1363 include_inactive: Option<bool>,
1364 start_at: Option<u32>,
1365 max_results: Option<u32>,
1366}
1367
1368async fn execute_get_users(
1369 provider: &dyn devboy_core::Provider,
1370 args: &Value,
1371) -> Result<ToolOutput> {
1372 let params: GetUsersParams = parse_tool_params(args, "get_users")?;
1373 let options = GetUsersOptions {
1374 user_id: params.user_id,
1375 project_key: params.project_key,
1376 search: params.search,
1377 include_inactive: params.include_inactive,
1378 start_at: params.start_at,
1379 max_results: params.max_results,
1380 };
1381 let result = IssueProvider::get_users(provider, options).await?;
1382 let meta = ResultMeta {
1383 pagination: result.pagination,
1384 sort_info: result.sort_info,
1385 };
1386 Ok(ToolOutput::Users(result.items, Some(meta)))
1387}
1388
1389#[derive(Deserialize)]
1390struct LinkIssuesParams {
1391 #[serde(alias = "sourceIssueKey", alias = "issueKey1")]
1392 source_key: String,
1393 #[serde(alias = "targetIssueKey", alias = "issueKey2")]
1394 target_key: String,
1395 #[serde(alias = "linkType")]
1396 link_type: String,
1397}
1398
1399async fn execute_link_issues(
1400 provider: &dyn devboy_core::Provider,
1401 args: &Value,
1402) -> Result<ToolOutput> {
1403 let params: LinkIssuesParams = serde_json::from_value(args.clone())
1404 .map_err(|e| Error::InvalidData(format!("invalid link_issues params: {e}")))?;
1405 IssueProvider::link_issues(
1406 provider,
1407 ¶ms.source_key,
1408 ¶ms.target_key,
1409 ¶ms.link_type,
1410 )
1411 .await?;
1412 Ok(ToolOutput::Text(format!(
1413 "Linked {} -> {} (type: {})",
1414 params.source_key, params.target_key, params.link_type
1415 )))
1416}
1417
1418async fn execute_unlink_issues(
1419 provider: &dyn devboy_core::Provider,
1420 args: &Value,
1421) -> Result<ToolOutput> {
1422 let params: LinkIssuesParams = serde_json::from_value(args.clone())
1423 .map_err(|e| Error::InvalidData(format!("invalid unlink_issues params: {e}")))?;
1424 IssueProvider::unlink_issues(
1425 provider,
1426 ¶ms.source_key,
1427 ¶ms.target_key,
1428 ¶ms.link_type,
1429 )
1430 .await?;
1431 Ok(ToolOutput::Text(format!(
1432 "Unlinked {} -> {} (type: {})",
1433 params.source_key, params.target_key, params.link_type
1434 )))
1435}
1436
1437#[derive(Deserialize, Default)]
1440struct GetEpicsParams {
1441 state: Option<String>,
1442 search: Option<String>,
1443 assignee: Option<String>,
1444 #[serde(rename = "goalId")]
1445 goal_id: Option<String>,
1446 limit: Option<u32>,
1447 offset: Option<u32>,
1448}
1449
1450fn extract_goal_id(labels: &[String]) -> Option<String> {
1452 labels.iter().find_map(|l| {
1453 let lower = l.to_lowercase();
1454 if lower.len() == 2
1455 && lower.starts_with('g')
1456 && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1457 {
1458 Some(lower.to_uppercase())
1459 } else {
1460 None
1461 }
1462 })
1463}
1464
1465fn epic_progress(subtasks: &[devboy_core::Issue]) -> serde_json::Value {
1467 let total = subtasks.len();
1468 let completed = subtasks.iter().filter(|s| s.state == "closed").count();
1469 let percentage = if total > 0 {
1470 (completed as f64 / total as f64 * 100.0).round() as u32
1471 } else {
1472 0
1473 };
1474 serde_json::json!({
1475 "total_subtasks": total,
1476 "completed_subtasks": completed,
1477 "percentage": percentage,
1478 })
1479}
1480
1481async fn execute_get_epics(
1482 provider: &dyn devboy_core::Provider,
1483 args: &Value,
1484) -> Result<ToolOutput> {
1485 let params: GetEpicsParams = parse_tool_params(args, "get_epics")?;
1486 let filter = IssueFilter {
1487 state: params.state,
1488 state_category: None,
1489 search: params.search,
1490 labels: Some(vec!["epic".to_string()]),
1491 labels_operator: None,
1492 assignee: params.assignee,
1493 limit: params.limit.or(Some(50)),
1494 offset: params.offset,
1495 sort_by: None,
1496 sort_order: None,
1497 project_key: None,
1498 native_query: None,
1499 };
1500 let result = provider.get_issues(filter).await?;
1501 let mut epics = result.items;
1502
1503 if let Some(ref goal) = params.goal_id {
1505 let goal_lower = goal.to_lowercase();
1506 epics.retain(|e| e.labels.iter().any(|l| l.to_lowercase() == goal_lower));
1507 }
1508
1509 let enriched: Vec<serde_json::Value> = epics
1511 .iter()
1512 .map(|epic| {
1513 let mut v = serde_json::to_value(epic).unwrap_or_default();
1514 v["goal_id"] = serde_json::json!(extract_goal_id(&epic.labels));
1515 v["progress"] = epic_progress(&epic.subtasks);
1516 v
1517 })
1518 .collect();
1519
1520 Ok(ToolOutput::Text(
1521 serde_json::to_string_pretty(&enriched).unwrap_or_default(),
1522 ))
1523}
1524
1525#[derive(Deserialize)]
1526struct CreateEpicParams {
1527 title: String,
1528 description: Option<String>,
1529 #[serde(rename = "goalId")]
1530 goal_id: Option<String>,
1531 #[serde(default)]
1532 labels: Vec<String>,
1533 #[serde(default)]
1534 assignees: Vec<String>,
1535 #[serde(default, deserialize_with = "deserialize_string_or_number")]
1536 priority: Option<String>,
1537 markdown: Option<bool>,
1538}
1539
1540async fn execute_create_epic(
1541 provider: &dyn devboy_core::Provider,
1542 args: &Value,
1543) -> Result<ToolOutput> {
1544 let params: CreateEpicParams = serde_json::from_value(args.clone())
1545 .map_err(|e| Error::InvalidData(format!("invalid create_epic params: {e}")))?;
1546
1547 let mut labels = params.labels;
1549 if !labels.iter().any(|l| l.eq_ignore_ascii_case("epic")) {
1550 labels.push("epic".to_string());
1551 }
1552
1553 if let Some(ref goal) = params.goal_id {
1555 let goal_tag = goal.to_lowercase();
1556 if !labels.iter().any(|l| l.to_lowercase() == goal_tag) {
1557 labels.push(goal_tag);
1558 }
1559 }
1560
1561 let input = CreateIssueInput {
1562 title: params.title,
1563 description: params.description,
1564 labels,
1565 assignees: params.assignees,
1566 priority: params.priority,
1567 parent: None,
1568 markdown: params.markdown.unwrap_or(true),
1569 project_id: None,
1570 issue_type: None,
1571 custom_fields: args.get("customFields").cloned(),
1572 components: Vec::new(),
1573 };
1574 let issue = provider.create_issue(input).await?;
1575
1576 if let Some(cf) = args.get("customFields").and_then(|v| v.as_array())
1578 && !cf.is_empty()
1579 && let Err(e) = provider.set_custom_fields(&issue.key, cf).await
1580 {
1581 tracing::warn!(error = %e, "Failed to set custom fields on created epic");
1582 }
1583
1584 Ok(ToolOutput::SingleIssue(Box::new(issue)))
1585}
1586
1587#[derive(Deserialize)]
1588struct UpdateEpicParams {
1589 #[serde(alias = "epicKey")]
1590 key: String,
1591 title: Option<String>,
1592 description: Option<String>,
1593 state: Option<String>,
1594 #[serde(rename = "goalId")]
1595 goal_id: Option<String>,
1596 labels: Option<Vec<String>>,
1597 assignees: Option<Vec<String>>,
1598 #[serde(default, deserialize_with = "deserialize_string_or_number")]
1599 priority: Option<String>,
1600 markdown: Option<bool>,
1601}
1602
1603async fn execute_update_epic(
1604 provider: &dyn devboy_core::Provider,
1605 args: &Value,
1606) -> Result<ToolOutput> {
1607 let params: UpdateEpicParams = serde_json::from_value(args.clone())
1608 .map_err(|e| Error::InvalidData(format!("invalid update_epic params: {e}")))?;
1609
1610 let labels = if let Some(ref new_goal) = params.goal_id {
1612 let current = provider.get_issue(¶ms.key).await?;
1614 let mut labels: Vec<String> = current
1615 .labels
1616 .iter()
1617 .filter(|l| {
1619 let lower = l.to_lowercase();
1620 !(lower.len() == 2
1621 && lower.starts_with('g')
1622 && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit()))
1623 })
1624 .cloned()
1625 .collect();
1626
1627 let goal_tag = new_goal.to_lowercase();
1629 if !labels.iter().any(|l| l.to_lowercase() == goal_tag) {
1630 labels.push(goal_tag);
1631 }
1632
1633 if let Some(extra) = params.labels {
1635 for l in extra {
1636 if !labels
1637 .iter()
1638 .any(|existing| existing.eq_ignore_ascii_case(&l))
1639 {
1640 labels.push(l);
1641 }
1642 }
1643 }
1644 Some(labels)
1645 } else {
1646 params.labels
1647 };
1648
1649 let input = UpdateIssueInput {
1650 title: params.title,
1651 description: params.description,
1652 state: params.state,
1653 labels,
1654 assignees: params.assignees,
1655 priority: params.priority,
1656 parent_id: None,
1657 markdown: params.markdown.unwrap_or(true),
1658 custom_fields: args.get("customFields").cloned(),
1659 components: None,
1660 };
1661 let key = params.key;
1662 let issue = provider.update_issue(&key, input).await?;
1663
1664 if let Some(cf) = args.get("customFields").and_then(|v| v.as_array())
1666 && !cf.is_empty()
1667 && let Err(e) = provider.set_custom_fields(&key, cf).await
1668 {
1669 tracing::warn!(error = %e, "Failed to set custom fields on updated epic");
1670 }
1671
1672 Ok(ToolOutput::SingleIssue(Box::new(issue)))
1673}
1674
1675pub const SUPPORTED_TOOLS: &[&str] = &[
1677 "get_issues",
1678 "get_issue",
1679 "get_issue_comments",
1680 "get_issue_relations",
1681 "create_issue",
1682 "update_issue",
1683 "add_issue_comment",
1684 "get_merge_requests",
1685 "get_merge_request",
1686 "get_merge_request_discussions",
1687 "get_merge_request_diffs",
1688 "create_merge_request",
1689 "create_merge_request_comment",
1690 "update_merge_request",
1691 "get_pipeline",
1692 "get_job_logs",
1693 "get_available_statuses",
1694 "get_users",
1695 "link_issues",
1696 "unlink_issues",
1697 "get_epics",
1698 "create_epic",
1699 "update_epic",
1700 "get_meeting_notes",
1701 "get_meeting_transcript",
1702 "search_meeting_notes",
1703 "get_knowledge_base_spaces",
1705 "list_knowledge_base_pages",
1706 "get_knowledge_base_page",
1707 "create_knowledge_base_page",
1708 "update_knowledge_base_page",
1709 "search_knowledge_base",
1710 "get_messenger_chats",
1712 "get_chat_messages",
1713 "search_chat_messages",
1714 "send_message",
1715 "get_assets",
1717 "upload_asset",
1718 "download_asset",
1719 "delete_asset",
1720];
1721
1722#[derive(Deserialize)]
1727struct UpdateMergeRequestParams {
1728 key: String,
1729 #[serde(default)]
1730 title: Option<String>,
1731 #[serde(default)]
1732 description: Option<String>,
1733 #[serde(default)]
1734 state: Option<String>,
1735 #[serde(default)]
1736 labels: Option<Vec<String>>,
1737 #[serde(default)]
1738 draft: Option<bool>,
1739}
1740
1741async fn execute_update_merge_request(
1742 provider: &dyn devboy_core::Provider,
1743 args: &Value,
1744) -> Result<ToolOutput> {
1745 let params: UpdateMergeRequestParams = serde_json::from_value(args.clone())?;
1746 debug!(key = %params.key, "update_merge_request");
1747
1748 let input = devboy_core::UpdateMergeRequestInput {
1749 title: params.title,
1750 description: params.description,
1751 state: params.state,
1752 labels: params.labels,
1753 draft: params.draft,
1754 };
1755
1756 let mr = MergeRequestProvider::update_merge_request(provider, ¶ms.key, input).await?;
1757 Ok(ToolOutput::SingleMergeRequest(Box::new(mr)))
1758}
1759
1760#[derive(Deserialize)]
1765struct GetAssetsParams {
1766 context_type: String,
1768 key: String,
1770}
1771
1772async fn execute_get_assets(
1773 provider: &dyn devboy_core::Provider,
1774 args: &Value,
1775) -> Result<ToolOutput> {
1776 let params: GetAssetsParams = serde_json::from_value(args.clone())?;
1777 debug!(context_type = %params.context_type, key = %params.key, "get_assets");
1778
1779 let assets = match params.context_type.as_str() {
1780 "issue" => IssueProvider::get_issue_attachments(provider, ¶ms.key).await?,
1781 "mr" | "merge_request" | "pull_request" => {
1782 MergeRequestProvider::get_mr_attachments(provider, ¶ms.key).await?
1783 }
1784 other => {
1785 return Err(Error::InvalidData(format!(
1786 "unsupported context_type: '{other}', expected 'issue' or 'mr'"
1787 )));
1788 }
1789 };
1790
1791 let capabilities =
1792 serde_json::to_value(IssueProvider::asset_capabilities(provider)).unwrap_or_default();
1793 let count = assets.len();
1794 let attachments: Vec<serde_json::Value> = assets
1795 .into_iter()
1796 .map(|a| serde_json::to_value(a).unwrap_or_default())
1797 .collect();
1798 Ok(ToolOutput::AssetList {
1799 attachments,
1800 count,
1801 capabilities,
1802 })
1803}
1804
1805#[derive(Deserialize)]
1806struct UploadAssetParams {
1807 context_type: String,
1809 key: String,
1810 filename: String,
1811 #[serde(rename = "fileData")]
1813 file_data: String,
1814}
1815
1816async fn execute_upload_asset(
1817 provider: &dyn devboy_core::Provider,
1818 args: &Value,
1819) -> Result<ToolOutput> {
1820 let params: UploadAssetParams = serde_json::from_value(args.clone())?;
1821 debug!(context_type = %params.context_type, key = %params.key, filename = %params.filename, "upload_asset");
1822
1823 let data = base64_decode(¶ms.file_data)?;
1824
1825 if data.len() > MAX_FILE_SIZE {
1826 return Err(Error::InvalidData(format!(
1827 "file '{}' is {} bytes, max allowed is {} bytes",
1828 params.filename,
1829 data.len(),
1830 MAX_FILE_SIZE,
1831 )));
1832 }
1833
1834 let size = data.len();
1835 let url = match params.context_type.as_str() {
1836 "issue" => {
1837 IssueProvider::upload_attachment(provider, ¶ms.key, ¶ms.filename, &data).await?
1838 }
1839 other => {
1840 return Err(Error::InvalidData(format!(
1841 "upload not supported for context_type: '{other}', use 'issue'"
1842 )));
1843 }
1844 };
1845
1846 Ok(ToolOutput::AssetUploaded {
1847 url,
1848 filename: params.filename,
1849 size,
1850 })
1851}
1852
1853#[derive(Deserialize)]
1854struct DownloadAssetParams {
1855 context_type: String,
1857 key: String,
1858 asset_id: String,
1860}
1861
1862async fn execute_download_asset(
1863 provider: &dyn devboy_core::Provider,
1864 args: &Value,
1865 asset_manager: Option<&devboy_assets::AssetManager>,
1866) -> Result<ToolOutput> {
1867 let params: DownloadAssetParams = serde_json::from_value(args.clone())?;
1868 debug!(context_type = %params.context_type, key = %params.key, asset_id = %params.asset_id, "download_asset");
1869
1870 if let Some(mgr) = asset_manager
1872 && let Ok(Some(resolved)) = mgr.get(¶ms.asset_id)
1873 {
1874 return Ok(ToolOutput::AssetDownloaded {
1875 asset_id: params.asset_id,
1876 size: resolved.asset.size as usize,
1877 local_path: Some(resolved.absolute_path.to_string_lossy().into_owned()),
1878 data: None,
1879 cached: true,
1880 });
1881 }
1882
1883 let bytes = match params.context_type.as_str() {
1885 "issue" => {
1886 IssueProvider::download_attachment(provider, ¶ms.key, ¶ms.asset_id).await?
1887 }
1888 "mr" | "merge_request" | "pull_request" => {
1889 MergeRequestProvider::download_mr_attachment(provider, ¶ms.key, ¶ms.asset_id)
1890 .await?
1891 }
1892 other => {
1893 return Err(Error::InvalidData(format!(
1894 "unsupported context_type: '{other}', expected 'issue' or 'mr'"
1895 )));
1896 }
1897 };
1898
1899 if let Some(mgr) = asset_manager {
1901 let context = match params.context_type.as_str() {
1902 "mr" | "merge_request" | "pull_request" => devboy_core::AssetContext::MergeRequest {
1903 mr_id: params.key.clone(),
1904 },
1905 _ => devboy_core::AssetContext::Issue {
1906 key: params.key.clone(),
1907 },
1908 };
1909 let filename = devboy_core::filename_from_url(¶ms.asset_id);
1910 match mgr.store(devboy_assets::StoreRequest {
1911 context,
1912 asset_id: Some(¶ms.asset_id),
1913 filename: &filename,
1914 mime_type: None,
1915 remote_url: None,
1916 data: &bytes,
1917 }) {
1918 Ok(cached) => {
1919 let abs = mgr.cache_dir().join(&cached.local_path);
1920 return Ok(ToolOutput::AssetDownloaded {
1921 asset_id: cached.id,
1922 size: cached.size as usize,
1923 local_path: Some(abs.to_string_lossy().into_owned()),
1924 data: None,
1925 cached: true,
1926 });
1927 }
1928 Err(e) => {
1929 tracing::warn!(?e, "failed to cache asset, returning base64 fallback");
1930 }
1931 }
1932 }
1933
1934 if bytes.len() > MAX_FILE_SIZE {
1936 return Err(Error::InvalidData(format!(
1937 "downloaded attachment is {} bytes, max allowed for base64 response is {} bytes",
1938 bytes.len(),
1939 MAX_FILE_SIZE,
1940 )));
1941 }
1942
1943 let encoded = base64_encode(&bytes);
1944 Ok(ToolOutput::AssetDownloaded {
1945 asset_id: params.asset_id,
1946 size: bytes.len(),
1947 local_path: None,
1948 data: Some(encoded),
1949 cached: false,
1950 })
1951}
1952
1953#[derive(Deserialize)]
1954struct DeleteAssetParams {
1955 key: String,
1956 asset_id: String,
1957}
1958
1959async fn execute_delete_asset(
1960 provider: &dyn devboy_core::Provider,
1961 args: &Value,
1962 asset_manager: Option<&devboy_assets::AssetManager>,
1963) -> Result<ToolOutput> {
1964 let params: DeleteAssetParams = serde_json::from_value(args.clone())?;
1965 debug!(key = %params.key, asset_id = %params.asset_id, "delete_asset");
1966
1967 IssueProvider::delete_attachment(provider, ¶ms.key, ¶ms.asset_id).await?;
1968
1969 if let Some(mgr) = asset_manager
1971 && let Err(e) = mgr.delete(¶ms.asset_id)
1972 {
1973 tracing::warn!(?e, asset_id = %params.asset_id, "failed to evict deleted asset from cache");
1974 }
1975
1976 let message = format!(
1977 "Attachment '{}' deleted from {}",
1978 params.asset_id, params.key
1979 );
1980 Ok(ToolOutput::AssetDeleted {
1981 asset_id: params.asset_id,
1982 message,
1983 })
1984}
1985
1986const MAX_BASE64_LEN: usize = (MAX_FILE_SIZE / 3 + 1) * 4 + 4;
1988
1989fn base64_decode(input: &str) -> Result<Vec<u8>> {
1992 let trimmed = input.trim();
1993 if trimmed.len() > MAX_BASE64_LEN {
1994 return Err(Error::InvalidData(format!(
1995 "base64 input too large ({} chars), max decoded size is {} bytes",
1996 trimmed.len(),
1997 MAX_FILE_SIZE,
1998 )));
1999 }
2000 use base64::Engine;
2001 base64::engine::general_purpose::STANDARD
2002 .decode(trimmed)
2003 .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(trimmed))
2004 .map_err(|e| Error::InvalidData(format!("invalid base64: {e}")))
2005}
2006
2007fn base64_encode(data: &[u8]) -> String {
2009 use base64::Engine;
2010 base64::engine::general_purpose::STANDARD.encode(data)
2011}
2012
2013async fn execute_get_structures(provider: &dyn devboy_core::Provider) -> Result<ToolOutput> {
2018 let result = provider.get_structures().await?;
2019 let meta = ResultMeta {
2020 pagination: result.pagination,
2021 sort_info: result.sort_info,
2022 };
2023 Ok(ToolOutput::Structures(result.items, Some(meta)))
2024}
2025
2026#[derive(Deserialize)]
2027#[serde(rename_all = "camelCase")]
2028struct GetStructureForestParams {
2029 structure_id: u64,
2030 offset: Option<u64>,
2031 limit: Option<u64>,
2032}
2033
2034async fn execute_get_structure_forest(
2035 provider: &dyn devboy_core::Provider,
2036 args: &Value,
2037) -> Result<ToolOutput> {
2038 let params: GetStructureForestParams = serde_json::from_value(args.clone())
2039 .map_err(|e| Error::InvalidData(format!("missing 'structureId': {e}")))?;
2040 let forest = provider
2041 .get_structure_forest(
2042 params.structure_id,
2043 GetForestOptions {
2044 offset: params.offset,
2045 limit: Some(params.limit.unwrap_or(200)),
2046 },
2047 )
2048 .await?;
2049 Ok(ToolOutput::StructureForest(Box::new(forest)))
2050}
2051
2052#[derive(Deserialize)]
2053#[serde(rename_all = "camelCase")]
2054struct AddStructureRowsParams {
2055 structure_id: u64,
2056 items: Vec<Value>,
2057 under: Option<u64>,
2058 after: Option<u64>,
2059 forest_version: Option<u64>,
2060}
2061
2062fn parse_structure_row_item(v: Value) -> Result<StructureRowItem> {
2074 if let Some(s) = v.as_str() {
2075 if let Ok(parsed) = serde_json::from_str::<Value>(s)
2076 && parsed.is_object()
2077 {
2078 return serde_json::from_value(parsed)
2079 .map_err(|e| Error::InvalidData(format!("invalid structure row item JSON: {e}")));
2080 }
2081 return Ok(StructureRowItem {
2082 item_id: s.to_string(),
2083 item_type: None,
2084 });
2085 }
2086 serde_json::from_value(v)
2087 .map_err(|e| Error::InvalidData(format!("invalid structure row item: {e}")))
2088}
2089
2090fn parse_structure_column_spec(v: Value) -> Result<StructureViewColumn> {
2096 if let Some(s) = v.as_str() {
2097 if let Ok(parsed) = serde_json::from_str::<Value>(s)
2098 && parsed.is_object()
2099 {
2100 return serde_json::from_value(parsed).map_err(|e| {
2101 Error::InvalidData(format!("invalid structure column spec JSON: {e}"))
2102 });
2103 }
2104 return Ok(StructureViewColumn {
2105 field: Some(s.to_string()),
2106 ..Default::default()
2107 });
2108 }
2109 serde_json::from_value(v)
2110 .map_err(|e| Error::InvalidData(format!("invalid structure column spec: {e}")))
2111}
2112
2113async fn execute_add_structure_rows(
2114 provider: &dyn devboy_core::Provider,
2115 args: &Value,
2116) -> Result<ToolOutput> {
2117 let params: AddStructureRowsParams = serde_json::from_value(args.clone())
2118 .map_err(|e| Error::InvalidData(format!("invalid add_structure_rows params: {e}")))?;
2119
2120 let items: Vec<StructureRowItem> = params
2121 .items
2122 .into_iter()
2123 .map(parse_structure_row_item)
2124 .collect::<Result<Vec<_>>>()?;
2125
2126 let result = provider
2127 .add_structure_rows(
2128 params.structure_id,
2129 AddStructureRowsInput {
2130 items,
2131 under: params.under,
2132 after: params.after,
2133 forest_version: params.forest_version,
2134 },
2135 )
2136 .await?;
2137 Ok(ToolOutput::ForestModified(result))
2138}
2139
2140#[derive(Deserialize)]
2141#[serde(rename_all = "camelCase")]
2142struct MoveStructureRowsParams {
2143 structure_id: u64,
2144 row_ids: Vec<u64>,
2145 under: Option<u64>,
2146 after: Option<u64>,
2147 forest_version: Option<u64>,
2148}
2149
2150async fn execute_move_structure_rows(
2151 provider: &dyn devboy_core::Provider,
2152 args: &Value,
2153) -> Result<ToolOutput> {
2154 let params: MoveStructureRowsParams = serde_json::from_value(args.clone())
2155 .map_err(|e| Error::InvalidData(format!("invalid move_structure_rows params: {e}")))?;
2156 let result = provider
2157 .move_structure_rows(
2158 params.structure_id,
2159 MoveStructureRowsInput {
2160 row_ids: params.row_ids,
2161 under: params.under,
2162 after: params.after,
2163 forest_version: params.forest_version,
2164 },
2165 )
2166 .await?;
2167 Ok(ToolOutput::ForestModified(result))
2168}
2169
2170#[derive(Deserialize)]
2171#[serde(rename_all = "camelCase")]
2172struct RemoveStructureRowParams {
2173 structure_id: u64,
2174 row_id: u64,
2175}
2176
2177async fn execute_remove_structure_row(
2178 provider: &dyn devboy_core::Provider,
2179 args: &Value,
2180) -> Result<ToolOutput> {
2181 let params: RemoveStructureRowParams = serde_json::from_value(args.clone())
2182 .map_err(|e| Error::InvalidData(format!("invalid remove_structure_row params: {e}")))?;
2183 provider
2184 .remove_structure_row(params.structure_id, params.row_id)
2185 .await?;
2186 Ok(ToolOutput::Text(format!(
2187 "Row {} removed from structure {}",
2188 params.row_id, params.structure_id
2189 )))
2190}
2191
2192#[derive(Deserialize)]
2193#[serde(rename_all = "camelCase")]
2194struct GetStructureValuesParams {
2195 structure_id: u64,
2196 rows: Vec<u64>,
2197 columns: Vec<Value>,
2198}
2199
2200async fn execute_get_structure_values(
2201 provider: &dyn devboy_core::Provider,
2202 args: &Value,
2203) -> Result<ToolOutput> {
2204 let params: GetStructureValuesParams = serde_json::from_value(args.clone())
2205 .map_err(|e| Error::InvalidData(format!("invalid get_structure_values params: {e}")))?;
2206
2207 let columns: Vec<StructureViewColumn> = params
2208 .columns
2209 .into_iter()
2210 .map(parse_structure_column_spec)
2211 .collect::<Result<Vec<_>>>()?;
2212
2213 let result = provider
2214 .get_structure_values(GetStructureValuesInput {
2215 structure_id: params.structure_id,
2216 rows: params.rows,
2217 columns,
2218 })
2219 .await?;
2220 Ok(ToolOutput::StructureValues(Box::new(result)))
2221}
2222
2223#[derive(Deserialize)]
2224#[serde(rename_all = "camelCase")]
2225struct GetStructureViewsParams {
2226 structure_id: u64,
2227 view_id: Option<u64>,
2228}
2229
2230async fn execute_get_structure_views(
2231 provider: &dyn devboy_core::Provider,
2232 args: &Value,
2233) -> Result<ToolOutput> {
2234 let params: GetStructureViewsParams = serde_json::from_value(args.clone())
2235 .map_err(|e| Error::InvalidData(format!("invalid get_structure_views params: {e}")))?;
2236 let views = provider
2237 .get_structure_views(params.structure_id, params.view_id)
2238 .await?;
2239 Ok(ToolOutput::StructureViews(views, None))
2240}
2241
2242#[derive(Deserialize)]
2243#[serde(rename_all = "camelCase")]
2244struct SaveStructureViewParams {
2245 id: Option<u64>,
2246 structure_id: u64,
2247 name: String,
2248 columns: Option<Vec<Value>>,
2249 group_by: Option<String>,
2250 sort_by: Option<String>,
2251 filter: Option<String>,
2252}
2253
2254async fn execute_save_structure_view(
2255 provider: &dyn devboy_core::Provider,
2256 args: &Value,
2257) -> Result<ToolOutput> {
2258 let params: SaveStructureViewParams = serde_json::from_value(args.clone())
2259 .map_err(|e| Error::InvalidData(format!("invalid save_structure_view params: {e}")))?;
2260
2261 let columns: Option<Vec<StructureViewColumn>> = params
2262 .columns
2263 .map(|cols| {
2264 cols.into_iter()
2265 .map(parse_structure_column_spec)
2266 .collect::<Result<Vec<_>>>()
2267 })
2268 .transpose()?;
2269
2270 let view = provider
2271 .save_structure_view(SaveStructureViewInput {
2272 id: params.id,
2273 structure_id: params.structure_id,
2274 name: params.name,
2275 columns,
2276 group_by: params.group_by,
2277 sort_by: params.sort_by,
2278 filter: params.filter,
2279 })
2280 .await?;
2281 Ok(ToolOutput::StructureViews(vec![view], None))
2282}
2283
2284#[derive(Deserialize)]
2285struct CreateStructureParams {
2286 name: String,
2287 description: Option<String>,
2288}
2289
2290async fn execute_create_structure(
2291 provider: &dyn devboy_core::Provider,
2292 args: &Value,
2293) -> Result<ToolOutput> {
2294 let params: CreateStructureParams = serde_json::from_value(args.clone())
2295 .map_err(|e| Error::InvalidData(format!("missing 'name': {e}")))?;
2296 let structure = provider
2297 .create_structure(CreateStructureInput {
2298 name: params.name,
2299 description: params.description,
2300 })
2301 .await?;
2302 Ok(ToolOutput::Structures(vec![structure], None))
2303}
2304
2305fn parse_tri_filter(s: Option<&str>) -> Result<Option<bool>> {
2312 match s.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
2313 None | Some("") | Some("all") | Some("any") => Ok(None),
2314 Some("true") | Some("yes") | Some("1") => Ok(Some(true)),
2315 Some("false") | Some("no") | Some("0") => Ok(Some(false)),
2316 Some(other) => Err(Error::InvalidData(format!(
2317 "expected 'true' | 'false' | 'all', got '{other}'"
2318 ))),
2319 }
2320}
2321
2322fn validate_iso_date(field: &str, value: &str) -> Result<()> {
2328 let bytes = value.as_bytes();
2329 let shape_ok = bytes.len() == 10
2330 && bytes[4] == b'-'
2331 && bytes[7] == b'-'
2332 && bytes[..4].iter().all(u8::is_ascii_digit)
2333 && bytes[5..7].iter().all(u8::is_ascii_digit)
2334 && bytes[8..].iter().all(u8::is_ascii_digit);
2335 if !shape_ok {
2336 return Err(Error::InvalidData(format!(
2337 "{field} must be an ISO 8601 calendar date (YYYY-MM-DD), got '{value}'"
2338 )));
2339 }
2340 let month: u32 = value[5..7].parse().unwrap();
2341 let day: u32 = value[8..10].parse().unwrap();
2342 if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
2343 return Err(Error::InvalidData(format!(
2344 "{field} = '{value}' is not a valid calendar date"
2345 )));
2346 }
2347 Ok(())
2348}
2349
2350#[derive(Deserialize, Default)]
2351#[serde(rename_all = "camelCase")]
2352struct ListProjectVersionsArgs {
2353 project: Option<String>,
2354 released: Option<String>,
2355 archived: Option<String>,
2356 limit: Option<u32>,
2357 include_issue_count: Option<bool>,
2358}
2359
2360async fn execute_list_project_versions(
2361 provider: &dyn devboy_core::Provider,
2362 args: &Value,
2363) -> Result<ToolOutput> {
2364 let params: ListProjectVersionsArgs = parse_tool_params(args, "list_project_versions")?;
2365
2366 let archived = match params.archived.as_deref() {
2370 None => Some(false),
2371 Some(s) => parse_tri_filter(Some(s))?,
2372 };
2373 let released = match params.released.as_deref() {
2374 None => None,
2375 Some(s) => parse_tri_filter(Some(s))?,
2376 };
2377 if let Some(0) = params.limit {
2381 return Err(Error::InvalidData(
2382 "limit must be at least 1 (use the default by omitting the field)".into(),
2383 ));
2384 }
2385 let limit = params.limit.unwrap_or(20).min(200);
2386
2387 let result = provider
2388 .list_project_versions(ListProjectVersionsParams {
2389 project: params.project.unwrap_or_default(),
2390 released,
2391 archived,
2392 limit: Some(limit),
2393 include_issue_count: params.include_issue_count.unwrap_or(false),
2394 })
2395 .await?;
2396
2397 let meta = ResultMeta {
2398 pagination: result.pagination,
2399 sort_info: result.sort_info,
2400 };
2401 Ok(ToolOutput::ProjectVersions(result.items, Some(meta)))
2402}
2403
2404#[derive(Deserialize)]
2405#[serde(rename_all = "camelCase")]
2406struct UpsertProjectVersionArgs {
2407 project: Option<String>,
2408 name: String,
2409 description: Option<String>,
2410 start_date: Option<String>,
2411 release_date: Option<String>,
2412 released: Option<bool>,
2413 archived: Option<bool>,
2414}
2415
2416async fn execute_upsert_project_version(
2417 provider: &dyn devboy_core::Provider,
2418 args: &Value,
2419) -> Result<ToolOutput> {
2420 let params: UpsertProjectVersionArgs = serde_json::from_value(args.clone())
2421 .map_err(|e| Error::InvalidData(format!("invalid upsert_project_version params: {e}")))?;
2422
2423 if let Some(ref d) = params.start_date {
2426 validate_iso_date("startDate", d)?;
2427 }
2428 if let Some(ref d) = params.release_date {
2429 validate_iso_date("releaseDate", d)?;
2430 }
2431
2432 let version = provider
2433 .upsert_project_version(UpsertProjectVersionInput {
2434 project: params.project.unwrap_or_default(),
2435 name: params.name,
2436 description: params.description,
2437 start_date: params.start_date,
2438 release_date: params.release_date,
2439 released: params.released,
2440 archived: params.archived,
2441 })
2442 .await?;
2443
2444 Ok(ToolOutput::SingleProjectVersion(Box::new(version)))
2445}
2446
2447#[cfg(test)]
2448mod tests {
2449 use super::*;
2450 use async_trait::async_trait;
2451 use devboy_core::{
2452 Comment, CreateMergeRequestInput, Discussion, FileDiff, Issue, IssueLink, IssueProvider,
2453 IssueRelations, KbPage, KbPageContent, KbSpace, KnowledgeBaseProvider, MergeRequest,
2454 MergeRequestProvider, Provider, User,
2455 };
2456
2457 struct MockProvider;
2460
2461 fn sample_issue() -> Issue {
2462 Issue {
2463 key: "gh#1".into(),
2464 title: "Test Issue".into(),
2465 description: Some("Body".into()),
2466 state: "open".into(),
2467 source: "mock".into(),
2468 priority: None,
2469 labels: vec!["bug".into()],
2470 author: None,
2471 assignees: vec![],
2472 url: Some("https://example.com/1".into()),
2473 created_at: Some("2024-01-01T00:00:00Z".into()),
2474 updated_at: Some("2024-01-02T00:00:00Z".into()),
2475 attachments_count: None,
2476 parent: None,
2477 subtasks: vec![],
2478 }
2479 }
2480
2481 fn sample_mr() -> MergeRequest {
2482 MergeRequest {
2483 key: "pr#1".into(),
2484 title: "Test PR".into(),
2485 description: Some("PR body".into()),
2486 state: "open".into(),
2487 source: "mock".into(),
2488 source_branch: "feature".into(),
2489 target_branch: "main".into(),
2490 author: None,
2491 assignees: vec![],
2492 reviewers: vec![],
2493 labels: vec![],
2494 draft: false,
2495 url: Some("https://example.com/pr/1".into()),
2496 created_at: Some("2024-01-01T00:00:00Z".into()),
2497 updated_at: Some("2024-01-02T00:00:00Z".into()),
2498 }
2499 }
2500
2501 fn sample_comment() -> Comment {
2502 Comment {
2503 id: "c1".into(),
2504 body: "Test comment".into(),
2505 author: None,
2506 created_at: None,
2507 updated_at: None,
2508 position: None,
2509 }
2510 }
2511
2512 fn sample_discussion() -> Discussion {
2513 Discussion {
2514 id: "d1".into(),
2515 resolved: false,
2516 resolved_by: None,
2517 comments: vec![sample_comment()],
2518 position: None,
2519 }
2520 }
2521
2522 fn sample_diff() -> FileDiff {
2523 FileDiff {
2524 file_path: "src/main.rs".into(),
2525 old_path: None,
2526 new_file: false,
2527 deleted_file: false,
2528 renamed_file: false,
2529 diff: "+added\n-removed".into(),
2530 additions: Some(1),
2531 deletions: Some(1),
2532 }
2533 }
2534
2535 fn sample_kb_space() -> KbSpace {
2536 KbSpace {
2537 id: "space-1".into(),
2538 key: "ENG".into(),
2539 name: "Engineering".into(),
2540 ..Default::default()
2541 }
2542 }
2543
2544 fn sample_kb_page() -> KbPage {
2545 KbPage {
2546 id: "page-1".into(),
2547 title: "Architecture".into(),
2548 space_key: Some("ENG".into()),
2549 ..Default::default()
2550 }
2551 }
2552
2553 fn sample_kb_page_content() -> KbPageContent {
2554 KbPageContent {
2555 page: sample_kb_page(),
2556 content: "<p>body</p>".into(),
2557 content_type: "storage".into(),
2558 ancestors: vec![],
2559 labels: vec!["docs".into()],
2560 }
2561 }
2562
2563 #[async_trait]
2564 impl IssueProvider for MockProvider {
2565 async fn get_issues(
2566 &self,
2567 _filter: IssueFilter,
2568 ) -> devboy_core::Result<devboy_core::ProviderResult<Issue>> {
2569 Ok(vec![sample_issue()].into())
2570 }
2571 async fn get_issue(&self, _key: &str) -> devboy_core::Result<Issue> {
2572 Ok(sample_issue())
2573 }
2574 async fn create_issue(
2575 &self,
2576 _input: devboy_core::CreateIssueInput,
2577 ) -> devboy_core::Result<Issue> {
2578 Ok(sample_issue())
2579 }
2580 async fn update_issue(
2581 &self,
2582 _key: &str,
2583 _input: devboy_core::UpdateIssueInput,
2584 ) -> devboy_core::Result<Issue> {
2585 Ok(sample_issue())
2586 }
2587 async fn get_comments(
2588 &self,
2589 _key: &str,
2590 ) -> devboy_core::Result<devboy_core::ProviderResult<Comment>> {
2591 Ok(vec![sample_comment()].into())
2592 }
2593 async fn add_comment(&self, _key: &str, _body: &str) -> devboy_core::Result<Comment> {
2594 Ok(sample_comment())
2595 }
2596 async fn get_issue_relations(&self, _key: &str) -> devboy_core::Result<IssueRelations> {
2597 Ok(IssueRelations {
2598 parent: Some(sample_issue()),
2599 subtasks: vec![sample_issue()],
2600 blocks: vec![IssueLink {
2601 issue: sample_issue(),
2602 link_type: "Blocks".into(),
2603 }],
2604 ..Default::default()
2605 })
2606 }
2607 async fn get_structures(
2608 &self,
2609 ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::Structure>> {
2610 Ok(vec![sample_structure()].into())
2611 }
2612 async fn get_structure_forest(
2613 &self,
2614 structure_id: u64,
2615 _options: devboy_core::GetForestOptions,
2616 ) -> devboy_core::Result<devboy_core::StructureForest> {
2617 Ok(sample_forest(structure_id))
2618 }
2619 async fn add_structure_rows(
2620 &self,
2621 _structure_id: u64,
2622 input: devboy_core::AddStructureRowsInput,
2623 ) -> devboy_core::Result<devboy_core::ForestModifyResult> {
2624 Ok(devboy_core::ForestModifyResult {
2625 version: 2,
2626 affected_count: input.items.len(),
2627 })
2628 }
2629 async fn move_structure_rows(
2630 &self,
2631 _structure_id: u64,
2632 input: devboy_core::MoveStructureRowsInput,
2633 ) -> devboy_core::Result<devboy_core::ForestModifyResult> {
2634 Ok(devboy_core::ForestModifyResult {
2635 version: 3,
2636 affected_count: input.row_ids.len(),
2637 })
2638 }
2639 async fn remove_structure_row(
2640 &self,
2641 _structure_id: u64,
2642 _row_id: u64,
2643 ) -> devboy_core::Result<()> {
2644 Ok(())
2645 }
2646 async fn get_structure_values(
2647 &self,
2648 input: devboy_core::GetStructureValuesInput,
2649 ) -> devboy_core::Result<devboy_core::StructureValues> {
2650 Ok(devboy_core::StructureValues {
2651 structure_id: input.structure_id,
2652 values: vec![],
2653 })
2654 }
2655 async fn get_structure_views(
2656 &self,
2657 structure_id: u64,
2658 _view_id: Option<u64>,
2659 ) -> devboy_core::Result<Vec<devboy_core::StructureView>> {
2660 Ok(vec![sample_view(structure_id)])
2661 }
2662 async fn save_structure_view(
2663 &self,
2664 input: devboy_core::SaveStructureViewInput,
2665 ) -> devboy_core::Result<devboy_core::StructureView> {
2666 Ok(devboy_core::StructureView {
2667 id: input.id.unwrap_or(99),
2668 name: input.name,
2669 structure_id: input.structure_id,
2670 ..Default::default()
2671 })
2672 }
2673 async fn create_structure(
2674 &self,
2675 input: devboy_core::CreateStructureInput,
2676 ) -> devboy_core::Result<devboy_core::Structure> {
2677 Ok(devboy_core::Structure {
2678 id: 42,
2679 name: input.name,
2680 description: input.description,
2681 })
2682 }
2683 async fn list_project_versions(
2684 &self,
2685 params: devboy_core::ListProjectVersionsParams,
2686 ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::ProjectVersion>> {
2687 let mut name = format!(
2690 "v-released={:?}-archived={:?}-limit={:?}-expand={}",
2691 params.released, params.archived, params.limit, params.include_issue_count
2692 );
2693 if !params.project.is_empty() {
2694 name.push_str(&format!("-project={}", params.project));
2695 }
2696 Ok(vec![devboy_core::ProjectVersion {
2697 id: "1".into(),
2698 project: if params.project.is_empty() {
2699 "MOCK".into()
2700 } else {
2701 params.project
2702 },
2703 name,
2704 description: Some("desc".into()),
2705 start_date: None,
2706 release_date: Some("2026-01-01".into()),
2707 released: false,
2708 archived: false,
2709 overdue: None,
2710 issue_count: Some(0),
2711 unresolved_issue_count: None,
2712 source: "mock".into(),
2713 }]
2714 .into())
2715 }
2716 async fn upsert_project_version(
2717 &self,
2718 input: devboy_core::UpsertProjectVersionInput,
2719 ) -> devboy_core::Result<devboy_core::ProjectVersion> {
2720 Ok(devboy_core::ProjectVersion {
2721 id: "777".into(),
2722 project: if input.project.is_empty() {
2723 "MOCK".into()
2724 } else {
2725 input.project
2726 },
2727 name: input.name,
2728 description: input.description,
2729 start_date: input.start_date,
2730 release_date: input.release_date,
2731 released: input.released.unwrap_or(false),
2732 archived: input.archived.unwrap_or(false),
2733 overdue: None,
2734 issue_count: None,
2735 unresolved_issue_count: None,
2736 source: "mock".into(),
2737 })
2738 }
2739 fn provider_name(&self) -> &'static str {
2740 "mock"
2741 }
2742 }
2743
2744 #[async_trait]
2745 impl MergeRequestProvider for MockProvider {
2746 async fn get_merge_requests(
2747 &self,
2748 _filter: MrFilter,
2749 ) -> devboy_core::Result<devboy_core::ProviderResult<MergeRequest>> {
2750 Ok(vec![sample_mr()].into())
2751 }
2752 async fn get_merge_request(&self, _key: &str) -> devboy_core::Result<MergeRequest> {
2753 Ok(sample_mr())
2754 }
2755 async fn get_discussions(
2756 &self,
2757 _key: &str,
2758 ) -> devboy_core::Result<devboy_core::ProviderResult<Discussion>> {
2759 Ok(vec![sample_discussion()].into())
2760 }
2761 async fn get_diffs(
2762 &self,
2763 _key: &str,
2764 ) -> devboy_core::Result<devboy_core::ProviderResult<FileDiff>> {
2765 Ok(vec![sample_diff()].into())
2766 }
2767 async fn add_comment(
2768 &self,
2769 _key: &str,
2770 _input: CreateCommentInput,
2771 ) -> devboy_core::Result<Comment> {
2772 Ok(sample_comment())
2773 }
2774 async fn create_merge_request(
2775 &self,
2776 _input: CreateMergeRequestInput,
2777 ) -> devboy_core::Result<MergeRequest> {
2778 Ok(sample_mr())
2779 }
2780 fn provider_name(&self) -> &'static str {
2781 "mock"
2782 }
2783 }
2784
2785 #[async_trait]
2786 impl devboy_core::PipelineProvider for MockProvider {
2787 fn provider_name(&self) -> &'static str {
2788 "mock"
2789 }
2790 }
2791
2792 #[async_trait]
2793 impl KnowledgeBaseProvider for MockProvider {
2794 fn provider_name(&self) -> &'static str {
2795 "mock"
2796 }
2797
2798 async fn get_spaces(&self) -> devboy_core::Result<devboy_core::ProviderResult<KbSpace>> {
2799 Ok(vec![sample_kb_space()].into())
2800 }
2801
2802 async fn list_pages(
2803 &self,
2804 _params: ListPagesParams,
2805 ) -> devboy_core::Result<devboy_core::ProviderResult<KbPage>> {
2806 Ok(vec![sample_kb_page()].into())
2807 }
2808
2809 async fn get_page(&self, _page_id: &str) -> devboy_core::Result<KbPageContent> {
2810 Ok(sample_kb_page_content())
2811 }
2812
2813 async fn create_page(
2814 &self,
2815 _params: devboy_core::CreatePageParams,
2816 ) -> devboy_core::Result<KbPage> {
2817 Ok(sample_kb_page())
2818 }
2819
2820 async fn update_page(
2821 &self,
2822 _params: devboy_core::UpdatePageParams,
2823 ) -> devboy_core::Result<KbPage> {
2824 Ok(sample_kb_page())
2825 }
2826
2827 async fn search(
2828 &self,
2829 _params: SearchKbParams,
2830 ) -> devboy_core::Result<devboy_core::ProviderResult<KbPage>> {
2831 Ok(vec![sample_kb_page()].into())
2832 }
2833 }
2834
2835 #[async_trait]
2836 impl Provider for MockProvider {
2837 async fn get_current_user(&self) -> devboy_core::Result<User> {
2838 Ok(User {
2839 id: "1".into(),
2840 username: "test".into(),
2841 name: None,
2842 email: None,
2843 avatar_url: None,
2844 })
2845 }
2846 }
2847
2848 #[test]
2851 fn test_executor_new() {
2852 let executor = Executor::new();
2853 assert!(executor.enrichers.is_empty());
2854 }
2855
2856 #[test]
2857 fn test_supported_tools_contains_all() {
2858 assert!(SUPPORTED_TOOLS.contains(&"get_issues"));
2859 assert!(SUPPORTED_TOOLS.contains(&"get_merge_requests"));
2860 assert!(SUPPORTED_TOOLS.contains(&"create_merge_request_comment"));
2861 assert!(SUPPORTED_TOOLS.contains(&"get_meeting_notes"));
2862 assert!(SUPPORTED_TOOLS.contains(&"get_meeting_transcript"));
2863 assert!(SUPPORTED_TOOLS.contains(&"search_meeting_notes"));
2864 assert!(SUPPORTED_TOOLS.contains(&"get_knowledge_base_spaces"));
2865 assert!(SUPPORTED_TOOLS.contains(&"list_knowledge_base_pages"));
2866 assert!(SUPPORTED_TOOLS.contains(&"get_knowledge_base_page"));
2867 assert!(SUPPORTED_TOOLS.contains(&"create_knowledge_base_page"));
2868 assert!(SUPPORTED_TOOLS.contains(&"update_knowledge_base_page"));
2869 assert!(SUPPORTED_TOOLS.contains(&"search_knowledge_base"));
2870 assert!(SUPPORTED_TOOLS.contains(&"get_messenger_chats"));
2871 assert!(SUPPORTED_TOOLS.contains(&"get_chat_messages"));
2872 assert!(SUPPORTED_TOOLS.contains(&"search_chat_messages"));
2873 assert!(SUPPORTED_TOOLS.contains(&"send_message"));
2874 assert_eq!(SUPPORTED_TOOLS.len(), 40);
2875 }
2876
2877 #[tokio::test]
2878 async fn test_dispatch_get_knowledge_base_spaces() {
2879 let provider = MockProvider;
2880 let result =
2881 dispatch_knowledge_base_tool("get_knowledge_base_spaces", &Value::Null, &provider)
2882 .await
2883 .unwrap();
2884 assert!(matches!(result, ToolOutput::KnowledgeBaseSpaces(v, _) if v.len() == 1));
2885 }
2886
2887 #[tokio::test]
2888 async fn test_dispatch_list_knowledge_base_pages() {
2889 let provider = MockProvider;
2890 let args = serde_json::json!({"spaceKey": "ENG", "limit": 10});
2891 let result = dispatch_knowledge_base_tool("list_knowledge_base_pages", &args, &provider)
2892 .await
2893 .unwrap();
2894 assert!(matches!(result, ToolOutput::KnowledgeBasePages(v, _) if v.len() == 1));
2895 }
2896
2897 #[tokio::test]
2898 async fn test_dispatch_get_knowledge_base_page() {
2899 let provider = MockProvider;
2900 let args = serde_json::json!({"pageId": "page-1"});
2901 let result = dispatch_knowledge_base_tool("get_knowledge_base_page", &args, &provider)
2902 .await
2903 .unwrap();
2904 assert!(matches!(result, ToolOutput::KnowledgeBasePage(_)));
2905 }
2906
2907 #[tokio::test]
2908 async fn test_dispatch_create_knowledge_base_page() {
2909 let provider = MockProvider;
2910 let args = serde_json::json!({
2911 "spaceKey": "ENG",
2912 "title": "New Page",
2913 "content": "<p>body</p>",
2914 "contentType": "storage",
2915 "labels": ["docs"]
2916 });
2917 let result = dispatch_knowledge_base_tool("create_knowledge_base_page", &args, &provider)
2918 .await
2919 .unwrap();
2920 assert!(matches!(result, ToolOutput::KnowledgeBasePageSummary(_)));
2921 }
2922
2923 #[tokio::test]
2924 async fn test_dispatch_update_knowledge_base_page() {
2925 let provider = MockProvider;
2926 let args = serde_json::json!({
2927 "pageId": "page-1",
2928 "title": "Updated",
2929 "content": "<p>new body</p>",
2930 "version": 2
2931 });
2932 let result = dispatch_knowledge_base_tool("update_knowledge_base_page", &args, &provider)
2933 .await
2934 .unwrap();
2935 assert!(matches!(result, ToolOutput::KnowledgeBasePageSummary(_)));
2936 }
2937
2938 #[tokio::test]
2939 async fn test_dispatch_search_knowledge_base() {
2940 let provider = MockProvider;
2941 let args = serde_json::json!({"query": "architecture", "spaceKey": "ENG"});
2942 let result = dispatch_knowledge_base_tool("search_knowledge_base", &args, &provider)
2943 .await
2944 .unwrap();
2945 assert!(matches!(result, ToolOutput::KnowledgeBasePages(v, _) if v.len() == 1));
2946 }
2947
2948 #[tokio::test]
2951 async fn test_dispatch_get_issues() {
2952 let provider = MockProvider;
2953 let args = serde_json::json!({"state": "open", "limit": 10});
2954 let result = dispatch_tool("get_issues", &args, &provider, None)
2955 .await
2956 .unwrap();
2957 assert!(matches!(result, ToolOutput::Issues(v, _) if v.len() == 1));
2958 }
2959
2960 #[tokio::test]
2961 async fn test_dispatch_get_issues_empty_args() {
2962 let provider = MockProvider;
2963 let result = dispatch_tool("get_issues", &Value::Null, &provider, None)
2964 .await
2965 .unwrap();
2966 assert!(matches!(result, ToolOutput::Issues(_, _)));
2967 }
2968
2969 #[tokio::test]
2970 async fn test_dispatch_get_issues_invalid_params_are_rejected() {
2971 let provider = MockProvider;
2976 let args = serde_json::json!({"state": 42});
2977 let err = dispatch_tool("get_issues", &args, &provider, None)
2978 .await
2979 .unwrap_err();
2980 assert!(
2981 matches!(err, devboy_core::Error::InvalidData(ref msg) if msg.contains("get_issues")),
2982 "expected InvalidData referencing get_issues, got {err:?}"
2983 );
2984 }
2985
2986 #[tokio::test]
2987 async fn test_dispatch_get_merge_requests_invalid_params_rejected() {
2988 let provider = MockProvider;
2989 let args = serde_json::json!({"limit": "not-a-number"});
2990 let err = dispatch_tool("get_merge_requests", &args, &provider, None)
2991 .await
2992 .unwrap_err();
2993 assert!(
2994 matches!(err, devboy_core::Error::InvalidData(ref msg) if msg.contains("get_merge_requests")),
2995 "expected InvalidData referencing get_merge_requests, got {err:?}"
2996 );
2997 }
2998
2999 #[tokio::test]
3000 async fn test_dispatch_get_pipeline_invalid_params_rejected() {
3001 let provider = MockProvider;
3002 let args = serde_json::json!({"includeFailedLogs": "yes"});
3003 let err = dispatch_tool("get_pipeline", &args, &provider, None)
3004 .await
3005 .unwrap_err();
3006 assert!(
3007 matches!(err, devboy_core::Error::InvalidData(ref msg) if msg.contains("get_pipeline")),
3008 "expected InvalidData referencing get_pipeline, got {err:?}"
3009 );
3010 }
3011
3012 #[test]
3013 fn parse_tool_params_null_yields_default() {
3014 #[derive(Debug, Default, serde::Deserialize)]
3015 struct P {
3016 #[allow(dead_code)]
3017 x: Option<String>,
3018 }
3019 let _: P = parse_tool_params(&Value::Null, "test").expect("null → default");
3020 }
3021
3022 #[test]
3023 fn parse_tool_params_empty_object_yields_default() {
3024 #[derive(Debug, Default, serde::Deserialize)]
3027 struct P {
3028 #[allow(dead_code)]
3029 x: Option<String>,
3030 }
3031 let _: P = parse_tool_params(&serde_json::json!({}), "test").expect("{} → default");
3032 }
3033
3034 #[test]
3035 fn parse_tool_params_invalid_maps_to_invalid_data() {
3036 #[derive(Debug, Default, serde::Deserialize)]
3037 struct P {
3038 #[allow(dead_code)]
3039 n: u32,
3040 }
3041 let err = parse_tool_params::<P>(&serde_json::json!({"n": "nope"}), "tool-x").unwrap_err();
3042 assert!(
3043 matches!(err, devboy_core::Error::InvalidData(ref msg) if msg.contains("tool-x")),
3044 "expected InvalidData(tool-x), got {err:?}"
3045 );
3046 }
3047
3048 #[tokio::test]
3049 async fn test_dispatch_get_issue() {
3050 let provider = MockProvider;
3051 let args = serde_json::json!({"key": "gh#1"});
3053 let result = dispatch_tool("get_issue", &args, &provider, None)
3054 .await
3055 .unwrap();
3056 assert!(matches!(result, ToolOutput::Text(_)));
3057
3058 let args =
3060 serde_json::json!({"key": "gh#1", "includeComments": false, "includeRelations": false});
3061 let result = dispatch_tool("get_issue", &args, &provider, None)
3062 .await
3063 .unwrap();
3064 assert!(matches!(result, ToolOutput::SingleIssue(_)));
3065 }
3066
3067 #[tokio::test]
3068 async fn test_dispatch_get_issue_missing_key() {
3069 let provider = MockProvider;
3070 let result = dispatch_tool("get_issue", &serde_json::json!({}), &provider, None).await;
3071 assert!(result.is_err());
3072 }
3073
3074 #[tokio::test]
3075 async fn test_dispatch_get_issue_comments() {
3076 let provider = MockProvider;
3077 let args = serde_json::json!({"key": "gh#1"});
3078 let result = dispatch_tool("get_issue_comments", &args, &provider, None)
3079 .await
3080 .unwrap();
3081 assert!(matches!(result, ToolOutput::Comments(v, _) if v.len() == 1));
3082 }
3083
3084 #[tokio::test]
3085 async fn test_dispatch_create_issue() {
3086 let provider = MockProvider;
3087 let args =
3088 serde_json::json!({"title": "New issue", "description": "Body", "labels": ["bug"]});
3089 let result = dispatch_tool("create_issue", &args, &provider, None)
3090 .await
3091 .unwrap();
3092 assert!(matches!(result, ToolOutput::SingleIssue(_)));
3093 }
3094
3095 #[test]
3096 fn create_issue_params_accepts_parent_id_alias() {
3097 let args = serde_json::json!({ "title": "t", "parentId": "DEV-799" });
3098 let params: CreateIssueParams = serde_json::from_value(args).unwrap();
3099 assert_eq!(params.parent.as_deref(), Some("DEV-799"));
3100 }
3101
3102 #[test]
3103 fn create_issue_params_still_accepts_parent() {
3104 let args = serde_json::json!({ "title": "t", "parent": "DEV-799" });
3105 let params: CreateIssueParams = serde_json::from_value(args).unwrap();
3106 assert_eq!(params.parent.as_deref(), Some("DEV-799"));
3107 }
3108
3109 #[tokio::test]
3110 async fn test_dispatch_update_issue() {
3111 let provider = MockProvider;
3112 let args = serde_json::json!({"key": "gh#1", "title": "Updated"});
3113 let result = dispatch_tool("update_issue", &args, &provider, None)
3114 .await
3115 .unwrap();
3116 assert!(matches!(result, ToolOutput::SingleIssue(_)));
3117 }
3118
3119 #[tokio::test]
3120 async fn test_dispatch_add_issue_comment() {
3121 let provider = MockProvider;
3122 let args = serde_json::json!({"key": "gh#1", "body": "A comment"});
3123 let result = dispatch_tool("add_issue_comment", &args, &provider, None)
3124 .await
3125 .unwrap();
3126 assert!(matches!(result, ToolOutput::Text(ref t) if t.contains("Comment added")));
3127 }
3128
3129 #[tokio::test]
3130 async fn test_dispatch_get_issue_relations() {
3131 let provider = MockProvider;
3132 let args = serde_json::json!({"key": "gh#1"});
3133 let result = dispatch_tool("get_issue_relations", &args, &provider, None)
3134 .await
3135 .unwrap();
3136 match result {
3137 ToolOutput::Relations(relations) => {
3138 assert!(relations.parent.is_some());
3139 assert_eq!(relations.subtasks.len(), 1);
3140 assert_eq!(relations.blocks.len(), 1);
3141 }
3142 other => panic!("Expected Relations, got {:?}", other),
3143 }
3144 }
3145
3146 #[tokio::test]
3147 async fn test_dispatch_get_issue_relations_missing_key() {
3148 let provider = MockProvider;
3149 let result = dispatch_tool(
3150 "get_issue_relations",
3151 &serde_json::json!({}),
3152 &provider,
3153 None,
3154 )
3155 .await;
3156 assert!(result.is_err());
3157 }
3158
3159 #[tokio::test]
3162 async fn test_dispatch_get_merge_requests() {
3163 let provider = MockProvider;
3164 let args = serde_json::json!({"state": "open", "limit": 5});
3165 let result = dispatch_tool("get_merge_requests", &args, &provider, None)
3166 .await
3167 .unwrap();
3168 assert!(matches!(result, ToolOutput::MergeRequests(v, _) if v.len() == 1));
3169 }
3170
3171 #[tokio::test]
3172 async fn test_dispatch_get_merge_requests_empty_args() {
3173 let provider = MockProvider;
3174 let result = dispatch_tool("get_merge_requests", &Value::Null, &provider, None)
3175 .await
3176 .unwrap();
3177 assert!(matches!(result, ToolOutput::MergeRequests(_, _)));
3178 }
3179
3180 #[tokio::test]
3181 async fn test_dispatch_get_merge_request() {
3182 let provider = MockProvider;
3183 let args = serde_json::json!({"key": "pr#1"});
3184 let result = dispatch_tool("get_merge_request", &args, &provider, None)
3185 .await
3186 .unwrap();
3187 assert!(matches!(result, ToolOutput::SingleMergeRequest(_)));
3188 }
3189
3190 #[tokio::test]
3191 async fn test_dispatch_get_merge_request_discussions() {
3192 let provider = MockProvider;
3193 let args = serde_json::json!({"key": "pr#1"});
3194 let result = dispatch_tool("get_merge_request_discussions", &args, &provider, None)
3195 .await
3196 .unwrap();
3197 assert!(matches!(result, ToolOutput::Discussions(v, _) if v.len() == 1));
3198 }
3199
3200 #[tokio::test]
3201 async fn test_dispatch_get_merge_request_diffs() {
3202 let provider = MockProvider;
3203 let args = serde_json::json!({"key": "pr#1"});
3204 let result = dispatch_tool("get_merge_request_diffs", &args, &provider, None)
3205 .await
3206 .unwrap();
3207 assert!(matches!(result, ToolOutput::Diffs(v, _) if v.len() == 1));
3208 }
3209
3210 #[tokio::test]
3211 async fn test_dispatch_create_merge_request() {
3212 let provider = MockProvider;
3213 let args = serde_json::json!({
3214 "title": "New PR",
3215 "source_branch": "feature",
3216 "target_branch": "main",
3217 "draft": false
3218 });
3219 let result = dispatch_tool("create_merge_request", &args, &provider, None)
3220 .await
3221 .unwrap();
3222 assert!(matches!(result, ToolOutput::SingleMergeRequest(_)));
3223 }
3224
3225 #[tokio::test]
3226 async fn test_dispatch_create_merge_request_comment_general() {
3227 let provider = MockProvider;
3228 let args = serde_json::json!({"key": "pr#1", "body": "LGTM"});
3229 let result = dispatch_tool("create_merge_request_comment", &args, &provider, None)
3230 .await
3231 .unwrap();
3232 assert!(matches!(result, ToolOutput::Text(ref t) if t.contains("Comment added")));
3233 }
3234
3235 #[tokio::test]
3236 async fn test_dispatch_create_merge_request_comment_inline() {
3237 let provider = MockProvider;
3238 let args = serde_json::json!({
3239 "key": "pr#1",
3240 "body": "Fix this line",
3241 "file_path": "src/main.rs",
3242 "line": 42,
3243 "line_type": "new",
3244 "commit_sha": "abc123"
3245 });
3246 let result = dispatch_tool("create_merge_request_comment", &args, &provider, None)
3247 .await
3248 .unwrap();
3249 assert!(matches!(result, ToolOutput::Text(ref t) if t.contains("Comment added")));
3250 }
3251
3252 #[test]
3253 fn test_create_merge_request_comment_params_accept_camel_case() {
3254 let args = serde_json::json!({
3255 "mrKey": "mr#566",
3256 "body": "reply",
3257 "filePath": "src/main.rs",
3258 "line": 12,
3259 "lineType": "new",
3260 "commitSha": "abc123",
3261 "discussionId": "788adb16c57805c9a5d59272c944cddea381a605"
3262 });
3263
3264 let params: CreateMrCommentParams = serde_json::from_value(args).unwrap();
3265 assert_eq!(params.key, "mr#566");
3266 assert_eq!(params.file_path.as_deref(), Some("src/main.rs"));
3267 assert_eq!(params.line_type.as_deref(), Some("new"));
3268 assert_eq!(params.commit_sha.as_deref(), Some("abc123"));
3269 assert_eq!(
3270 params.discussion_id.as_deref(),
3271 Some("788adb16c57805c9a5d59272c944cddea381a605")
3272 );
3273 }
3274
3275 #[test]
3276 fn test_create_merge_request_comment_params_still_accept_snake_case() {
3277 let args = serde_json::json!({
3282 "key": "mr#566",
3283 "body": "reply",
3284 "file_path": "src/main.rs",
3285 "line": 12,
3286 "line_type": "new",
3287 "commit_sha": "abc123",
3288 "discussion_id": "788adb16c57805c9a5d59272c944cddea381a605"
3289 });
3290
3291 let params: CreateMrCommentParams = serde_json::from_value(args).unwrap();
3292 assert_eq!(params.key, "mr#566");
3293 assert_eq!(params.file_path.as_deref(), Some("src/main.rs"));
3294 assert_eq!(params.line_type.as_deref(), Some("new"));
3295 assert_eq!(params.commit_sha.as_deref(), Some("abc123"));
3296 assert_eq!(
3297 params.discussion_id.as_deref(),
3298 Some("788adb16c57805c9a5d59272c944cddea381a605")
3299 );
3300 }
3301
3302 #[tokio::test]
3303 async fn test_dispatch_create_merge_request_comment_accepts_camel_case_args() {
3304 let provider = MockProvider;
3309 let args = serde_json::json!({
3310 "mrKey": "mr#1",
3311 "body": "threaded reply",
3312 "discussionId": "abc123"
3313 });
3314 let result = dispatch_tool("create_merge_request_comment", &args, &provider, None)
3315 .await
3316 .unwrap();
3317 assert!(matches!(result, ToolOutput::Text(ref t) if t.contains("Comment added")));
3318 }
3319
3320 #[tokio::test]
3321 async fn test_dispatch_unknown_tool() {
3322 let provider = MockProvider;
3323 let result = dispatch_tool("nonexistent_tool", &Value::Null, &provider, None).await;
3324 assert!(result.is_err());
3325 }
3326
3327 #[tokio::test]
3330 async fn test_executor_enricher_transforms_args() {
3331 use devboy_core::{ToolEnricher, ToolSchema};
3332
3333 struct TestEnricher;
3334 impl ToolEnricher for TestEnricher {
3335 fn supported_categories(&self) -> &[devboy_core::ToolCategory] {
3336 &[devboy_core::ToolCategory::IssueTracker]
3337 }
3338 fn enrich_schema(&self, _tool: &str, _schema: &mut ToolSchema) {}
3339 fn transform_args(&self, _tool: &str, args: &mut Value) {
3340 if let Some(obj) = args.as_object_mut() {
3341 obj.insert("transformed".into(), Value::Bool(true));
3342 }
3343 }
3344 }
3345
3346 let mut executor = Executor::new();
3347 executor.add_enricher(Box::new(TestEnricher));
3348 assert_eq!(executor.enrichers.len(), 1);
3349 }
3350
3351 #[tokio::test]
3354 async fn test_dispatch_get_pipeline_unsupported() {
3355 let provider = MockProvider;
3356 let args = serde_json::json!({"branch": "main"});
3357 let result = dispatch_tool("get_pipeline", &args, &provider, None).await;
3358 assert!(result.is_err());
3360 }
3361
3362 #[tokio::test]
3363 async fn test_dispatch_get_job_logs_unsupported() {
3364 let provider = MockProvider;
3365 let args = serde_json::json!({"jobId": "123"});
3366 let result = dispatch_tool("get_job_logs", &args, &provider, None).await;
3367 assert!(result.is_err());
3368 }
3369
3370 #[tokio::test]
3371 async fn test_dispatch_get_pipeline_with_mr_key() {
3372 let provider = MockProvider;
3373 let args = serde_json::json!({"mrKey": "pr#1", "includeFailedLogs": false});
3374 let result = dispatch_tool("get_pipeline", &args, &provider, None).await;
3375 assert!(result.is_err());
3376 }
3377
3378 #[tokio::test]
3379 async fn test_dispatch_get_job_logs_with_pattern() {
3380 let provider = MockProvider;
3381 let args = serde_json::json!({"jobId": "123", "pattern": "ERROR", "context": 3});
3382 let result = dispatch_tool("get_job_logs", &args, &provider, None).await;
3383 assert!(result.is_err());
3384 }
3385
3386 #[tokio::test]
3387 async fn test_dispatch_get_job_logs_paginated() {
3388 let provider = MockProvider;
3389 let args = serde_json::json!({"jobId": "123", "offset": 10, "limit": 50});
3390 let result = dispatch_tool("get_job_logs", &args, &provider, None).await;
3391 assert!(result.is_err());
3392 }
3393
3394 #[tokio::test]
3395 async fn test_dispatch_get_job_logs_full() {
3396 let provider = MockProvider;
3397 let args = serde_json::json!({"jobId": "123", "full": true});
3398 let result = dispatch_tool("get_job_logs", &args, &provider, None).await;
3399 assert!(result.is_err());
3400 }
3401
3402 #[test]
3403 fn test_executor_default() {
3404 let executor = Executor::default();
3405 assert!(executor.enrichers.is_empty());
3406 }
3407
3408 #[tokio::test]
3411 async fn test_dispatch_get_available_statuses_unsupported() {
3412 let provider = MockProvider;
3413 let result = dispatch_tool("get_available_statuses", &Value::Null, &provider, None).await;
3414 assert!(result.is_err());
3416 }
3417
3418 #[tokio::test]
3419 async fn test_dispatch_get_users_unsupported() {
3420 let provider = MockProvider;
3421 let args = serde_json::json!({"search": "test"});
3422 let result = dispatch_tool("get_users", &args, &provider, None).await;
3423 assert!(result.is_err());
3425 }
3426
3427 #[tokio::test]
3428 async fn test_dispatch_link_issues_unsupported() {
3429 let provider = MockProvider;
3430 let args = serde_json::json!({
3431 "source_key": "gh#1",
3432 "target_key": "gh#2",
3433 "link_type": "blocks"
3434 });
3435 let result = dispatch_tool("link_issues", &args, &provider, None).await;
3436 assert!(result.is_err());
3437 }
3438
3439 #[tokio::test]
3440 async fn test_dispatch_get_epics() {
3441 let provider = MockProvider;
3442 let args = serde_json::json!({"state": "open", "limit": 10});
3443 let result = dispatch_tool("get_epics", &args, &provider, None)
3444 .await
3445 .unwrap();
3446 assert!(matches!(result, ToolOutput::Text(_)));
3448 }
3449
3450 #[tokio::test]
3451 async fn test_dispatch_get_epics_empty_args() {
3452 let provider = MockProvider;
3453 let result = dispatch_tool("get_epics", &Value::Null, &provider, None)
3454 .await
3455 .unwrap();
3456 assert!(matches!(result, ToolOutput::Text(_)));
3457 }
3458
3459 #[tokio::test]
3460 async fn test_dispatch_create_epic() {
3461 let provider = MockProvider;
3462 let args = serde_json::json!({"title": "New Epic", "description": "Epic description"});
3463 let result = dispatch_tool("create_epic", &args, &provider, None)
3464 .await
3465 .unwrap();
3466 assert!(matches!(result, ToolOutput::SingleIssue(_)));
3467 }
3468
3469 #[tokio::test]
3470 async fn test_dispatch_update_epic() {
3471 let provider = MockProvider;
3472 let args = serde_json::json!({"key": "gh#1", "title": "Updated Epic"});
3473 let result = dispatch_tool("update_epic", &args, &provider, None)
3474 .await
3475 .unwrap();
3476 assert!(matches!(result, ToolOutput::SingleIssue(_)));
3477 }
3478
3479 #[tokio::test]
3480 async fn test_dispatch_link_issues_missing_params() {
3481 let provider = MockProvider;
3482 let args = serde_json::json!({"source_key": "gh#1"});
3483 let result = dispatch_tool("link_issues", &args, &provider, None).await;
3484 assert!(result.is_err());
3485 }
3486
3487 struct MockMeetingProvider;
3490
3491 #[async_trait]
3492 impl MeetingNotesProvider for MockMeetingProvider {
3493 fn provider_name(&self) -> &'static str {
3494 "mock_meetings"
3495 }
3496
3497 async fn get_meetings(
3498 &self,
3499 _filter: MeetingFilter,
3500 ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::MeetingNote>> {
3501 Ok(vec![devboy_core::MeetingNote {
3502 id: "m1".into(),
3503 title: "Test Meeting".into(),
3504 ..Default::default()
3505 }]
3506 .into())
3507 }
3508
3509 async fn get_transcript(
3510 &self,
3511 meeting_id: &str,
3512 ) -> devboy_core::Result<devboy_core::MeetingTranscript> {
3513 Ok(devboy_core::MeetingTranscript {
3514 meeting_id: meeting_id.to_string(),
3515 title: Some("Test Transcript".into()),
3516 sentences: vec![devboy_core::TranscriptSentence {
3517 speaker_id: "s1".into(),
3518 speaker_name: Some("Alice".into()),
3519 text: "Hello".into(),
3520 start_time: 0.0,
3521 end_time: 1.0,
3522 }],
3523 })
3524 }
3525
3526 async fn search_meetings(
3527 &self,
3528 _query: &str,
3529 _filter: MeetingFilter,
3530 ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::MeetingNote>> {
3531 Ok(vec![devboy_core::MeetingNote {
3532 id: "m2".into(),
3533 title: "Search Result Meeting".into(),
3534 ..Default::default()
3535 }]
3536 .into())
3537 }
3538 }
3539
3540 #[tokio::test]
3541 async fn test_dispatch_get_meeting_notes() {
3542 let provider = MockMeetingProvider;
3543 let args = serde_json::json!({"from_date": "2025-01-01", "limit": 10});
3544 let result = dispatch_meeting_tool("get_meeting_notes", &args, &provider)
3545 .await
3546 .unwrap();
3547 match result {
3548 ToolOutput::MeetingNotes(meetings, _) => {
3549 assert_eq!(meetings.len(), 1);
3550 assert_eq!(meetings[0].title, "Test Meeting");
3551 }
3552 other => panic!("Expected MeetingNotes, got {:?}", other),
3553 }
3554 }
3555
3556 #[tokio::test]
3557 async fn test_dispatch_get_meeting_transcript() {
3558 let provider = MockMeetingProvider;
3559 let args = serde_json::json!({"meeting_id": "m1"});
3560 let result = dispatch_meeting_tool("get_meeting_transcript", &args, &provider)
3561 .await
3562 .unwrap();
3563 match result {
3564 ToolOutput::MeetingTranscript(transcript) => {
3565 assert_eq!(transcript.meeting_id, "m1");
3566 assert_eq!(transcript.sentences.len(), 1);
3567 assert_eq!(transcript.sentences[0].speaker_name, Some("Alice".into()));
3568 }
3569 other => panic!("Expected MeetingTranscript, got {:?}", other),
3570 }
3571 }
3572
3573 #[tokio::test]
3574 async fn test_dispatch_search_meeting_notes() {
3575 let provider = MockMeetingProvider;
3576 let args = serde_json::json!({"query": "sprint", "limit": 5});
3577 let result = dispatch_meeting_tool("search_meeting_notes", &args, &provider)
3578 .await
3579 .unwrap();
3580 match result {
3581 ToolOutput::MeetingNotes(meetings, _) => {
3582 assert_eq!(meetings.len(), 1);
3583 assert_eq!(meetings[0].title, "Search Result Meeting");
3584 }
3585 other => panic!("Expected MeetingNotes, got {:?}", other),
3586 }
3587 }
3588
3589 #[tokio::test]
3590 async fn test_dispatch_unknown_meeting_tool() {
3591 let provider = MockMeetingProvider;
3592 let result = dispatch_meeting_tool("nonexistent_tool", &Value::Null, &provider).await;
3593 assert!(result.is_err());
3594 }
3595
3596 fn sample_structure() -> devboy_core::Structure {
3601 devboy_core::Structure {
3602 id: 1,
3603 name: "Q1 Plan".into(),
3604 description: Some("Quarter 1 planning".into()),
3605 }
3606 }
3607
3608 fn sample_forest(structure_id: u64) -> devboy_core::StructureForest {
3609 devboy_core::StructureForest {
3610 version: 1,
3611 structure_id,
3612 tree: vec![devboy_core::StructureNode {
3613 row_id: 100,
3614 item_id: Some("PROJ-1".into()),
3615 item_type: Some("issue".into()),
3616 children: vec![],
3617 }],
3618 total_count: Some(1),
3619 }
3620 }
3621
3622 fn sample_view(structure_id: u64) -> devboy_core::StructureView {
3623 devboy_core::StructureView {
3624 id: 10,
3625 name: "Default".into(),
3626 structure_id,
3627 ..Default::default()
3628 }
3629 }
3630
3631 #[tokio::test]
3632 async fn test_dispatch_get_structures() {
3633 let provider = MockProvider;
3634 let result = dispatch_tool("get_structures", &Value::Null, &provider, None)
3635 .await
3636 .unwrap();
3637 assert!(matches!(result, ToolOutput::Structures(ref items, _) if items.len() == 1));
3638 assert_eq!(result.type_name(), "structures");
3639 }
3640
3641 #[tokio::test]
3642 async fn test_dispatch_get_structure_forest() {
3643 let provider = MockProvider;
3644 let args = serde_json::json!({"structureId": 1});
3645 let result = dispatch_tool("get_structure_forest", &args, &provider, None)
3646 .await
3647 .unwrap();
3648 assert!(matches!(result, ToolOutput::StructureForest(_)));
3649 assert_eq!(result.type_name(), "structure_forest");
3650 }
3651
3652 #[tokio::test]
3653 async fn test_dispatch_get_structure_forest_missing_id() {
3654 let provider = MockProvider;
3655 let result = dispatch_tool("get_structure_forest", &Value::Null, &provider, None).await;
3656 assert!(result.is_err());
3657 }
3658
3659 #[tokio::test]
3660 async fn test_dispatch_add_structure_rows() {
3661 let provider = MockProvider;
3662 let args = serde_json::json!({
3663 "structureId": 1,
3664 "items": ["PROJ-1", "PROJ-2"],
3665 "under": 100
3666 });
3667 let result = dispatch_tool("add_structure_rows", &args, &provider, None)
3668 .await
3669 .unwrap();
3670 match result {
3671 ToolOutput::ForestModified(r) => {
3672 assert_eq!(r.version, 2);
3673 assert_eq!(r.affected_count, 2);
3674 }
3675 _ => panic!("expected ForestModified"),
3676 }
3677 }
3678
3679 #[tokio::test]
3680 async fn test_dispatch_move_structure_rows() {
3681 let provider = MockProvider;
3682 let args = serde_json::json!({
3683 "structureId": 1,
3684 "rowIds": [100, 101],
3685 "under": 200
3686 });
3687 let result = dispatch_tool("move_structure_rows", &args, &provider, None)
3688 .await
3689 .unwrap();
3690 assert!(matches!(result, ToolOutput::ForestModified(_)));
3691 }
3692
3693 #[tokio::test]
3694 async fn test_dispatch_remove_structure_row() {
3695 let provider = MockProvider;
3696 let args = serde_json::json!({"structureId": 1, "rowId": 100});
3697 let result = dispatch_tool("remove_structure_row", &args, &provider, None)
3698 .await
3699 .unwrap();
3700 assert!(matches!(result, ToolOutput::Text(_)));
3701 }
3702
3703 #[tokio::test]
3704 async fn test_dispatch_get_structure_values() {
3705 let provider = MockProvider;
3706 let args = serde_json::json!({
3707 "structureId": 1,
3708 "rows": [100],
3709 "columns": ["summary", {"field": "status"}]
3710 });
3711 let result = dispatch_tool("get_structure_values", &args, &provider, None)
3712 .await
3713 .unwrap();
3714 assert!(matches!(result, ToolOutput::StructureValues(_)));
3715 }
3716
3717 #[tokio::test]
3718 async fn test_dispatch_get_structure_views() {
3719 let provider = MockProvider;
3720 let args = serde_json::json!({"structureId": 1});
3721 let result = dispatch_tool("get_structure_views", &args, &provider, None)
3722 .await
3723 .unwrap();
3724 assert!(matches!(result, ToolOutput::StructureViews(views, _) if views.len() == 1));
3725 }
3726
3727 #[tokio::test]
3728 async fn test_dispatch_save_structure_view() {
3729 let provider = MockProvider;
3730 let args = serde_json::json!({
3731 "structureId": 1,
3732 "name": "Sprint View"
3733 });
3734 let result = dispatch_tool("save_structure_view", &args, &provider, None)
3735 .await
3736 .unwrap();
3737 assert!(
3738 matches!(result, ToolOutput::StructureViews(views, _) if views[0].name == "Sprint View")
3739 );
3740 }
3741
3742 #[tokio::test]
3743 async fn test_dispatch_create_structure() {
3744 let provider = MockProvider;
3745 let args = serde_json::json!({"name": "New Structure", "description": "Test"});
3746 let result = dispatch_tool("create_structure", &args, &provider, None)
3747 .await
3748 .unwrap();
3749 match result {
3750 ToolOutput::Structures(items, _) => {
3751 assert_eq!(items[0].name, "New Structure");
3752 assert_eq!(items[0].id, 42);
3753 }
3754 _ => panic!("expected Structures"),
3755 }
3756 }
3757
3758 #[tokio::test]
3763 async fn test_dispatch_list_project_versions_applies_paper_defaults() {
3764 let provider = MockProvider;
3766 let result = dispatch_tool(
3767 "list_project_versions",
3768 &serde_json::json!({}),
3769 &provider,
3770 None,
3771 )
3772 .await
3773 .unwrap();
3774 match result {
3775 ToolOutput::ProjectVersions(items, _) => {
3776 let echoed = &items[0].name;
3777 assert!(echoed.contains("released=None"), "got {echoed}");
3778 assert!(echoed.contains("archived=Some(false)"), "got {echoed}");
3779 assert!(echoed.contains("limit=Some(20)"), "got {echoed}");
3780 assert!(echoed.contains("expand=false"), "got {echoed}");
3781 }
3782 other => panic!("expected ProjectVersions, got {other:?}"),
3783 }
3784 }
3785
3786 #[tokio::test]
3787 async fn test_dispatch_list_project_versions_explicit_filters_override_defaults() {
3788 let provider = MockProvider;
3789 let args = serde_json::json!({
3790 "project": "PROJ",
3791 "released": "true",
3792 "archived": "all",
3793 "limit": 5,
3794 "includeIssueCount": true,
3795 });
3796 let result = dispatch_tool("list_project_versions", &args, &provider, None)
3797 .await
3798 .unwrap();
3799 match result {
3800 ToolOutput::ProjectVersions(items, _) => {
3801 let echoed = &items[0].name;
3802 assert!(echoed.contains("released=Some(true)"), "got {echoed}");
3803 assert!(echoed.contains("archived=None"), "got {echoed}");
3804 assert!(echoed.contains("limit=Some(5)"), "got {echoed}");
3805 assert!(echoed.contains("expand=true"), "got {echoed}");
3806 assert_eq!(items[0].project, "PROJ");
3807 }
3808 other => panic!("expected ProjectVersions, got {other:?}"),
3809 }
3810 }
3811
3812 #[tokio::test]
3813 async fn test_dispatch_list_project_versions_rejects_unknown_filter() {
3814 let provider = MockProvider;
3815 let err = dispatch_tool(
3816 "list_project_versions",
3817 &serde_json::json!({"released": "maybe"}),
3818 &provider,
3819 None,
3820 )
3821 .await
3822 .unwrap_err();
3823 assert!(
3824 matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("'maybe'")),
3825 "expected InvalidData about 'maybe', got {err:?}"
3826 );
3827 }
3828
3829 #[tokio::test]
3830 async fn test_dispatch_upsert_project_version_returns_single() {
3831 let provider = MockProvider;
3832 let args = serde_json::json!({
3833 "project": "PROJ",
3834 "name": "3.18.0",
3835 "description": "release notes",
3836 "released": true,
3837 "releaseDate": "2026-05-01",
3838 });
3839 let result = dispatch_tool("upsert_project_version", &args, &provider, None)
3840 .await
3841 .unwrap();
3842 match result {
3843 ToolOutput::SingleProjectVersion(v) => {
3844 assert_eq!(v.name, "3.18.0");
3845 assert_eq!(v.project, "PROJ");
3846 assert!(v.released);
3847 assert_eq!(v.release_date.as_deref(), Some("2026-05-01"));
3848 assert_eq!(v.description.as_deref(), Some("release notes"));
3849 }
3850 other => panic!("expected SingleProjectVersion, got {other:?}"),
3851 }
3852 }
3853
3854 #[tokio::test]
3855 async fn test_dispatch_upsert_project_version_requires_name() {
3856 let provider = MockProvider;
3857 let err = dispatch_tool(
3858 "upsert_project_version",
3859 &serde_json::json!({"project": "PROJ"}),
3860 &provider,
3861 None,
3862 )
3863 .await
3864 .unwrap_err();
3865 assert!(matches!(err, devboy_core::Error::InvalidData(_)));
3866 }
3867
3868 #[test]
3869 fn parse_tri_filter_accepts_canonical_strings() {
3870 assert_eq!(parse_tri_filter(None).unwrap(), None);
3871 assert_eq!(parse_tri_filter(Some("all")).unwrap(), None);
3872 assert_eq!(parse_tri_filter(Some("True")).unwrap(), Some(true));
3873 assert_eq!(parse_tri_filter(Some("false")).unwrap(), Some(false));
3874 assert_eq!(parse_tri_filter(Some("yes")).unwrap(), Some(true));
3875 assert_eq!(parse_tri_filter(Some("0")).unwrap(), Some(false));
3876 assert!(parse_tri_filter(Some("maybe")).is_err());
3877 }
3878
3879 #[test]
3880 fn validate_iso_date_accepts_yyyy_mm_dd() {
3881 assert!(validate_iso_date("releaseDate", "2026-05-04").is_ok());
3882 assert!(validate_iso_date("releaseDate", "2026-12-31").is_ok());
3883 }
3884
3885 #[test]
3886 fn validate_iso_date_rejects_other_shapes() {
3887 assert!(validate_iso_date("releaseDate", "2026/05/04").is_err());
3889 assert!(validate_iso_date("releaseDate", "2026-5-4").is_err());
3890 assert!(validate_iso_date("releaseDate", "2026-05-04T00:00:00Z").is_err());
3891 assert!(validate_iso_date("releaseDate", "tomorrow").is_err());
3892 assert!(validate_iso_date("releaseDate", "2026-13-01").is_err());
3894 assert!(validate_iso_date("releaseDate", "2026-00-15").is_err());
3895 assert!(validate_iso_date("releaseDate", "2026-05-32").is_err());
3896 }
3897
3898 #[tokio::test]
3899 async fn test_dispatch_upsert_project_version_rejects_bad_date() {
3900 let provider = MockProvider;
3901 let err = dispatch_tool(
3902 "upsert_project_version",
3903 &serde_json::json!({"name": "3.18.0", "releaseDate": "next friday"}),
3904 &provider,
3905 None,
3906 )
3907 .await
3908 .unwrap_err();
3909 assert!(
3910 matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("releaseDate")),
3911 "expected InvalidData about releaseDate, got {err:?}"
3912 );
3913 }
3914
3915 #[tokio::test]
3916 async fn test_dispatch_list_project_versions_rejects_zero_limit() {
3917 let provider = MockProvider;
3918 let err = dispatch_tool(
3919 "list_project_versions",
3920 &serde_json::json!({"limit": 0}),
3921 &provider,
3922 None,
3923 )
3924 .await
3925 .unwrap_err();
3926 assert!(
3927 matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("limit")),
3928 "expected InvalidData about limit, got {err:?}"
3929 );
3930 }
3931
3932 #[test]
3937 fn parse_row_item_bare_string_becomes_item_id() {
3938 let item = parse_structure_row_item(serde_json::json!("PROJ-1")).unwrap();
3939 assert_eq!(item.item_id, "PROJ-1");
3940 assert!(item.item_type.is_none());
3941 }
3942
3943 #[test]
3944 fn parse_row_item_json_object_string_parses_fields() {
3945 let item = parse_structure_row_item(serde_json::json!(
3946 "{\"item_id\":\"PROJ-2\",\"item_type\":\"issue\"}"
3947 ))
3948 .unwrap();
3949 assert_eq!(item.item_id, "PROJ-2");
3950 assert_eq!(item.item_type.as_deref(), Some("issue"));
3951 }
3952
3953 #[test]
3954 fn parse_row_item_malformed_json_object_is_error() {
3955 let err = parse_structure_row_item(serde_json::json!("{\"wrong\":true}")).unwrap_err();
3957 assert!(matches!(err, Error::InvalidData(_)));
3958 }
3959
3960 #[test]
3961 fn parse_column_spec_bare_string_sets_field() {
3962 let col = parse_structure_column_spec(serde_json::json!("summary")).unwrap();
3963 assert_eq!(col.field.as_deref(), Some("summary"));
3964 assert!(col.formula.is_none());
3965 }
3966
3967 #[test]
3968 fn parse_column_spec_formula_json_string_parses() {
3969 let col = parse_structure_column_spec(serde_json::json!(
3970 "{\"formula\":\"SUM(\\\"Story Points\\\")\"}"
3971 ))
3972 .unwrap();
3973 assert!(col.field.is_none());
3974 assert_eq!(col.formula.as_deref(), Some("SUM(\"Story Points\")"));
3975 }
3976
3977 #[test]
3978 fn parse_column_spec_object_value_is_deserialised() {
3979 let col = parse_structure_column_spec(serde_json::json!({"field": "status", "width": 120}))
3981 .unwrap();
3982 assert_eq!(col.field.as_deref(), Some("status"));
3983 assert_eq!(col.width, Some(120));
3984 }
3985}