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