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 labels: Option<Vec<String>>,
1004 assignees: Option<Vec<String>>,
1005 #[serde(default, deserialize_with = "deserialize_string_or_number")]
1006 priority: Option<String>,
1007 #[serde(rename = "parentId")]
1008 parent_id: Option<String>,
1009 markdown: Option<bool>,
1010 #[serde(default)]
1014 components: Option<Vec<String>>,
1015 #[serde(default, rename = "fixVersions")]
1017 fix_versions: Option<Vec<String>>,
1018 #[serde(default, rename = "epicKey")]
1020 epic_key: Option<String>,
1021 #[serde(default, rename = "sprintId")]
1023 sprint_id: Option<i64>,
1024 #[serde(default, rename = "epicName")]
1026 epic_name: Option<String>,
1027}
1028
1029async fn execute_update_issue(
1030 provider: &dyn devboy_core::Provider,
1031 args: &Value,
1032) -> Result<ToolOutput> {
1033 let params: UpdateIssueParams = serde_json::from_value(args.clone())
1034 .map_err(|e| Error::InvalidData(format!("invalid update_issue params: {e}")))?;
1035 let custom_fields = args.get("customFields").cloned();
1036 let input = UpdateIssueInput {
1037 title: params.title,
1038 description: params.description,
1039 state: params.state,
1040 labels: params.labels,
1041 assignees: params.assignees,
1042 priority: params.priority,
1043 parent_id: params.parent_id,
1044 markdown: params.markdown.unwrap_or(true),
1045 custom_fields,
1046 components: params.components,
1047 fix_versions: params.fix_versions,
1048 epic_key: params.epic_key,
1049 sprint_id: params.sprint_id,
1050 epic_name: params.epic_name,
1051 };
1052 let key = params.key;
1053 let issue = provider.update_issue(&key, input).await?;
1054
1055 if let Some(cf) = args.get("customFields").and_then(|v| v.as_array())
1057 && !cf.is_empty()
1058 && let Err(e) = provider.set_custom_fields(&key, cf).await
1059 {
1060 tracing::warn!(error = %e, "Failed to set custom fields on updated issue");
1061 }
1062 Ok(ToolOutput::SingleIssue(Box::new(issue)))
1063}
1064
1065#[derive(Deserialize)]
1066struct AddCommentParams {
1067 key: String,
1068 body: String,
1069 #[serde(default)]
1070 attachments: Vec<AttachmentParam>,
1071}
1072
1073#[derive(Deserialize)]
1074struct AttachmentParam {
1075 #[serde(rename = "fileData")]
1077 file_data: String,
1078 filename: String,
1080}
1081
1082async fn execute_add_issue_comment(
1083 provider: &dyn devboy_core::Provider,
1084 args: &Value,
1085) -> Result<ToolOutput> {
1086 let params: AddCommentParams = serde_json::from_value(args.clone())
1087 .map_err(|e| Error::InvalidData(format!("invalid add_issue_comment params: {e}")))?;
1088
1089 let mut body = params.body.clone();
1090 let mut uploaded = 0;
1091 let mut upload_errors = Vec::new();
1092
1093 const MAX_ATTACHMENTS: usize = 10;
1095
1096 if params.attachments.len() > MAX_ATTACHMENTS {
1097 return Err(Error::InvalidData(format!(
1098 "Too many attachments: {} (max {})",
1099 params.attachments.len(),
1100 MAX_ATTACHMENTS
1101 )));
1102 }
1103
1104 for att in ¶ms.attachments {
1106 use base64::Engine;
1107 let data = match base64::engine::general_purpose::STANDARD.decode(&att.file_data) {
1108 Ok(d) => d,
1109 Err(e) => {
1110 upload_errors.push(format!("{}: decode error: {}", att.filename, e));
1111 continue;
1112 }
1113 };
1114
1115 if data.len() > MAX_FILE_SIZE {
1116 upload_errors.push(format!(
1117 "{}: file too large ({} bytes, max {})",
1118 att.filename,
1119 data.len(),
1120 MAX_FILE_SIZE
1121 ));
1122 continue;
1123 }
1124
1125 match provider
1126 .upload_attachment(¶ms.key, &att.filename, &data)
1127 .await
1128 {
1129 Ok(url) => {
1130 if !url.is_empty() {
1131 body.push_str(&format!("\n\n[{}]({})", att.filename, url));
1132 }
1133 uploaded += 1;
1134 }
1135 Err(e) => {
1136 upload_errors.push(format!("{}: {}", att.filename, e));
1137 }
1138 }
1139 }
1140
1141 let comment = devboy_core::IssueProvider::add_comment(provider, ¶ms.key, &body).await?;
1142
1143 let mut msg = format!("Comment added to {} (id: {})", params.key, comment.id);
1144 if uploaded > 0 {
1145 msg.push_str(&format!(", {} attachment(s) uploaded", uploaded));
1146 }
1147 if !upload_errors.is_empty() {
1148 msg.push_str(&format!(
1149 ", {} attachment error(s): {}",
1150 upload_errors.len(),
1151 upload_errors.join("; ")
1152 ));
1153 }
1154 Ok(ToolOutput::Text(msg))
1155}
1156
1157#[derive(Deserialize, Default)]
1160struct GetMergeRequestsParams {
1161 state: Option<String>,
1162 author: Option<String>,
1163 labels: Option<Vec<String>>,
1164 source_branch: Option<String>,
1165 target_branch: Option<String>,
1166 limit: Option<u32>,
1167 offset: Option<u32>,
1168 sort_by: Option<String>,
1169 sort_order: Option<String>,
1170 #[allow(dead_code)]
1172 budget: Option<usize>,
1173}
1174
1175async fn execute_get_merge_requests(
1176 provider: &dyn devboy_core::Provider,
1177 args: &Value,
1178) -> Result<ToolOutput> {
1179 let params: GetMergeRequestsParams = parse_tool_params(args, "get_merge_requests")?;
1180 let filter = MrFilter {
1181 state: params.state,
1182 source_branch: params.source_branch,
1183 target_branch: params.target_branch,
1184 author: params.author,
1185 labels: params.labels,
1186 limit: params.limit.or(Some(20)),
1187 offset: params.offset,
1188 sort_by: params.sort_by,
1189 sort_order: params.sort_order,
1190 };
1191 let result = provider.get_merge_requests(filter).await?;
1192 let meta = ResultMeta {
1193 pagination: result.pagination,
1194 sort_info: result.sort_info,
1195 };
1196 Ok(ToolOutput::MergeRequests(result.items, Some(meta)))
1197}
1198
1199async fn execute_get_merge_request(
1200 provider: &dyn devboy_core::Provider,
1201 args: &Value,
1202) -> Result<ToolOutput> {
1203 let params: KeyParam = serde_json::from_value(args.clone())
1204 .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
1205 let mr = provider.get_merge_request(¶ms.key).await?;
1206 Ok(ToolOutput::SingleMergeRequest(Box::new(mr)))
1207}
1208
1209async fn execute_get_merge_request_discussions(
1210 provider: &dyn devboy_core::Provider,
1211 args: &Value,
1212) -> Result<ToolOutput> {
1213 let params: KeyParam = serde_json::from_value(args.clone())
1214 .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
1215 let result = provider.get_discussions(¶ms.key).await?;
1216 let meta = ResultMeta {
1217 pagination: result.pagination,
1218 sort_info: result.sort_info,
1219 };
1220 Ok(ToolOutput::Discussions(result.items, Some(meta)))
1221}
1222
1223async fn execute_get_merge_request_diffs(
1224 provider: &dyn devboy_core::Provider,
1225 args: &Value,
1226) -> Result<ToolOutput> {
1227 let params: KeyParam = serde_json::from_value(args.clone())
1228 .map_err(|e| Error::InvalidData(format!("missing 'key' parameter: {e}")))?;
1229 let result = provider.get_diffs(¶ms.key).await?;
1230 let meta = ResultMeta {
1231 pagination: result.pagination,
1232 sort_info: result.sort_info,
1233 };
1234 Ok(ToolOutput::Diffs(result.items, Some(meta)))
1235}
1236
1237#[derive(Deserialize)]
1238struct CreateMergeRequestParams {
1239 title: String,
1240 description: Option<String>,
1241 source_branch: String,
1242 target_branch: String,
1243 #[serde(default)]
1244 draft: bool,
1245 #[serde(default)]
1246 labels: Vec<String>,
1247 #[serde(default)]
1248 reviewers: Vec<String>,
1249}
1250
1251async fn execute_create_merge_request(
1252 provider: &dyn devboy_core::Provider,
1253 args: &Value,
1254) -> Result<ToolOutput> {
1255 let params: CreateMergeRequestParams = serde_json::from_value(args.clone())
1256 .map_err(|e| Error::InvalidData(format!("invalid create_merge_request params: {e}")))?;
1257 let input = CreateMergeRequestInput {
1258 title: params.title,
1259 description: params.description,
1260 source_branch: params.source_branch,
1261 target_branch: params.target_branch,
1262 draft: params.draft,
1263 labels: params.labels,
1264 reviewers: params.reviewers,
1265 };
1266 let mr = provider.create_merge_request(input).await?;
1267 Ok(ToolOutput::SingleMergeRequest(Box::new(mr)))
1268}
1269
1270#[derive(Deserialize)]
1271struct CreateMrCommentParams {
1272 #[serde(alias = "mrKey")]
1273 key: String,
1274 body: String,
1275 #[serde(alias = "filePath")]
1276 file_path: Option<String>,
1277 line: Option<u32>,
1278 #[serde(alias = "lineType")]
1279 line_type: Option<String>,
1280 #[serde(alias = "commitSha")]
1281 commit_sha: Option<String>,
1282 #[serde(alias = "discussionId")]
1283 discussion_id: Option<String>,
1284}
1285
1286async fn execute_create_merge_request_comment(
1287 provider: &dyn devboy_core::Provider,
1288 args: &Value,
1289) -> Result<ToolOutput> {
1290 let params: CreateMrCommentParams = serde_json::from_value(args.clone()).map_err(|e| {
1291 Error::InvalidData(format!("invalid create_merge_request_comment params: {e}"))
1292 })?;
1293
1294 let position = params.file_path.map(|fp| devboy_core::CodePosition {
1295 file_path: fp,
1296 line: params.line.unwrap_or(1),
1297 line_type: params.line_type.unwrap_or_else(|| "new".into()),
1298 commit_sha: params.commit_sha,
1299 });
1300
1301 let input = CreateCommentInput {
1302 body: params.body,
1303 position,
1304 discussion_id: params.discussion_id,
1305 };
1306
1307 let comment = MergeRequestProvider::add_comment(provider, ¶ms.key, input).await?;
1308 Ok(ToolOutput::Text(format!(
1309 "Comment added to {} (id: {})",
1310 params.key, comment.id
1311 )))
1312}
1313
1314#[derive(Deserialize, Default)]
1317struct GetPipelineParams {
1318 branch: Option<String>,
1319 #[serde(rename = "mrKey")]
1320 mr_key: Option<String>,
1321 #[serde(rename = "includeFailedLogs")]
1322 include_failed_logs: Option<bool>,
1323}
1324
1325async fn execute_get_pipeline(
1326 provider: &dyn devboy_core::Provider,
1327 args: &Value,
1328) -> Result<ToolOutput> {
1329 let params: GetPipelineParams = parse_tool_params(args, "get_pipeline")?;
1330 let input = GetPipelineInput {
1331 branch: params.branch,
1332 mr_key: params.mr_key,
1333 include_failed_logs: params.include_failed_logs.unwrap_or(true),
1334 };
1335 let pipeline = PipelineProvider::get_pipeline(provider, input).await?;
1336 Ok(ToolOutput::Pipeline(Box::new(pipeline)))
1337}
1338
1339#[derive(Deserialize)]
1340struct GetJobLogsParams {
1341 #[serde(rename = "jobId")]
1342 job_id: String,
1343 pattern: Option<String>,
1344 context: Option<usize>,
1345 #[serde(rename = "maxMatches")]
1346 max_matches: Option<usize>,
1347 offset: Option<usize>,
1348 limit: Option<usize>,
1349 full: Option<bool>,
1350}
1351
1352async fn execute_get_job_logs(
1353 provider: &dyn devboy_core::Provider,
1354 args: &Value,
1355) -> Result<ToolOutput> {
1356 let params: GetJobLogsParams = serde_json::from_value(args.clone())
1357 .map_err(|e| Error::InvalidData(format!("invalid get_job_logs params: {e}")))?;
1358
1359 let clamped_limit = params.limit.map(|l| l.min(1000));
1361
1362 let mode = if let Some(pattern) = params.pattern {
1363 JobLogMode::Search {
1364 pattern,
1365 context: params.context.unwrap_or(5).min(50),
1366 max_matches: params.max_matches.unwrap_or(20).min(100),
1367 }
1368 } else if let Some(true) = params.full {
1369 JobLogMode::Full {
1370 max_lines: clamped_limit.unwrap_or(1000),
1371 }
1372 } else if params.offset.is_some() || clamped_limit.is_some() {
1373 JobLogMode::Paginated {
1374 offset: params.offset.unwrap_or(0),
1375 limit: clamped_limit.unwrap_or(200),
1376 }
1377 } else {
1378 JobLogMode::Smart
1379 };
1380
1381 let options = JobLogOptions { mode };
1382 let log_output = PipelineProvider::get_job_logs(provider, ¶ms.job_id, options).await?;
1383 Ok(ToolOutput::JobLog(Box::new(log_output)))
1384}
1385
1386async fn execute_get_available_statuses(
1389 provider: &dyn devboy_core::Provider,
1390) -> Result<ToolOutput> {
1391 let result = IssueProvider::get_statuses(provider).await?;
1392 let meta = ResultMeta {
1393 pagination: result.pagination,
1394 sort_info: result.sort_info,
1395 };
1396 Ok(ToolOutput::Statuses(result.items, Some(meta)))
1397}
1398
1399#[derive(Deserialize, Default)]
1400struct GetUsersParams {
1401 user_id: Option<String>,
1402 project_key: Option<String>,
1403 search: Option<String>,
1404 include_inactive: Option<bool>,
1405 start_at: Option<u32>,
1406 max_results: Option<u32>,
1407}
1408
1409async fn execute_get_users(
1410 provider: &dyn devboy_core::Provider,
1411 args: &Value,
1412) -> Result<ToolOutput> {
1413 let params: GetUsersParams = parse_tool_params(args, "get_users")?;
1414 let options = GetUsersOptions {
1415 user_id: params.user_id,
1416 project_key: params.project_key,
1417 search: params.search,
1418 include_inactive: params.include_inactive,
1419 start_at: params.start_at,
1420 max_results: params.max_results,
1421 };
1422 let result = IssueProvider::get_users(provider, options).await?;
1423 let meta = ResultMeta {
1424 pagination: result.pagination,
1425 sort_info: result.sort_info,
1426 };
1427 Ok(ToolOutput::Users(result.items, Some(meta)))
1428}
1429
1430#[derive(Deserialize)]
1431struct LinkIssuesParams {
1432 #[serde(alias = "sourceIssueKey", alias = "issueKey1")]
1433 source_key: String,
1434 #[serde(alias = "targetIssueKey", alias = "issueKey2")]
1435 target_key: String,
1436 #[serde(alias = "linkType")]
1437 link_type: String,
1438}
1439
1440async fn execute_link_issues(
1441 provider: &dyn devboy_core::Provider,
1442 args: &Value,
1443) -> Result<ToolOutput> {
1444 let params: LinkIssuesParams = serde_json::from_value(args.clone())
1445 .map_err(|e| Error::InvalidData(format!("invalid link_issues params: {e}")))?;
1446 IssueProvider::link_issues(
1447 provider,
1448 ¶ms.source_key,
1449 ¶ms.target_key,
1450 ¶ms.link_type,
1451 )
1452 .await?;
1453 Ok(ToolOutput::Text(format!(
1454 "Linked {} -> {} (type: {})",
1455 params.source_key, params.target_key, params.link_type
1456 )))
1457}
1458
1459async fn execute_unlink_issues(
1460 provider: &dyn devboy_core::Provider,
1461 args: &Value,
1462) -> Result<ToolOutput> {
1463 let params: LinkIssuesParams = serde_json::from_value(args.clone())
1464 .map_err(|e| Error::InvalidData(format!("invalid unlink_issues params: {e}")))?;
1465 IssueProvider::unlink_issues(
1466 provider,
1467 ¶ms.source_key,
1468 ¶ms.target_key,
1469 ¶ms.link_type,
1470 )
1471 .await?;
1472 Ok(ToolOutput::Text(format!(
1473 "Unlinked {} -> {} (type: {})",
1474 params.source_key, params.target_key, params.link_type
1475 )))
1476}
1477
1478#[derive(Deserialize, Default)]
1481struct GetEpicsParams {
1482 state: Option<String>,
1483 search: Option<String>,
1484 assignee: Option<String>,
1485 #[serde(rename = "goalId")]
1486 goal_id: Option<String>,
1487 limit: Option<u32>,
1488 offset: Option<u32>,
1489}
1490
1491fn extract_goal_id(labels: &[String]) -> Option<String> {
1493 labels.iter().find_map(|l| {
1494 let lower = l.to_lowercase();
1495 if lower.len() == 2
1496 && lower.starts_with('g')
1497 && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1498 {
1499 Some(lower.to_uppercase())
1500 } else {
1501 None
1502 }
1503 })
1504}
1505
1506fn epic_progress(subtasks: &[devboy_core::Issue]) -> serde_json::Value {
1508 let total = subtasks.len();
1509 let completed = subtasks.iter().filter(|s| s.state == "closed").count();
1510 let percentage = if total > 0 {
1511 (completed as f64 / total as f64 * 100.0).round() as u32
1512 } else {
1513 0
1514 };
1515 serde_json::json!({
1516 "total_subtasks": total,
1517 "completed_subtasks": completed,
1518 "percentage": percentage,
1519 })
1520}
1521
1522async fn execute_get_epics(
1523 provider: &dyn devboy_core::Provider,
1524 args: &Value,
1525) -> Result<ToolOutput> {
1526 let params: GetEpicsParams = parse_tool_params(args, "get_epics")?;
1527 let filter = IssueFilter {
1528 state: params.state,
1529 state_category: None,
1530 search: params.search,
1531 labels: Some(vec!["epic".to_string()]),
1532 labels_operator: None,
1533 assignee: params.assignee,
1534 limit: params.limit.or(Some(50)),
1535 offset: params.offset,
1536 sort_by: None,
1537 sort_order: None,
1538 project_key: None,
1539 native_query: None,
1540 };
1541 let result = provider.get_issues(filter).await?;
1542 let mut epics = result.items;
1543
1544 if let Some(ref goal) = params.goal_id {
1546 let goal_lower = goal.to_lowercase();
1547 epics.retain(|e| e.labels.iter().any(|l| l.to_lowercase() == goal_lower));
1548 }
1549
1550 let enriched: Vec<serde_json::Value> = epics
1552 .iter()
1553 .map(|epic| {
1554 let mut v = serde_json::to_value(epic).unwrap_or_default();
1555 v["goal_id"] = serde_json::json!(extract_goal_id(&epic.labels));
1556 v["progress"] = epic_progress(&epic.subtasks);
1557 v
1558 })
1559 .collect();
1560
1561 Ok(ToolOutput::Text(
1562 serde_json::to_string_pretty(&enriched).unwrap_or_default(),
1563 ))
1564}
1565
1566#[derive(Deserialize)]
1567struct CreateEpicParams {
1568 title: String,
1569 description: Option<String>,
1570 #[serde(rename = "goalId")]
1571 goal_id: Option<String>,
1572 #[serde(default)]
1573 labels: Vec<String>,
1574 #[serde(default)]
1575 assignees: Vec<String>,
1576 #[serde(default, deserialize_with = "deserialize_string_or_number")]
1577 priority: Option<String>,
1578 markdown: Option<bool>,
1579}
1580
1581async fn execute_create_epic(
1582 provider: &dyn devboy_core::Provider,
1583 args: &Value,
1584) -> Result<ToolOutput> {
1585 let params: CreateEpicParams = serde_json::from_value(args.clone())
1586 .map_err(|e| Error::InvalidData(format!("invalid create_epic params: {e}")))?;
1587
1588 let mut labels = params.labels;
1590 if !labels.iter().any(|l| l.eq_ignore_ascii_case("epic")) {
1591 labels.push("epic".to_string());
1592 }
1593
1594 if let Some(ref goal) = params.goal_id {
1596 let goal_tag = goal.to_lowercase();
1597 if !labels.iter().any(|l| l.to_lowercase() == goal_tag) {
1598 labels.push(goal_tag);
1599 }
1600 }
1601
1602 let input = CreateIssueInput {
1603 title: params.title,
1604 description: params.description,
1605 labels,
1606 assignees: params.assignees,
1607 priority: params.priority,
1608 parent: None,
1609 markdown: params.markdown.unwrap_or(true),
1610 project_id: None,
1611 issue_type: None,
1612 custom_fields: args.get("customFields").cloned(),
1613 components: Vec::new(),
1614 fix_versions: Vec::new(),
1615 epic_key: None,
1616 sprint_id: None,
1617 epic_name: None,
1618 };
1619 let issue = provider.create_issue(input).await?;
1620
1621 if let Some(cf) = args.get("customFields").and_then(|v| v.as_array())
1623 && !cf.is_empty()
1624 && let Err(e) = provider.set_custom_fields(&issue.key, cf).await
1625 {
1626 tracing::warn!(error = %e, "Failed to set custom fields on created epic");
1627 }
1628
1629 Ok(ToolOutput::SingleIssue(Box::new(issue)))
1630}
1631
1632#[derive(Deserialize)]
1633struct UpdateEpicParams {
1634 #[serde(alias = "epicKey")]
1635 key: String,
1636 title: Option<String>,
1637 description: Option<String>,
1638 state: Option<String>,
1639 #[serde(rename = "goalId")]
1640 goal_id: Option<String>,
1641 labels: Option<Vec<String>>,
1642 assignees: Option<Vec<String>>,
1643 #[serde(default, deserialize_with = "deserialize_string_or_number")]
1644 priority: Option<String>,
1645 markdown: Option<bool>,
1646}
1647
1648async fn execute_update_epic(
1649 provider: &dyn devboy_core::Provider,
1650 args: &Value,
1651) -> Result<ToolOutput> {
1652 let params: UpdateEpicParams = serde_json::from_value(args.clone())
1653 .map_err(|e| Error::InvalidData(format!("invalid update_epic params: {e}")))?;
1654
1655 let labels = if let Some(ref new_goal) = params.goal_id {
1657 let current = provider.get_issue(¶ms.key).await?;
1659 let mut labels: Vec<String> = current
1660 .labels
1661 .iter()
1662 .filter(|l| {
1664 let lower = l.to_lowercase();
1665 !(lower.len() == 2
1666 && lower.starts_with('g')
1667 && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit()))
1668 })
1669 .cloned()
1670 .collect();
1671
1672 let goal_tag = new_goal.to_lowercase();
1674 if !labels.iter().any(|l| l.to_lowercase() == goal_tag) {
1675 labels.push(goal_tag);
1676 }
1677
1678 if let Some(extra) = params.labels {
1680 for l in extra {
1681 if !labels
1682 .iter()
1683 .any(|existing| existing.eq_ignore_ascii_case(&l))
1684 {
1685 labels.push(l);
1686 }
1687 }
1688 }
1689 Some(labels)
1690 } else {
1691 params.labels
1692 };
1693
1694 let input = UpdateIssueInput {
1695 title: params.title,
1696 description: params.description,
1697 state: params.state,
1698 labels,
1699 assignees: params.assignees,
1700 priority: params.priority,
1701 parent_id: None,
1702 markdown: params.markdown.unwrap_or(true),
1703 custom_fields: args.get("customFields").cloned(),
1704 components: None,
1705 fix_versions: None,
1706 epic_key: None,
1707 sprint_id: None,
1708 epic_name: None,
1709 };
1710 let key = params.key;
1711 let issue = provider.update_issue(&key, input).await?;
1712
1713 if let Some(cf) = args.get("customFields").and_then(|v| v.as_array())
1715 && !cf.is_empty()
1716 && let Err(e) = provider.set_custom_fields(&key, cf).await
1717 {
1718 tracing::warn!(error = %e, "Failed to set custom fields on updated epic");
1719 }
1720
1721 Ok(ToolOutput::SingleIssue(Box::new(issue)))
1722}
1723
1724pub const SUPPORTED_TOOLS: &[&str] = &[
1726 "get_issues",
1727 "get_issue",
1728 "get_issue_comments",
1729 "get_issue_relations",
1730 "create_issue",
1731 "update_issue",
1732 "add_issue_comment",
1733 "get_merge_requests",
1734 "get_merge_request",
1735 "get_merge_request_discussions",
1736 "get_merge_request_diffs",
1737 "create_merge_request",
1738 "create_merge_request_comment",
1739 "update_merge_request",
1740 "get_pipeline",
1741 "get_job_logs",
1742 "get_available_statuses",
1743 "get_users",
1744 "link_issues",
1745 "unlink_issues",
1746 "get_epics",
1747 "create_epic",
1748 "update_epic",
1749 "get_meeting_notes",
1750 "get_meeting_transcript",
1751 "search_meeting_notes",
1752 "get_knowledge_base_spaces",
1754 "list_knowledge_base_pages",
1755 "get_knowledge_base_page",
1756 "create_knowledge_base_page",
1757 "update_knowledge_base_page",
1758 "search_knowledge_base",
1759 "get_messenger_chats",
1761 "get_chat_messages",
1762 "search_chat_messages",
1763 "send_message",
1764 "get_assets",
1766 "upload_asset",
1767 "download_asset",
1768 "delete_asset",
1769];
1770
1771#[derive(Deserialize)]
1776struct UpdateMergeRequestParams {
1777 key: String,
1778 #[serde(default)]
1779 title: Option<String>,
1780 #[serde(default)]
1781 description: Option<String>,
1782 #[serde(default)]
1783 state: Option<String>,
1784 #[serde(default)]
1785 labels: Option<Vec<String>>,
1786 #[serde(default)]
1787 draft: Option<bool>,
1788}
1789
1790async fn execute_update_merge_request(
1791 provider: &dyn devboy_core::Provider,
1792 args: &Value,
1793) -> Result<ToolOutput> {
1794 let params: UpdateMergeRequestParams = serde_json::from_value(args.clone())?;
1795 debug!(key = %params.key, "update_merge_request");
1796
1797 let input = devboy_core::UpdateMergeRequestInput {
1798 title: params.title,
1799 description: params.description,
1800 state: params.state,
1801 labels: params.labels,
1802 draft: params.draft,
1803 };
1804
1805 let mr = MergeRequestProvider::update_merge_request(provider, ¶ms.key, input).await?;
1806 Ok(ToolOutput::SingleMergeRequest(Box::new(mr)))
1807}
1808
1809#[derive(Deserialize)]
1814struct GetAssetsParams {
1815 context_type: String,
1817 key: String,
1819}
1820
1821async fn execute_get_assets(
1822 provider: &dyn devboy_core::Provider,
1823 args: &Value,
1824) -> Result<ToolOutput> {
1825 let params: GetAssetsParams = serde_json::from_value(args.clone())?;
1826 debug!(context_type = %params.context_type, key = %params.key, "get_assets");
1827
1828 let assets = match params.context_type.as_str() {
1829 "issue" => IssueProvider::get_issue_attachments(provider, ¶ms.key).await?,
1830 "mr" | "merge_request" | "pull_request" => {
1831 MergeRequestProvider::get_mr_attachments(provider, ¶ms.key).await?
1832 }
1833 other => {
1834 return Err(Error::InvalidData(format!(
1835 "unsupported context_type: '{other}', expected 'issue' or 'mr'"
1836 )));
1837 }
1838 };
1839
1840 let capabilities =
1841 serde_json::to_value(IssueProvider::asset_capabilities(provider)).unwrap_or_default();
1842 let count = assets.len();
1843 let attachments: Vec<serde_json::Value> = assets
1844 .into_iter()
1845 .map(|a| serde_json::to_value(a).unwrap_or_default())
1846 .collect();
1847 Ok(ToolOutput::AssetList {
1848 attachments,
1849 count,
1850 capabilities,
1851 })
1852}
1853
1854#[derive(Deserialize)]
1855struct UploadAssetParams {
1856 context_type: String,
1858 key: String,
1859 filename: String,
1860 #[serde(rename = "fileData")]
1862 file_data: String,
1863}
1864
1865async fn execute_upload_asset(
1866 provider: &dyn devboy_core::Provider,
1867 args: &Value,
1868) -> Result<ToolOutput> {
1869 let params: UploadAssetParams = serde_json::from_value(args.clone())?;
1870 debug!(context_type = %params.context_type, key = %params.key, filename = %params.filename, "upload_asset");
1871
1872 let data = base64_decode(¶ms.file_data)?;
1873
1874 if data.len() > MAX_FILE_SIZE {
1875 return Err(Error::InvalidData(format!(
1876 "file '{}' is {} bytes, max allowed is {} bytes",
1877 params.filename,
1878 data.len(),
1879 MAX_FILE_SIZE,
1880 )));
1881 }
1882
1883 let size = data.len();
1884 let url = match params.context_type.as_str() {
1885 "issue" => {
1886 IssueProvider::upload_attachment(provider, ¶ms.key, ¶ms.filename, &data).await?
1887 }
1888 other => {
1889 return Err(Error::InvalidData(format!(
1890 "upload not supported for context_type: '{other}', use 'issue'"
1891 )));
1892 }
1893 };
1894
1895 Ok(ToolOutput::AssetUploaded {
1896 url,
1897 filename: params.filename,
1898 size,
1899 })
1900}
1901
1902#[derive(Deserialize)]
1903struct DownloadAssetParams {
1904 context_type: String,
1906 key: String,
1907 asset_id: String,
1909}
1910
1911async fn execute_download_asset(
1912 provider: &dyn devboy_core::Provider,
1913 args: &Value,
1914 asset_manager: Option<&devboy_assets::AssetManager>,
1915) -> Result<ToolOutput> {
1916 let params: DownloadAssetParams = serde_json::from_value(args.clone())?;
1917 debug!(context_type = %params.context_type, key = %params.key, asset_id = %params.asset_id, "download_asset");
1918
1919 if let Some(mgr) = asset_manager
1921 && let Ok(Some(resolved)) = mgr.get(¶ms.asset_id)
1922 {
1923 return Ok(ToolOutput::AssetDownloaded {
1924 asset_id: params.asset_id,
1925 size: resolved.asset.size as usize,
1926 local_path: Some(resolved.absolute_path.to_string_lossy().into_owned()),
1927 data: None,
1928 cached: true,
1929 });
1930 }
1931
1932 let bytes = match params.context_type.as_str() {
1934 "issue" => {
1935 IssueProvider::download_attachment(provider, ¶ms.key, ¶ms.asset_id).await?
1936 }
1937 "mr" | "merge_request" | "pull_request" => {
1938 MergeRequestProvider::download_mr_attachment(provider, ¶ms.key, ¶ms.asset_id)
1939 .await?
1940 }
1941 other => {
1942 return Err(Error::InvalidData(format!(
1943 "unsupported context_type: '{other}', expected 'issue' or 'mr'"
1944 )));
1945 }
1946 };
1947
1948 if let Some(mgr) = asset_manager {
1950 let context = match params.context_type.as_str() {
1951 "mr" | "merge_request" | "pull_request" => devboy_core::AssetContext::MergeRequest {
1952 mr_id: params.key.clone(),
1953 },
1954 _ => devboy_core::AssetContext::Issue {
1955 key: params.key.clone(),
1956 },
1957 };
1958 let filename = devboy_core::filename_from_url(¶ms.asset_id);
1959 match mgr.store(devboy_assets::StoreRequest {
1960 context,
1961 asset_id: Some(¶ms.asset_id),
1962 filename: &filename,
1963 mime_type: None,
1964 remote_url: None,
1965 data: &bytes,
1966 }) {
1967 Ok(cached) => {
1968 let abs = mgr.cache_dir().join(&cached.local_path);
1969 return Ok(ToolOutput::AssetDownloaded {
1970 asset_id: cached.id,
1971 size: cached.size as usize,
1972 local_path: Some(abs.to_string_lossy().into_owned()),
1973 data: None,
1974 cached: true,
1975 });
1976 }
1977 Err(e) => {
1978 tracing::warn!(?e, "failed to cache asset, returning base64 fallback");
1979 }
1980 }
1981 }
1982
1983 if bytes.len() > MAX_FILE_SIZE {
1985 return Err(Error::InvalidData(format!(
1986 "downloaded attachment is {} bytes, max allowed for base64 response is {} bytes",
1987 bytes.len(),
1988 MAX_FILE_SIZE,
1989 )));
1990 }
1991
1992 let encoded = base64_encode(&bytes);
1993 Ok(ToolOutput::AssetDownloaded {
1994 asset_id: params.asset_id,
1995 size: bytes.len(),
1996 local_path: None,
1997 data: Some(encoded),
1998 cached: false,
1999 })
2000}
2001
2002#[derive(Deserialize)]
2003struct DeleteAssetParams {
2004 key: String,
2005 asset_id: String,
2006}
2007
2008async fn execute_delete_asset(
2009 provider: &dyn devboy_core::Provider,
2010 args: &Value,
2011 asset_manager: Option<&devboy_assets::AssetManager>,
2012) -> Result<ToolOutput> {
2013 let params: DeleteAssetParams = serde_json::from_value(args.clone())?;
2014 debug!(key = %params.key, asset_id = %params.asset_id, "delete_asset");
2015
2016 IssueProvider::delete_attachment(provider, ¶ms.key, ¶ms.asset_id).await?;
2017
2018 if let Some(mgr) = asset_manager
2020 && let Err(e) = mgr.delete(¶ms.asset_id)
2021 {
2022 tracing::warn!(?e, asset_id = %params.asset_id, "failed to evict deleted asset from cache");
2023 }
2024
2025 let message = format!(
2026 "Attachment '{}' deleted from {}",
2027 params.asset_id, params.key
2028 );
2029 Ok(ToolOutput::AssetDeleted {
2030 asset_id: params.asset_id,
2031 message,
2032 })
2033}
2034
2035const MAX_BASE64_LEN: usize = (MAX_FILE_SIZE / 3 + 1) * 4 + 4;
2037
2038fn base64_decode(input: &str) -> Result<Vec<u8>> {
2041 let trimmed = input.trim();
2042 if trimmed.len() > MAX_BASE64_LEN {
2043 return Err(Error::InvalidData(format!(
2044 "base64 input too large ({} chars), max decoded size is {} bytes",
2045 trimmed.len(),
2046 MAX_FILE_SIZE,
2047 )));
2048 }
2049 use base64::Engine;
2050 base64::engine::general_purpose::STANDARD
2051 .decode(trimmed)
2052 .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(trimmed))
2053 .map_err(|e| Error::InvalidData(format!("invalid base64: {e}")))
2054}
2055
2056fn base64_encode(data: &[u8]) -> String {
2058 use base64::Engine;
2059 base64::engine::general_purpose::STANDARD.encode(data)
2060}
2061
2062async fn execute_get_structures(provider: &dyn devboy_core::Provider) -> Result<ToolOutput> {
2067 let result = provider.get_structures().await?;
2068 let meta = ResultMeta {
2069 pagination: result.pagination,
2070 sort_info: result.sort_info,
2071 };
2072 Ok(ToolOutput::Structures(result.items, Some(meta)))
2073}
2074
2075#[derive(Deserialize)]
2076#[serde(rename_all = "camelCase")]
2077struct GetStructureForestParams {
2078 structure_id: u64,
2079 offset: Option<u64>,
2080 limit: Option<u64>,
2081}
2082
2083async fn execute_get_structure_forest(
2084 provider: &dyn devboy_core::Provider,
2085 args: &Value,
2086) -> Result<ToolOutput> {
2087 let params: GetStructureForestParams = serde_json::from_value(args.clone())
2088 .map_err(|e| Error::InvalidData(format!("missing 'structureId': {e}")))?;
2089 let forest = provider
2090 .get_structure_forest(
2091 params.structure_id,
2092 GetForestOptions {
2093 offset: params.offset,
2094 limit: Some(params.limit.unwrap_or(200)),
2095 },
2096 )
2097 .await?;
2098 Ok(ToolOutput::StructureForest(Box::new(forest)))
2099}
2100
2101#[derive(Deserialize)]
2102#[serde(rename_all = "camelCase")]
2103struct AddStructureRowsParams {
2104 structure_id: u64,
2105 items: Vec<Value>,
2106 under: Option<u64>,
2107 after: Option<u64>,
2108 forest_version: Option<u64>,
2109}
2110
2111fn parse_structure_row_item(v: Value) -> Result<StructureRowItem> {
2123 if let Some(s) = v.as_str() {
2124 if let Ok(parsed) = serde_json::from_str::<Value>(s)
2125 && parsed.is_object()
2126 {
2127 return serde_json::from_value(parsed)
2128 .map_err(|e| Error::InvalidData(format!("invalid structure row item JSON: {e}")));
2129 }
2130 return Ok(StructureRowItem {
2131 item_id: s.to_string(),
2132 item_type: None,
2133 });
2134 }
2135 serde_json::from_value(v)
2136 .map_err(|e| Error::InvalidData(format!("invalid structure row item: {e}")))
2137}
2138
2139fn parse_structure_column_spec(v: Value) -> Result<StructureViewColumn> {
2145 if let Some(s) = v.as_str() {
2146 if let Ok(parsed) = serde_json::from_str::<Value>(s)
2147 && parsed.is_object()
2148 {
2149 return serde_json::from_value(parsed).map_err(|e| {
2150 Error::InvalidData(format!("invalid structure column spec JSON: {e}"))
2151 });
2152 }
2153 return Ok(StructureViewColumn {
2154 field: Some(s.to_string()),
2155 ..Default::default()
2156 });
2157 }
2158 serde_json::from_value(v)
2159 .map_err(|e| Error::InvalidData(format!("invalid structure column spec: {e}")))
2160}
2161
2162async fn execute_add_structure_rows(
2163 provider: &dyn devboy_core::Provider,
2164 args: &Value,
2165) -> Result<ToolOutput> {
2166 let params: AddStructureRowsParams = serde_json::from_value(args.clone())
2167 .map_err(|e| Error::InvalidData(format!("invalid add_structure_rows params: {e}")))?;
2168
2169 let items: Vec<StructureRowItem> = params
2170 .items
2171 .into_iter()
2172 .map(parse_structure_row_item)
2173 .collect::<Result<Vec<_>>>()?;
2174
2175 let result = provider
2176 .add_structure_rows(
2177 params.structure_id,
2178 AddStructureRowsInput {
2179 items,
2180 under: params.under,
2181 after: params.after,
2182 forest_version: params.forest_version,
2183 },
2184 )
2185 .await?;
2186 Ok(ToolOutput::ForestModified(result))
2187}
2188
2189#[derive(Deserialize)]
2190#[serde(rename_all = "camelCase")]
2191struct MoveStructureRowsParams {
2192 structure_id: u64,
2193 row_ids: Vec<u64>,
2194 under: Option<u64>,
2195 after: Option<u64>,
2196 forest_version: Option<u64>,
2197}
2198
2199async fn execute_move_structure_rows(
2200 provider: &dyn devboy_core::Provider,
2201 args: &Value,
2202) -> Result<ToolOutput> {
2203 let params: MoveStructureRowsParams = serde_json::from_value(args.clone())
2204 .map_err(|e| Error::InvalidData(format!("invalid move_structure_rows params: {e}")))?;
2205 let result = provider
2206 .move_structure_rows(
2207 params.structure_id,
2208 MoveStructureRowsInput {
2209 row_ids: params.row_ids,
2210 under: params.under,
2211 after: params.after,
2212 forest_version: params.forest_version,
2213 },
2214 )
2215 .await?;
2216 Ok(ToolOutput::ForestModified(result))
2217}
2218
2219#[derive(Deserialize)]
2220#[serde(rename_all = "camelCase")]
2221struct RemoveStructureRowParams {
2222 structure_id: u64,
2223 row_id: u64,
2224}
2225
2226async fn execute_remove_structure_row(
2227 provider: &dyn devboy_core::Provider,
2228 args: &Value,
2229) -> Result<ToolOutput> {
2230 let params: RemoveStructureRowParams = serde_json::from_value(args.clone())
2231 .map_err(|e| Error::InvalidData(format!("invalid remove_structure_row params: {e}")))?;
2232 provider
2233 .remove_structure_row(params.structure_id, params.row_id)
2234 .await?;
2235 Ok(ToolOutput::Text(format!(
2236 "Row {} removed from structure {}",
2237 params.row_id, params.structure_id
2238 )))
2239}
2240
2241#[derive(Deserialize)]
2242#[serde(rename_all = "camelCase")]
2243struct GetStructureValuesParams {
2244 structure_id: u64,
2245 rows: Vec<u64>,
2246 columns: Vec<Value>,
2247}
2248
2249async fn execute_get_structure_values(
2250 provider: &dyn devboy_core::Provider,
2251 args: &Value,
2252) -> Result<ToolOutput> {
2253 let params: GetStructureValuesParams = serde_json::from_value(args.clone())
2254 .map_err(|e| Error::InvalidData(format!("invalid get_structure_values params: {e}")))?;
2255
2256 let columns: Vec<StructureViewColumn> = params
2257 .columns
2258 .into_iter()
2259 .map(parse_structure_column_spec)
2260 .collect::<Result<Vec<_>>>()?;
2261
2262 let result = provider
2263 .get_structure_values(GetStructureValuesInput {
2264 structure_id: params.structure_id,
2265 rows: params.rows,
2266 columns,
2267 })
2268 .await?;
2269 Ok(ToolOutput::StructureValues(Box::new(result)))
2270}
2271
2272#[derive(Deserialize)]
2273#[serde(rename_all = "camelCase")]
2274struct GetStructureViewsParams {
2275 structure_id: u64,
2276 view_id: Option<u64>,
2277}
2278
2279async fn execute_get_structure_views(
2280 provider: &dyn devboy_core::Provider,
2281 args: &Value,
2282) -> Result<ToolOutput> {
2283 let params: GetStructureViewsParams = serde_json::from_value(args.clone())
2284 .map_err(|e| Error::InvalidData(format!("invalid get_structure_views params: {e}")))?;
2285 let views = provider
2286 .get_structure_views(params.structure_id, params.view_id)
2287 .await?;
2288 Ok(ToolOutput::StructureViews(views, None))
2289}
2290
2291#[derive(Deserialize)]
2292#[serde(rename_all = "camelCase")]
2293struct SaveStructureViewParams {
2294 id: Option<u64>,
2295 structure_id: u64,
2296 name: String,
2297 columns: Option<Vec<Value>>,
2298 group_by: Option<String>,
2299 sort_by: Option<String>,
2300 filter: Option<String>,
2301}
2302
2303async fn execute_save_structure_view(
2304 provider: &dyn devboy_core::Provider,
2305 args: &Value,
2306) -> Result<ToolOutput> {
2307 let params: SaveStructureViewParams = serde_json::from_value(args.clone())
2308 .map_err(|e| Error::InvalidData(format!("invalid save_structure_view params: {e}")))?;
2309
2310 let columns: Option<Vec<StructureViewColumn>> = params
2311 .columns
2312 .map(|cols| {
2313 cols.into_iter()
2314 .map(parse_structure_column_spec)
2315 .collect::<Result<Vec<_>>>()
2316 })
2317 .transpose()?;
2318
2319 let view = provider
2320 .save_structure_view(SaveStructureViewInput {
2321 id: params.id,
2322 structure_id: params.structure_id,
2323 name: params.name,
2324 columns,
2325 group_by: params.group_by,
2326 sort_by: params.sort_by,
2327 filter: params.filter,
2328 })
2329 .await?;
2330 Ok(ToolOutput::StructureViews(vec![view], None))
2331}
2332
2333#[derive(Deserialize)]
2334struct CreateStructureParams {
2335 name: String,
2336 description: Option<String>,
2337}
2338
2339async fn execute_create_structure(
2340 provider: &dyn devboy_core::Provider,
2341 args: &Value,
2342) -> Result<ToolOutput> {
2343 let params: CreateStructureParams = serde_json::from_value(args.clone())
2344 .map_err(|e| Error::InvalidData(format!("missing 'name': {e}")))?;
2345 let structure = provider
2346 .create_structure(CreateStructureInput {
2347 name: params.name,
2348 description: params.description,
2349 })
2350 .await?;
2351 Ok(ToolOutput::Structures(vec![structure], None))
2352}
2353
2354fn parse_tri_filter(s: Option<&str>) -> Result<Option<bool>> {
2361 match s.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
2362 None | Some("") | Some("all") | Some("any") => Ok(None),
2363 Some("true") | Some("yes") | Some("1") => Ok(Some(true)),
2364 Some("false") | Some("no") | Some("0") => Ok(Some(false)),
2365 Some(other) => Err(Error::InvalidData(format!(
2366 "expected 'true' | 'false' | 'all', got '{other}'"
2367 ))),
2368 }
2369}
2370
2371fn validate_iso_date(field: &str, value: &str) -> Result<()> {
2377 let bytes = value.as_bytes();
2378 let shape_ok = bytes.len() == 10
2379 && bytes[4] == b'-'
2380 && bytes[7] == b'-'
2381 && bytes[..4].iter().all(u8::is_ascii_digit)
2382 && bytes[5..7].iter().all(u8::is_ascii_digit)
2383 && bytes[8..].iter().all(u8::is_ascii_digit);
2384 if !shape_ok {
2385 return Err(Error::InvalidData(format!(
2386 "{field} must be an ISO 8601 calendar date (YYYY-MM-DD), got '{value}'"
2387 )));
2388 }
2389 let month: u32 = value[5..7].parse().unwrap();
2390 let day: u32 = value[8..10].parse().unwrap();
2391 if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
2392 return Err(Error::InvalidData(format!(
2393 "{field} = '{value}' is not a valid calendar date"
2394 )));
2395 }
2396 Ok(())
2397}
2398
2399#[derive(Deserialize, Default)]
2400#[serde(rename_all = "camelCase")]
2401struct ListProjectVersionsArgs {
2402 project: Option<String>,
2403 released: Option<String>,
2404 archived: Option<String>,
2405 limit: Option<u32>,
2406 include_issue_count: Option<bool>,
2407}
2408
2409async fn execute_list_project_versions(
2410 provider: &dyn devboy_core::Provider,
2411 args: &Value,
2412) -> Result<ToolOutput> {
2413 let params: ListProjectVersionsArgs = parse_tool_params(args, "list_project_versions")?;
2414
2415 let archived = match params.archived.as_deref() {
2419 None => Some(false),
2420 Some(s) => parse_tri_filter(Some(s))?,
2421 };
2422 let released = match params.released.as_deref() {
2423 None => None,
2424 Some(s) => parse_tri_filter(Some(s))?,
2425 };
2426 if let Some(0) = params.limit {
2430 return Err(Error::InvalidData(
2431 "limit must be at least 1 (use the default by omitting the field)".into(),
2432 ));
2433 }
2434 let limit = params.limit.unwrap_or(20).min(200);
2435
2436 let result = provider
2437 .list_project_versions(ListProjectVersionsParams {
2438 project: params.project.unwrap_or_default(),
2439 released,
2440 archived,
2441 limit: Some(limit),
2442 include_issue_count: params.include_issue_count.unwrap_or(false),
2443 })
2444 .await?;
2445
2446 let meta = ResultMeta {
2447 pagination: result.pagination,
2448 sort_info: result.sort_info,
2449 };
2450 Ok(ToolOutput::ProjectVersions(result.items, Some(meta)))
2451}
2452
2453#[derive(Deserialize)]
2454#[serde(rename_all = "camelCase")]
2455struct UpsertProjectVersionArgs {
2456 project: Option<String>,
2457 name: String,
2458 description: Option<String>,
2459 start_date: Option<String>,
2460 release_date: Option<String>,
2461 released: Option<bool>,
2462 archived: Option<bool>,
2463}
2464
2465async fn execute_upsert_project_version(
2466 provider: &dyn devboy_core::Provider,
2467 args: &Value,
2468) -> Result<ToolOutput> {
2469 let params: UpsertProjectVersionArgs = serde_json::from_value(args.clone())
2470 .map_err(|e| Error::InvalidData(format!("invalid upsert_project_version params: {e}")))?;
2471
2472 if let Some(ref d) = params.start_date {
2475 validate_iso_date("startDate", d)?;
2476 }
2477 if let Some(ref d) = params.release_date {
2478 validate_iso_date("releaseDate", d)?;
2479 }
2480
2481 let version = provider
2482 .upsert_project_version(UpsertProjectVersionInput {
2483 project: params.project.unwrap_or_default(),
2484 name: params.name,
2485 description: params.description,
2486 start_date: params.start_date,
2487 release_date: params.release_date,
2488 released: params.released,
2489 archived: params.archived,
2490 })
2491 .await?;
2492
2493 Ok(ToolOutput::SingleProjectVersion(Box::new(version)))
2494}
2495
2496#[derive(Deserialize, Default)]
2497#[serde(rename_all = "camelCase")]
2498struct GetBoardSprintsArgs {
2499 board_id: u64,
2500 state: Option<String>,
2503}
2504
2505async fn execute_get_board_sprints(
2506 provider: &dyn devboy_core::Provider,
2507 args: &Value,
2508) -> Result<ToolOutput> {
2509 let params: GetBoardSprintsArgs = parse_tool_params(args, "get_board_sprints")?;
2510 let state = match params.state.as_deref() {
2511 None | Some("all") => SprintState::All,
2512 Some("active") => SprintState::Active,
2513 Some("future") => SprintState::Future,
2514 Some("closed") => SprintState::Closed,
2515 Some(other) => {
2516 return Err(Error::InvalidData(format!(
2517 "invalid sprint state `{other}` — expected one of: active, future, closed, all"
2518 )));
2519 }
2520 };
2521
2522 let result = provider.get_board_sprints(params.board_id, state).await?;
2523 let meta = ResultMeta {
2524 pagination: result.pagination,
2525 sort_info: result.sort_info,
2526 };
2527 Ok(ToolOutput::Sprints(result.items, Some(meta)))
2528}
2529
2530#[derive(Deserialize, Default)]
2531#[serde(rename_all = "camelCase")]
2532struct AssignToSprintArgs {
2533 sprint_id: u64,
2534 issue_keys: Vec<String>,
2535}
2536
2537async fn execute_assign_to_sprint(
2538 provider: &dyn devboy_core::Provider,
2539 args: &Value,
2540) -> Result<ToolOutput> {
2541 let params: AssignToSprintArgs = parse_tool_params(args, "assign_to_sprint")?;
2542 if params.issue_keys.is_empty() {
2543 return Err(Error::InvalidData(
2544 "issueKeys must contain at least one issue key".into(),
2545 ));
2546 }
2547 let count = params.issue_keys.len();
2548 provider
2549 .assign_to_sprint(AssignToSprintInput {
2550 sprint_id: params.sprint_id,
2551 issue_keys: params.issue_keys,
2552 })
2553 .await?;
2554 Ok(ToolOutput::Text(format!(
2555 "Moved {count} issue(s) to sprint {}.",
2556 params.sprint_id
2557 )))
2558}
2559
2560#[derive(Deserialize, Default)]
2561#[serde(rename_all = "camelCase")]
2562struct GetCustomFieldsArgs {
2563 project: Option<String>,
2564 issue_type: Option<String>,
2565 search: Option<String>,
2566 limit: Option<u32>,
2567}
2568
2569async fn execute_get_custom_fields(
2570 provider: &dyn devboy_core::Provider,
2571 args: &Value,
2572) -> Result<ToolOutput> {
2573 let params: GetCustomFieldsArgs = parse_tool_params(args, "get_custom_fields")?;
2574 if let Some(0) = params.limit {
2575 return Err(Error::InvalidData(
2576 "limit must be at least 1 (use the default by omitting the field)".into(),
2577 ));
2578 }
2579 let result = provider
2580 .list_custom_fields(ListCustomFieldsParams {
2581 project: params.project,
2582 issue_type: params.issue_type,
2583 search: params.search,
2584 limit: params.limit,
2585 })
2586 .await?;
2587 let meta = ResultMeta {
2588 pagination: result.pagination,
2589 sort_info: result.sort_info,
2590 };
2591 Ok(ToolOutput::CustomFields(result.items, Some(meta)))
2592}
2593
2594#[cfg(test)]
2595mod tests {
2596 use super::*;
2597 use async_trait::async_trait;
2598 use devboy_core::{
2599 Comment, CreateMergeRequestInput, Discussion, FileDiff, Issue, IssueLink, IssueProvider,
2600 IssueRelations, KbPage, KbPageContent, KbSpace, KnowledgeBaseProvider, MergeRequest,
2601 MergeRequestProvider, Provider, User,
2602 };
2603
2604 struct MockProvider;
2607
2608 fn sample_issue() -> Issue {
2609 Issue {
2610 key: "gh#1".into(),
2611 title: "Test Issue".into(),
2612 description: Some("Body".into()),
2613 state: "open".into(),
2614 source: "mock".into(),
2615 priority: None,
2616 labels: vec!["bug".into()],
2617 author: None,
2618 assignees: vec![],
2619 url: Some("https://example.com/1".into()),
2620 created_at: Some("2024-01-01T00:00:00Z".into()),
2621 updated_at: Some("2024-01-02T00:00:00Z".into()),
2622 attachments_count: None,
2623 parent: None,
2624 subtasks: vec![],
2625 custom_fields: std::collections::HashMap::new(),
2626 }
2627 }
2628
2629 fn sample_mr() -> MergeRequest {
2630 MergeRequest {
2631 key: "pr#1".into(),
2632 title: "Test PR".into(),
2633 description: Some("PR body".into()),
2634 state: "open".into(),
2635 source: "mock".into(),
2636 source_branch: "feature".into(),
2637 target_branch: "main".into(),
2638 author: None,
2639 assignees: vec![],
2640 reviewers: vec![],
2641 labels: vec![],
2642 draft: false,
2643 url: Some("https://example.com/pr/1".into()),
2644 created_at: Some("2024-01-01T00:00:00Z".into()),
2645 updated_at: Some("2024-01-02T00:00:00Z".into()),
2646 }
2647 }
2648
2649 fn sample_comment() -> Comment {
2650 Comment {
2651 id: "c1".into(),
2652 body: "Test comment".into(),
2653 author: None,
2654 created_at: None,
2655 updated_at: None,
2656 position: None,
2657 }
2658 }
2659
2660 fn sample_discussion() -> Discussion {
2661 Discussion {
2662 id: "d1".into(),
2663 resolved: false,
2664 resolved_by: None,
2665 comments: vec![sample_comment()],
2666 position: None,
2667 }
2668 }
2669
2670 fn sample_diff() -> FileDiff {
2671 FileDiff {
2672 file_path: "src/main.rs".into(),
2673 old_path: None,
2674 new_file: false,
2675 deleted_file: false,
2676 renamed_file: false,
2677 diff: "+added\n-removed".into(),
2678 additions: Some(1),
2679 deletions: Some(1),
2680 }
2681 }
2682
2683 fn sample_kb_space() -> KbSpace {
2684 KbSpace {
2685 id: "space-1".into(),
2686 key: "ENG".into(),
2687 name: "Engineering".into(),
2688 ..Default::default()
2689 }
2690 }
2691
2692 fn sample_kb_page() -> KbPage {
2693 KbPage {
2694 id: "page-1".into(),
2695 title: "Architecture".into(),
2696 space_key: Some("ENG".into()),
2697 ..Default::default()
2698 }
2699 }
2700
2701 fn sample_kb_page_content() -> KbPageContent {
2702 KbPageContent {
2703 page: sample_kb_page(),
2704 content: "<p>body</p>".into(),
2705 content_type: "storage".into(),
2706 ancestors: vec![],
2707 labels: vec!["docs".into()],
2708 }
2709 }
2710
2711 #[async_trait]
2712 impl IssueProvider for MockProvider {
2713 async fn get_issues(
2714 &self,
2715 _filter: IssueFilter,
2716 ) -> devboy_core::Result<devboy_core::ProviderResult<Issue>> {
2717 Ok(vec![sample_issue()].into())
2718 }
2719 async fn get_issue(&self, _key: &str) -> devboy_core::Result<Issue> {
2720 Ok(sample_issue())
2721 }
2722 async fn create_issue(
2723 &self,
2724 _input: devboy_core::CreateIssueInput,
2725 ) -> devboy_core::Result<Issue> {
2726 Ok(sample_issue())
2727 }
2728 async fn update_issue(
2729 &self,
2730 _key: &str,
2731 _input: devboy_core::UpdateIssueInput,
2732 ) -> devboy_core::Result<Issue> {
2733 Ok(sample_issue())
2734 }
2735 async fn get_comments(
2736 &self,
2737 _key: &str,
2738 ) -> devboy_core::Result<devboy_core::ProviderResult<Comment>> {
2739 Ok(vec![sample_comment()].into())
2740 }
2741 async fn add_comment(&self, _key: &str, _body: &str) -> devboy_core::Result<Comment> {
2742 Ok(sample_comment())
2743 }
2744 async fn get_issue_relations(&self, _key: &str) -> devboy_core::Result<IssueRelations> {
2745 Ok(IssueRelations {
2746 parent: Some(sample_issue()),
2747 subtasks: vec![sample_issue()],
2748 blocks: vec![IssueLink {
2749 issue: sample_issue(),
2750 link_type: "Blocks".into(),
2751 }],
2752 ..Default::default()
2753 })
2754 }
2755 async fn get_structures(
2756 &self,
2757 ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::Structure>> {
2758 Ok(vec![sample_structure()].into())
2759 }
2760 async fn get_structure_forest(
2761 &self,
2762 structure_id: u64,
2763 _options: devboy_core::GetForestOptions,
2764 ) -> devboy_core::Result<devboy_core::StructureForest> {
2765 Ok(sample_forest(structure_id))
2766 }
2767 async fn add_structure_rows(
2768 &self,
2769 _structure_id: u64,
2770 input: devboy_core::AddStructureRowsInput,
2771 ) -> devboy_core::Result<devboy_core::ForestModifyResult> {
2772 Ok(devboy_core::ForestModifyResult {
2773 version: 2,
2774 affected_count: input.items.len(),
2775 })
2776 }
2777 async fn move_structure_rows(
2778 &self,
2779 _structure_id: u64,
2780 input: devboy_core::MoveStructureRowsInput,
2781 ) -> devboy_core::Result<devboy_core::ForestModifyResult> {
2782 Ok(devboy_core::ForestModifyResult {
2783 version: 3,
2784 affected_count: input.row_ids.len(),
2785 })
2786 }
2787 async fn remove_structure_row(
2788 &self,
2789 _structure_id: u64,
2790 _row_id: u64,
2791 ) -> devboy_core::Result<()> {
2792 Ok(())
2793 }
2794 async fn get_structure_values(
2795 &self,
2796 input: devboy_core::GetStructureValuesInput,
2797 ) -> devboy_core::Result<devboy_core::StructureValues> {
2798 Ok(devboy_core::StructureValues {
2799 structure_id: input.structure_id,
2800 values: vec![],
2801 })
2802 }
2803 async fn get_structure_views(
2804 &self,
2805 structure_id: u64,
2806 _view_id: Option<u64>,
2807 ) -> devboy_core::Result<Vec<devboy_core::StructureView>> {
2808 Ok(vec![sample_view(structure_id)])
2809 }
2810 async fn save_structure_view(
2811 &self,
2812 input: devboy_core::SaveStructureViewInput,
2813 ) -> devboy_core::Result<devboy_core::StructureView> {
2814 Ok(devboy_core::StructureView {
2815 id: input.id.unwrap_or(99),
2816 name: input.name,
2817 structure_id: input.structure_id,
2818 ..Default::default()
2819 })
2820 }
2821 async fn create_structure(
2822 &self,
2823 input: devboy_core::CreateStructureInput,
2824 ) -> devboy_core::Result<devboy_core::Structure> {
2825 Ok(devboy_core::Structure {
2826 id: 42,
2827 name: input.name,
2828 description: input.description,
2829 })
2830 }
2831 async fn list_project_versions(
2832 &self,
2833 params: devboy_core::ListProjectVersionsParams,
2834 ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::ProjectVersion>> {
2835 let mut name = format!(
2838 "v-released={:?}-archived={:?}-limit={:?}-expand={}",
2839 params.released, params.archived, params.limit, params.include_issue_count
2840 );
2841 if !params.project.is_empty() {
2842 name.push_str(&format!("-project={}", params.project));
2843 }
2844 Ok(vec![devboy_core::ProjectVersion {
2845 id: "1".into(),
2846 project: if params.project.is_empty() {
2847 "MOCK".into()
2848 } else {
2849 params.project
2850 },
2851 name,
2852 description: Some("desc".into()),
2853 start_date: None,
2854 release_date: Some("2026-01-01".into()),
2855 released: false,
2856 archived: false,
2857 overdue: None,
2858 issue_count: Some(0),
2859 unresolved_issue_count: None,
2860 source: "mock".into(),
2861 }]
2862 .into())
2863 }
2864 async fn upsert_project_version(
2865 &self,
2866 input: devboy_core::UpsertProjectVersionInput,
2867 ) -> devboy_core::Result<devboy_core::ProjectVersion> {
2868 Ok(devboy_core::ProjectVersion {
2869 id: "777".into(),
2870 project: if input.project.is_empty() {
2871 "MOCK".into()
2872 } else {
2873 input.project
2874 },
2875 name: input.name,
2876 description: input.description,
2877 start_date: input.start_date,
2878 release_date: input.release_date,
2879 released: input.released.unwrap_or(false),
2880 archived: input.archived.unwrap_or(false),
2881 overdue: None,
2882 issue_count: None,
2883 unresolved_issue_count: None,
2884 source: "mock".into(),
2885 })
2886 }
2887 async fn get_board_sprints(
2888 &self,
2889 board_id: u64,
2890 state: devboy_core::SprintState,
2891 ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::Sprint>> {
2892 Ok(vec![devboy_core::Sprint {
2895 id: 1,
2896 name: format!("sprint-board={board_id}-state={state:?}"),
2897 state: "active".into(),
2898 origin_board_id: Some(board_id),
2899 start_date: None,
2900 end_date: None,
2901 goal: None,
2902 }]
2903 .into())
2904 }
2905 async fn assign_to_sprint(
2906 &self,
2907 _input: devboy_core::AssignToSprintInput,
2908 ) -> devboy_core::Result<()> {
2909 Ok(())
2910 }
2911 async fn list_custom_fields(
2912 &self,
2913 params: devboy_core::ListCustomFieldsParams,
2914 ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::CustomFieldDescriptor>>
2915 {
2916 let mut all = vec![
2919 devboy_core::CustomFieldDescriptor {
2920 id: "customfield_10014".into(),
2921 name: "Epic Link".into(),
2922 field_type: "any".into(),
2923 description: None,
2924 native: None,
2925 },
2926 devboy_core::CustomFieldDescriptor {
2927 id: "customfield_10011".into(),
2928 name: "Epic Name".into(),
2929 field_type: "string".into(),
2930 description: None,
2931 native: None,
2932 },
2933 devboy_core::CustomFieldDescriptor {
2934 id: "customfield_10020".into(),
2935 name: "Sprint".into(),
2936 field_type: "array".into(),
2937 description: None,
2938 native: None,
2939 },
2940 ];
2941 if let Some(needle) = params.search.as_deref().map(str::to_lowercase) {
2942 all.retain(|f| f.name.to_lowercase().contains(&needle));
2943 }
2944 let total = all.len() as u32;
2945 let limit = params.limit.unwrap_or(50);
2946 if (limit as usize) < all.len() {
2947 all.truncate(limit as usize);
2948 }
2949 let pagination = devboy_core::Pagination {
2950 offset: 0,
2951 limit,
2952 total: Some(total),
2953 has_more: (all.len() as u32) < total,
2954 next_cursor: None,
2955 };
2956 Ok(devboy_core::ProviderResult::new(all).with_pagination(pagination))
2957 }
2958 fn provider_name(&self) -> &'static str {
2959 "mock"
2960 }
2961 }
2962
2963 #[async_trait]
2964 impl MergeRequestProvider for MockProvider {
2965 async fn get_merge_requests(
2966 &self,
2967 _filter: MrFilter,
2968 ) -> devboy_core::Result<devboy_core::ProviderResult<MergeRequest>> {
2969 Ok(vec![sample_mr()].into())
2970 }
2971 async fn get_merge_request(&self, _key: &str) -> devboy_core::Result<MergeRequest> {
2972 Ok(sample_mr())
2973 }
2974 async fn get_discussions(
2975 &self,
2976 _key: &str,
2977 ) -> devboy_core::Result<devboy_core::ProviderResult<Discussion>> {
2978 Ok(vec![sample_discussion()].into())
2979 }
2980 async fn get_diffs(
2981 &self,
2982 _key: &str,
2983 ) -> devboy_core::Result<devboy_core::ProviderResult<FileDiff>> {
2984 Ok(vec![sample_diff()].into())
2985 }
2986 async fn add_comment(
2987 &self,
2988 _key: &str,
2989 _input: CreateCommentInput,
2990 ) -> devboy_core::Result<Comment> {
2991 Ok(sample_comment())
2992 }
2993 async fn create_merge_request(
2994 &self,
2995 _input: CreateMergeRequestInput,
2996 ) -> devboy_core::Result<MergeRequest> {
2997 Ok(sample_mr())
2998 }
2999 fn provider_name(&self) -> &'static str {
3000 "mock"
3001 }
3002 }
3003
3004 #[async_trait]
3005 impl devboy_core::PipelineProvider for MockProvider {
3006 fn provider_name(&self) -> &'static str {
3007 "mock"
3008 }
3009 }
3010
3011 #[async_trait]
3012 impl KnowledgeBaseProvider for MockProvider {
3013 fn provider_name(&self) -> &'static str {
3014 "mock"
3015 }
3016
3017 async fn get_spaces(&self) -> devboy_core::Result<devboy_core::ProviderResult<KbSpace>> {
3018 Ok(vec![sample_kb_space()].into())
3019 }
3020
3021 async fn list_pages(
3022 &self,
3023 _params: ListPagesParams,
3024 ) -> devboy_core::Result<devboy_core::ProviderResult<KbPage>> {
3025 Ok(vec![sample_kb_page()].into())
3026 }
3027
3028 async fn get_page(&self, _page_id: &str) -> devboy_core::Result<KbPageContent> {
3029 Ok(sample_kb_page_content())
3030 }
3031
3032 async fn create_page(
3033 &self,
3034 _params: devboy_core::CreatePageParams,
3035 ) -> devboy_core::Result<KbPage> {
3036 Ok(sample_kb_page())
3037 }
3038
3039 async fn update_page(
3040 &self,
3041 _params: devboy_core::UpdatePageParams,
3042 ) -> devboy_core::Result<KbPage> {
3043 Ok(sample_kb_page())
3044 }
3045
3046 async fn search(
3047 &self,
3048 _params: SearchKbParams,
3049 ) -> devboy_core::Result<devboy_core::ProviderResult<KbPage>> {
3050 Ok(vec![sample_kb_page()].into())
3051 }
3052 }
3053
3054 #[async_trait]
3055 impl Provider for MockProvider {
3056 async fn get_current_user(&self) -> devboy_core::Result<User> {
3057 Ok(User {
3058 id: "1".into(),
3059 username: "test".into(),
3060 name: None,
3061 email: None,
3062 avatar_url: None,
3063 })
3064 }
3065 }
3066
3067 #[test]
3070 fn test_executor_new() {
3071 let executor = Executor::new();
3072 assert!(executor.enrichers.is_empty());
3073 }
3074
3075 #[test]
3076 fn test_supported_tools_contains_all() {
3077 assert!(SUPPORTED_TOOLS.contains(&"get_issues"));
3078 assert!(SUPPORTED_TOOLS.contains(&"get_merge_requests"));
3079 assert!(SUPPORTED_TOOLS.contains(&"create_merge_request_comment"));
3080 assert!(SUPPORTED_TOOLS.contains(&"get_meeting_notes"));
3081 assert!(SUPPORTED_TOOLS.contains(&"get_meeting_transcript"));
3082 assert!(SUPPORTED_TOOLS.contains(&"search_meeting_notes"));
3083 assert!(SUPPORTED_TOOLS.contains(&"get_knowledge_base_spaces"));
3084 assert!(SUPPORTED_TOOLS.contains(&"list_knowledge_base_pages"));
3085 assert!(SUPPORTED_TOOLS.contains(&"get_knowledge_base_page"));
3086 assert!(SUPPORTED_TOOLS.contains(&"create_knowledge_base_page"));
3087 assert!(SUPPORTED_TOOLS.contains(&"update_knowledge_base_page"));
3088 assert!(SUPPORTED_TOOLS.contains(&"search_knowledge_base"));
3089 assert!(SUPPORTED_TOOLS.contains(&"get_messenger_chats"));
3090 assert!(SUPPORTED_TOOLS.contains(&"get_chat_messages"));
3091 assert!(SUPPORTED_TOOLS.contains(&"search_chat_messages"));
3092 assert!(SUPPORTED_TOOLS.contains(&"send_message"));
3093 assert_eq!(SUPPORTED_TOOLS.len(), 40);
3094 }
3095
3096 #[tokio::test]
3097 async fn test_dispatch_get_knowledge_base_spaces() {
3098 let provider = MockProvider;
3099 let result =
3100 dispatch_knowledge_base_tool("get_knowledge_base_spaces", &Value::Null, &provider)
3101 .await
3102 .unwrap();
3103 assert!(matches!(result, ToolOutput::KnowledgeBaseSpaces(v, _) if v.len() == 1));
3104 }
3105
3106 #[tokio::test]
3107 async fn test_dispatch_list_knowledge_base_pages() {
3108 let provider = MockProvider;
3109 let args = serde_json::json!({"spaceKey": "ENG", "limit": 10});
3110 let result = dispatch_knowledge_base_tool("list_knowledge_base_pages", &args, &provider)
3111 .await
3112 .unwrap();
3113 assert!(matches!(result, ToolOutput::KnowledgeBasePages(v, _) if v.len() == 1));
3114 }
3115
3116 #[tokio::test]
3117 async fn test_dispatch_get_knowledge_base_page() {
3118 let provider = MockProvider;
3119 let args = serde_json::json!({"pageId": "page-1"});
3120 let result = dispatch_knowledge_base_tool("get_knowledge_base_page", &args, &provider)
3121 .await
3122 .unwrap();
3123 assert!(matches!(result, ToolOutput::KnowledgeBasePage(_)));
3124 }
3125
3126 #[tokio::test]
3127 async fn test_dispatch_create_knowledge_base_page() {
3128 let provider = MockProvider;
3129 let args = serde_json::json!({
3130 "spaceKey": "ENG",
3131 "title": "New Page",
3132 "content": "<p>body</p>",
3133 "contentType": "storage",
3134 "labels": ["docs"]
3135 });
3136 let result = dispatch_knowledge_base_tool("create_knowledge_base_page", &args, &provider)
3137 .await
3138 .unwrap();
3139 assert!(matches!(result, ToolOutput::KnowledgeBasePageSummary(_)));
3140 }
3141
3142 #[tokio::test]
3143 async fn test_dispatch_update_knowledge_base_page() {
3144 let provider = MockProvider;
3145 let args = serde_json::json!({
3146 "pageId": "page-1",
3147 "title": "Updated",
3148 "content": "<p>new body</p>",
3149 "version": 2
3150 });
3151 let result = dispatch_knowledge_base_tool("update_knowledge_base_page", &args, &provider)
3152 .await
3153 .unwrap();
3154 assert!(matches!(result, ToolOutput::KnowledgeBasePageSummary(_)));
3155 }
3156
3157 #[tokio::test]
3158 async fn test_dispatch_search_knowledge_base() {
3159 let provider = MockProvider;
3160 let args = serde_json::json!({"query": "architecture", "spaceKey": "ENG"});
3161 let result = dispatch_knowledge_base_tool("search_knowledge_base", &args, &provider)
3162 .await
3163 .unwrap();
3164 assert!(matches!(result, ToolOutput::KnowledgeBasePages(v, _) if v.len() == 1));
3165 }
3166
3167 #[tokio::test]
3170 async fn test_dispatch_get_issues() {
3171 let provider = MockProvider;
3172 let args = serde_json::json!({"state": "open", "limit": 10});
3173 let result = dispatch_tool("get_issues", &args, &provider, None)
3174 .await
3175 .unwrap();
3176 assert!(matches!(result, ToolOutput::Issues(v, _) if v.len() == 1));
3177 }
3178
3179 #[tokio::test]
3180 async fn test_dispatch_get_issues_empty_args() {
3181 let provider = MockProvider;
3182 let result = dispatch_tool("get_issues", &Value::Null, &provider, None)
3183 .await
3184 .unwrap();
3185 assert!(matches!(result, ToolOutput::Issues(_, _)));
3186 }
3187
3188 #[tokio::test]
3189 async fn test_dispatch_get_issues_invalid_params_are_rejected() {
3190 let provider = MockProvider;
3195 let args = serde_json::json!({"state": 42});
3196 let err = dispatch_tool("get_issues", &args, &provider, None)
3197 .await
3198 .unwrap_err();
3199 assert!(
3200 matches!(err, devboy_core::Error::InvalidData(ref msg) if msg.contains("get_issues")),
3201 "expected InvalidData referencing get_issues, got {err:?}"
3202 );
3203 }
3204
3205 #[tokio::test]
3206 async fn test_dispatch_get_merge_requests_invalid_params_rejected() {
3207 let provider = MockProvider;
3208 let args = serde_json::json!({"limit": "not-a-number"});
3209 let err = dispatch_tool("get_merge_requests", &args, &provider, None)
3210 .await
3211 .unwrap_err();
3212 assert!(
3213 matches!(err, devboy_core::Error::InvalidData(ref msg) if msg.contains("get_merge_requests")),
3214 "expected InvalidData referencing get_merge_requests, got {err:?}"
3215 );
3216 }
3217
3218 #[tokio::test]
3219 async fn test_dispatch_get_pipeline_invalid_params_rejected() {
3220 let provider = MockProvider;
3221 let args = serde_json::json!({"includeFailedLogs": "yes"});
3222 let err = dispatch_tool("get_pipeline", &args, &provider, None)
3223 .await
3224 .unwrap_err();
3225 assert!(
3226 matches!(err, devboy_core::Error::InvalidData(ref msg) if msg.contains("get_pipeline")),
3227 "expected InvalidData referencing get_pipeline, got {err:?}"
3228 );
3229 }
3230
3231 #[test]
3232 fn parse_tool_params_null_yields_default() {
3233 #[derive(Debug, Default, serde::Deserialize)]
3234 struct P {
3235 #[allow(dead_code)]
3236 x: Option<String>,
3237 }
3238 let _: P = parse_tool_params(&Value::Null, "test").expect("null → default");
3239 }
3240
3241 #[test]
3242 fn parse_tool_params_empty_object_yields_default() {
3243 #[derive(Debug, Default, serde::Deserialize)]
3246 struct P {
3247 #[allow(dead_code)]
3248 x: Option<String>,
3249 }
3250 let _: P = parse_tool_params(&serde_json::json!({}), "test").expect("{} → default");
3251 }
3252
3253 #[test]
3254 fn parse_tool_params_invalid_maps_to_invalid_data() {
3255 #[derive(Debug, Default, serde::Deserialize)]
3256 struct P {
3257 #[allow(dead_code)]
3258 n: u32,
3259 }
3260 let err = parse_tool_params::<P>(&serde_json::json!({"n": "nope"}), "tool-x").unwrap_err();
3261 assert!(
3262 matches!(err, devboy_core::Error::InvalidData(ref msg) if msg.contains("tool-x")),
3263 "expected InvalidData(tool-x), got {err:?}"
3264 );
3265 }
3266
3267 #[tokio::test]
3268 async fn test_dispatch_get_issue() {
3269 let provider = MockProvider;
3270 let args = serde_json::json!({"key": "gh#1"});
3272 let result = dispatch_tool("get_issue", &args, &provider, None)
3273 .await
3274 .unwrap();
3275 assert!(matches!(result, ToolOutput::Text(_)));
3276
3277 let args =
3279 serde_json::json!({"key": "gh#1", "includeComments": false, "includeRelations": false});
3280 let result = dispatch_tool("get_issue", &args, &provider, None)
3281 .await
3282 .unwrap();
3283 assert!(matches!(result, ToolOutput::SingleIssue(_)));
3284 }
3285
3286 #[tokio::test]
3287 async fn test_dispatch_get_issue_missing_key() {
3288 let provider = MockProvider;
3289 let result = dispatch_tool("get_issue", &serde_json::json!({}), &provider, None).await;
3290 assert!(result.is_err());
3291 }
3292
3293 #[tokio::test]
3294 async fn test_dispatch_get_issue_comments() {
3295 let provider = MockProvider;
3296 let args = serde_json::json!({"key": "gh#1"});
3297 let result = dispatch_tool("get_issue_comments", &args, &provider, None)
3298 .await
3299 .unwrap();
3300 assert!(matches!(result, ToolOutput::Comments(v, _) if v.len() == 1));
3301 }
3302
3303 #[tokio::test]
3304 async fn test_dispatch_create_issue() {
3305 let provider = MockProvider;
3306 let args =
3307 serde_json::json!({"title": "New issue", "description": "Body", "labels": ["bug"]});
3308 let result = dispatch_tool("create_issue", &args, &provider, None)
3309 .await
3310 .unwrap();
3311 assert!(matches!(result, ToolOutput::SingleIssue(_)));
3312 }
3313
3314 #[test]
3315 fn create_issue_params_accepts_parent_id_alias() {
3316 let args = serde_json::json!({ "title": "t", "parentId": "DEV-799" });
3317 let params: CreateIssueParams = serde_json::from_value(args).unwrap();
3318 assert_eq!(params.parent.as_deref(), Some("DEV-799"));
3319 }
3320
3321 #[test]
3322 fn create_issue_params_still_accepts_parent() {
3323 let args = serde_json::json!({ "title": "t", "parent": "DEV-799" });
3324 let params: CreateIssueParams = serde_json::from_value(args).unwrap();
3325 assert_eq!(params.parent.as_deref(), Some("DEV-799"));
3326 }
3327
3328 #[tokio::test]
3329 async fn test_dispatch_update_issue() {
3330 let provider = MockProvider;
3331 let args = serde_json::json!({"key": "gh#1", "title": "Updated"});
3332 let result = dispatch_tool("update_issue", &args, &provider, None)
3333 .await
3334 .unwrap();
3335 assert!(matches!(result, ToolOutput::SingleIssue(_)));
3336 }
3337
3338 #[tokio::test]
3339 async fn test_dispatch_add_issue_comment() {
3340 let provider = MockProvider;
3341 let args = serde_json::json!({"key": "gh#1", "body": "A comment"});
3342 let result = dispatch_tool("add_issue_comment", &args, &provider, None)
3343 .await
3344 .unwrap();
3345 assert!(matches!(result, ToolOutput::Text(ref t) if t.contains("Comment added")));
3346 }
3347
3348 #[tokio::test]
3349 async fn test_dispatch_get_issue_relations() {
3350 let provider = MockProvider;
3351 let args = serde_json::json!({"key": "gh#1"});
3352 let result = dispatch_tool("get_issue_relations", &args, &provider, None)
3353 .await
3354 .unwrap();
3355 match result {
3356 ToolOutput::Relations(relations) => {
3357 assert!(relations.parent.is_some());
3358 assert_eq!(relations.subtasks.len(), 1);
3359 assert_eq!(relations.blocks.len(), 1);
3360 }
3361 other => panic!("Expected Relations, got {:?}", other),
3362 }
3363 }
3364
3365 #[tokio::test]
3366 async fn test_dispatch_get_issue_relations_missing_key() {
3367 let provider = MockProvider;
3368 let result = dispatch_tool(
3369 "get_issue_relations",
3370 &serde_json::json!({}),
3371 &provider,
3372 None,
3373 )
3374 .await;
3375 assert!(result.is_err());
3376 }
3377
3378 #[tokio::test]
3381 async fn test_dispatch_get_merge_requests() {
3382 let provider = MockProvider;
3383 let args = serde_json::json!({"state": "open", "limit": 5});
3384 let result = dispatch_tool("get_merge_requests", &args, &provider, None)
3385 .await
3386 .unwrap();
3387 assert!(matches!(result, ToolOutput::MergeRequests(v, _) if v.len() == 1));
3388 }
3389
3390 #[tokio::test]
3391 async fn test_dispatch_get_merge_requests_empty_args() {
3392 let provider = MockProvider;
3393 let result = dispatch_tool("get_merge_requests", &Value::Null, &provider, None)
3394 .await
3395 .unwrap();
3396 assert!(matches!(result, ToolOutput::MergeRequests(_, _)));
3397 }
3398
3399 #[tokio::test]
3400 async fn test_dispatch_get_merge_request() {
3401 let provider = MockProvider;
3402 let args = serde_json::json!({"key": "pr#1"});
3403 let result = dispatch_tool("get_merge_request", &args, &provider, None)
3404 .await
3405 .unwrap();
3406 assert!(matches!(result, ToolOutput::SingleMergeRequest(_)));
3407 }
3408
3409 #[tokio::test]
3410 async fn test_dispatch_get_merge_request_discussions() {
3411 let provider = MockProvider;
3412 let args = serde_json::json!({"key": "pr#1"});
3413 let result = dispatch_tool("get_merge_request_discussions", &args, &provider, None)
3414 .await
3415 .unwrap();
3416 assert!(matches!(result, ToolOutput::Discussions(v, _) if v.len() == 1));
3417 }
3418
3419 #[tokio::test]
3420 async fn test_dispatch_get_merge_request_diffs() {
3421 let provider = MockProvider;
3422 let args = serde_json::json!({"key": "pr#1"});
3423 let result = dispatch_tool("get_merge_request_diffs", &args, &provider, None)
3424 .await
3425 .unwrap();
3426 assert!(matches!(result, ToolOutput::Diffs(v, _) if v.len() == 1));
3427 }
3428
3429 #[tokio::test]
3430 async fn test_dispatch_create_merge_request() {
3431 let provider = MockProvider;
3432 let args = serde_json::json!({
3433 "title": "New PR",
3434 "source_branch": "feature",
3435 "target_branch": "main",
3436 "draft": false
3437 });
3438 let result = dispatch_tool("create_merge_request", &args, &provider, None)
3439 .await
3440 .unwrap();
3441 assert!(matches!(result, ToolOutput::SingleMergeRequest(_)));
3442 }
3443
3444 #[tokio::test]
3445 async fn test_dispatch_create_merge_request_comment_general() {
3446 let provider = MockProvider;
3447 let args = serde_json::json!({"key": "pr#1", "body": "LGTM"});
3448 let result = dispatch_tool("create_merge_request_comment", &args, &provider, None)
3449 .await
3450 .unwrap();
3451 assert!(matches!(result, ToolOutput::Text(ref t) if t.contains("Comment added")));
3452 }
3453
3454 #[tokio::test]
3455 async fn test_dispatch_create_merge_request_comment_inline() {
3456 let provider = MockProvider;
3457 let args = serde_json::json!({
3458 "key": "pr#1",
3459 "body": "Fix this line",
3460 "file_path": "src/main.rs",
3461 "line": 42,
3462 "line_type": "new",
3463 "commit_sha": "abc123"
3464 });
3465 let result = dispatch_tool("create_merge_request_comment", &args, &provider, None)
3466 .await
3467 .unwrap();
3468 assert!(matches!(result, ToolOutput::Text(ref t) if t.contains("Comment added")));
3469 }
3470
3471 #[test]
3472 fn test_create_merge_request_comment_params_accept_camel_case() {
3473 let args = serde_json::json!({
3474 "mrKey": "mr#566",
3475 "body": "reply",
3476 "filePath": "src/main.rs",
3477 "line": 12,
3478 "lineType": "new",
3479 "commitSha": "abc123",
3480 "discussionId": "788adb16c57805c9a5d59272c944cddea381a605"
3481 });
3482
3483 let params: CreateMrCommentParams = serde_json::from_value(args).unwrap();
3484 assert_eq!(params.key, "mr#566");
3485 assert_eq!(params.file_path.as_deref(), Some("src/main.rs"));
3486 assert_eq!(params.line_type.as_deref(), Some("new"));
3487 assert_eq!(params.commit_sha.as_deref(), Some("abc123"));
3488 assert_eq!(
3489 params.discussion_id.as_deref(),
3490 Some("788adb16c57805c9a5d59272c944cddea381a605")
3491 );
3492 }
3493
3494 #[test]
3495 fn test_create_merge_request_comment_params_still_accept_snake_case() {
3496 let args = serde_json::json!({
3501 "key": "mr#566",
3502 "body": "reply",
3503 "file_path": "src/main.rs",
3504 "line": 12,
3505 "line_type": "new",
3506 "commit_sha": "abc123",
3507 "discussion_id": "788adb16c57805c9a5d59272c944cddea381a605"
3508 });
3509
3510 let params: CreateMrCommentParams = serde_json::from_value(args).unwrap();
3511 assert_eq!(params.key, "mr#566");
3512 assert_eq!(params.file_path.as_deref(), Some("src/main.rs"));
3513 assert_eq!(params.line_type.as_deref(), Some("new"));
3514 assert_eq!(params.commit_sha.as_deref(), Some("abc123"));
3515 assert_eq!(
3516 params.discussion_id.as_deref(),
3517 Some("788adb16c57805c9a5d59272c944cddea381a605")
3518 );
3519 }
3520
3521 #[tokio::test]
3522 async fn test_dispatch_create_merge_request_comment_accepts_camel_case_args() {
3523 let provider = MockProvider;
3528 let args = serde_json::json!({
3529 "mrKey": "mr#1",
3530 "body": "threaded reply",
3531 "discussionId": "abc123"
3532 });
3533 let result = dispatch_tool("create_merge_request_comment", &args, &provider, None)
3534 .await
3535 .unwrap();
3536 assert!(matches!(result, ToolOutput::Text(ref t) if t.contains("Comment added")));
3537 }
3538
3539 #[tokio::test]
3540 async fn test_dispatch_unknown_tool() {
3541 let provider = MockProvider;
3542 let result = dispatch_tool("nonexistent_tool", &Value::Null, &provider, None).await;
3543 assert!(result.is_err());
3544 }
3545
3546 #[tokio::test]
3549 async fn test_executor_enricher_transforms_args() {
3550 use devboy_core::{ToolEnricher, ToolSchema};
3551
3552 struct TestEnricher;
3553 impl ToolEnricher for TestEnricher {
3554 fn supported_categories(&self) -> &[devboy_core::ToolCategory] {
3555 &[devboy_core::ToolCategory::IssueTracker]
3556 }
3557 fn enrich_schema(&self, _tool: &str, _schema: &mut ToolSchema) {}
3558 fn transform_args(&self, _tool: &str, args: &mut Value) {
3559 if let Some(obj) = args.as_object_mut() {
3560 obj.insert("transformed".into(), Value::Bool(true));
3561 }
3562 }
3563 }
3564
3565 let mut executor = Executor::new();
3566 executor.add_enricher(Box::new(TestEnricher));
3567 assert_eq!(executor.enrichers.len(), 1);
3568 }
3569
3570 #[tokio::test]
3573 async fn test_dispatch_get_pipeline_unsupported() {
3574 let provider = MockProvider;
3575 let args = serde_json::json!({"branch": "main"});
3576 let result = dispatch_tool("get_pipeline", &args, &provider, None).await;
3577 assert!(result.is_err());
3579 }
3580
3581 #[tokio::test]
3582 async fn test_dispatch_get_job_logs_unsupported() {
3583 let provider = MockProvider;
3584 let args = serde_json::json!({"jobId": "123"});
3585 let result = dispatch_tool("get_job_logs", &args, &provider, None).await;
3586 assert!(result.is_err());
3587 }
3588
3589 #[tokio::test]
3590 async fn test_dispatch_get_pipeline_with_mr_key() {
3591 let provider = MockProvider;
3592 let args = serde_json::json!({"mrKey": "pr#1", "includeFailedLogs": false});
3593 let result = dispatch_tool("get_pipeline", &args, &provider, None).await;
3594 assert!(result.is_err());
3595 }
3596
3597 #[tokio::test]
3598 async fn test_dispatch_get_job_logs_with_pattern() {
3599 let provider = MockProvider;
3600 let args = serde_json::json!({"jobId": "123", "pattern": "ERROR", "context": 3});
3601 let result = dispatch_tool("get_job_logs", &args, &provider, None).await;
3602 assert!(result.is_err());
3603 }
3604
3605 #[tokio::test]
3606 async fn test_dispatch_get_job_logs_paginated() {
3607 let provider = MockProvider;
3608 let args = serde_json::json!({"jobId": "123", "offset": 10, "limit": 50});
3609 let result = dispatch_tool("get_job_logs", &args, &provider, None).await;
3610 assert!(result.is_err());
3611 }
3612
3613 #[tokio::test]
3614 async fn test_dispatch_get_job_logs_full() {
3615 let provider = MockProvider;
3616 let args = serde_json::json!({"jobId": "123", "full": true});
3617 let result = dispatch_tool("get_job_logs", &args, &provider, None).await;
3618 assert!(result.is_err());
3619 }
3620
3621 #[test]
3622 fn test_executor_default() {
3623 let executor = Executor::default();
3624 assert!(executor.enrichers.is_empty());
3625 }
3626
3627 #[tokio::test]
3630 async fn test_dispatch_get_available_statuses_unsupported() {
3631 let provider = MockProvider;
3632 let result = dispatch_tool("get_available_statuses", &Value::Null, &provider, None).await;
3633 assert!(result.is_err());
3635 }
3636
3637 #[tokio::test]
3638 async fn test_dispatch_get_users_unsupported() {
3639 let provider = MockProvider;
3640 let args = serde_json::json!({"search": "test"});
3641 let result = dispatch_tool("get_users", &args, &provider, None).await;
3642 assert!(result.is_err());
3644 }
3645
3646 #[tokio::test]
3647 async fn test_dispatch_link_issues_unsupported() {
3648 let provider = MockProvider;
3649 let args = serde_json::json!({
3650 "source_key": "gh#1",
3651 "target_key": "gh#2",
3652 "link_type": "blocks"
3653 });
3654 let result = dispatch_tool("link_issues", &args, &provider, None).await;
3655 assert!(result.is_err());
3656 }
3657
3658 #[tokio::test]
3659 async fn test_dispatch_get_epics() {
3660 let provider = MockProvider;
3661 let args = serde_json::json!({"state": "open", "limit": 10});
3662 let result = dispatch_tool("get_epics", &args, &provider, None)
3663 .await
3664 .unwrap();
3665 assert!(matches!(result, ToolOutput::Text(_)));
3667 }
3668
3669 #[tokio::test]
3670 async fn test_dispatch_get_epics_empty_args() {
3671 let provider = MockProvider;
3672 let result = dispatch_tool("get_epics", &Value::Null, &provider, None)
3673 .await
3674 .unwrap();
3675 assert!(matches!(result, ToolOutput::Text(_)));
3676 }
3677
3678 #[tokio::test]
3679 async fn test_dispatch_create_epic() {
3680 let provider = MockProvider;
3681 let args = serde_json::json!({"title": "New Epic", "description": "Epic description"});
3682 let result = dispatch_tool("create_epic", &args, &provider, None)
3683 .await
3684 .unwrap();
3685 assert!(matches!(result, ToolOutput::SingleIssue(_)));
3686 }
3687
3688 #[tokio::test]
3689 async fn test_dispatch_update_epic() {
3690 let provider = MockProvider;
3691 let args = serde_json::json!({"key": "gh#1", "title": "Updated Epic"});
3692 let result = dispatch_tool("update_epic", &args, &provider, None)
3693 .await
3694 .unwrap();
3695 assert!(matches!(result, ToolOutput::SingleIssue(_)));
3696 }
3697
3698 #[tokio::test]
3699 async fn test_dispatch_link_issues_missing_params() {
3700 let provider = MockProvider;
3701 let args = serde_json::json!({"source_key": "gh#1"});
3702 let result = dispatch_tool("link_issues", &args, &provider, None).await;
3703 assert!(result.is_err());
3704 }
3705
3706 struct MockMeetingProvider;
3709
3710 #[async_trait]
3711 impl MeetingNotesProvider for MockMeetingProvider {
3712 fn provider_name(&self) -> &'static str {
3713 "mock_meetings"
3714 }
3715
3716 async fn get_meetings(
3717 &self,
3718 _filter: MeetingFilter,
3719 ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::MeetingNote>> {
3720 Ok(vec![devboy_core::MeetingNote {
3721 id: "m1".into(),
3722 title: "Test Meeting".into(),
3723 ..Default::default()
3724 }]
3725 .into())
3726 }
3727
3728 async fn get_transcript(
3729 &self,
3730 meeting_id: &str,
3731 ) -> devboy_core::Result<devboy_core::MeetingTranscript> {
3732 Ok(devboy_core::MeetingTranscript {
3733 meeting_id: meeting_id.to_string(),
3734 title: Some("Test Transcript".into()),
3735 sentences: vec![devboy_core::TranscriptSentence {
3736 speaker_id: "s1".into(),
3737 speaker_name: Some("Alice".into()),
3738 text: "Hello".into(),
3739 start_time: 0.0,
3740 end_time: 1.0,
3741 }],
3742 })
3743 }
3744
3745 async fn search_meetings(
3746 &self,
3747 _query: &str,
3748 _filter: MeetingFilter,
3749 ) -> devboy_core::Result<devboy_core::ProviderResult<devboy_core::MeetingNote>> {
3750 Ok(vec![devboy_core::MeetingNote {
3751 id: "m2".into(),
3752 title: "Search Result Meeting".into(),
3753 ..Default::default()
3754 }]
3755 .into())
3756 }
3757 }
3758
3759 #[tokio::test]
3760 async fn test_dispatch_get_meeting_notes() {
3761 let provider = MockMeetingProvider;
3762 let args = serde_json::json!({"from_date": "2025-01-01", "limit": 10});
3763 let result = dispatch_meeting_tool("get_meeting_notes", &args, &provider)
3764 .await
3765 .unwrap();
3766 match result {
3767 ToolOutput::MeetingNotes(meetings, _) => {
3768 assert_eq!(meetings.len(), 1);
3769 assert_eq!(meetings[0].title, "Test Meeting");
3770 }
3771 other => panic!("Expected MeetingNotes, got {:?}", other),
3772 }
3773 }
3774
3775 #[tokio::test]
3776 async fn test_dispatch_get_meeting_transcript() {
3777 let provider = MockMeetingProvider;
3778 let args = serde_json::json!({"meeting_id": "m1"});
3779 let result = dispatch_meeting_tool("get_meeting_transcript", &args, &provider)
3780 .await
3781 .unwrap();
3782 match result {
3783 ToolOutput::MeetingTranscript(transcript) => {
3784 assert_eq!(transcript.meeting_id, "m1");
3785 assert_eq!(transcript.sentences.len(), 1);
3786 assert_eq!(transcript.sentences[0].speaker_name, Some("Alice".into()));
3787 }
3788 other => panic!("Expected MeetingTranscript, got {:?}", other),
3789 }
3790 }
3791
3792 #[tokio::test]
3793 async fn test_dispatch_search_meeting_notes() {
3794 let provider = MockMeetingProvider;
3795 let args = serde_json::json!({"query": "sprint", "limit": 5});
3796 let result = dispatch_meeting_tool("search_meeting_notes", &args, &provider)
3797 .await
3798 .unwrap();
3799 match result {
3800 ToolOutput::MeetingNotes(meetings, _) => {
3801 assert_eq!(meetings.len(), 1);
3802 assert_eq!(meetings[0].title, "Search Result Meeting");
3803 }
3804 other => panic!("Expected MeetingNotes, got {:?}", other),
3805 }
3806 }
3807
3808 #[tokio::test]
3809 async fn test_dispatch_unknown_meeting_tool() {
3810 let provider = MockMeetingProvider;
3811 let result = dispatch_meeting_tool("nonexistent_tool", &Value::Null, &provider).await;
3812 assert!(result.is_err());
3813 }
3814
3815 fn sample_structure() -> devboy_core::Structure {
3820 devboy_core::Structure {
3821 id: 1,
3822 name: "Q1 Plan".into(),
3823 description: Some("Quarter 1 planning".into()),
3824 }
3825 }
3826
3827 fn sample_forest(structure_id: u64) -> devboy_core::StructureForest {
3828 devboy_core::StructureForest {
3829 version: 1,
3830 structure_id,
3831 tree: vec![devboy_core::StructureNode {
3832 row_id: 100,
3833 item_id: Some("PROJ-1".into()),
3834 item_type: Some("issue".into()),
3835 children: vec![],
3836 }],
3837 total_count: Some(1),
3838 }
3839 }
3840
3841 fn sample_view(structure_id: u64) -> devboy_core::StructureView {
3842 devboy_core::StructureView {
3843 id: 10,
3844 name: "Default".into(),
3845 structure_id,
3846 ..Default::default()
3847 }
3848 }
3849
3850 #[tokio::test]
3851 async fn test_dispatch_get_structures() {
3852 let provider = MockProvider;
3853 let result = dispatch_tool("get_structures", &Value::Null, &provider, None)
3854 .await
3855 .unwrap();
3856 assert!(matches!(result, ToolOutput::Structures(ref items, _) if items.len() == 1));
3857 assert_eq!(result.type_name(), "structures");
3858 }
3859
3860 #[tokio::test]
3861 async fn test_dispatch_get_structure_forest() {
3862 let provider = MockProvider;
3863 let args = serde_json::json!({"structureId": 1});
3864 let result = dispatch_tool("get_structure_forest", &args, &provider, None)
3865 .await
3866 .unwrap();
3867 assert!(matches!(result, ToolOutput::StructureForest(_)));
3868 assert_eq!(result.type_name(), "structure_forest");
3869 }
3870
3871 #[tokio::test]
3872 async fn test_dispatch_get_structure_forest_missing_id() {
3873 let provider = MockProvider;
3874 let result = dispatch_tool("get_structure_forest", &Value::Null, &provider, None).await;
3875 assert!(result.is_err());
3876 }
3877
3878 #[tokio::test]
3879 async fn test_dispatch_add_structure_rows() {
3880 let provider = MockProvider;
3881 let args = serde_json::json!({
3882 "structureId": 1,
3883 "items": ["PROJ-1", "PROJ-2"],
3884 "under": 100
3885 });
3886 let result = dispatch_tool("add_structure_rows", &args, &provider, None)
3887 .await
3888 .unwrap();
3889 match result {
3890 ToolOutput::ForestModified(r) => {
3891 assert_eq!(r.version, 2);
3892 assert_eq!(r.affected_count, 2);
3893 }
3894 _ => panic!("expected ForestModified"),
3895 }
3896 }
3897
3898 #[tokio::test]
3899 async fn test_dispatch_move_structure_rows() {
3900 let provider = MockProvider;
3901 let args = serde_json::json!({
3902 "structureId": 1,
3903 "rowIds": [100, 101],
3904 "under": 200
3905 });
3906 let result = dispatch_tool("move_structure_rows", &args, &provider, None)
3907 .await
3908 .unwrap();
3909 assert!(matches!(result, ToolOutput::ForestModified(_)));
3910 }
3911
3912 #[tokio::test]
3913 async fn test_dispatch_remove_structure_row() {
3914 let provider = MockProvider;
3915 let args = serde_json::json!({"structureId": 1, "rowId": 100});
3916 let result = dispatch_tool("remove_structure_row", &args, &provider, None)
3917 .await
3918 .unwrap();
3919 assert!(matches!(result, ToolOutput::Text(_)));
3920 }
3921
3922 #[tokio::test]
3923 async fn test_dispatch_get_structure_values() {
3924 let provider = MockProvider;
3925 let args = serde_json::json!({
3926 "structureId": 1,
3927 "rows": [100],
3928 "columns": ["summary", {"field": "status"}]
3929 });
3930 let result = dispatch_tool("get_structure_values", &args, &provider, None)
3931 .await
3932 .unwrap();
3933 assert!(matches!(result, ToolOutput::StructureValues(_)));
3934 }
3935
3936 #[tokio::test]
3937 async fn test_dispatch_get_structure_views() {
3938 let provider = MockProvider;
3939 let args = serde_json::json!({"structureId": 1});
3940 let result = dispatch_tool("get_structure_views", &args, &provider, None)
3941 .await
3942 .unwrap();
3943 assert!(matches!(result, ToolOutput::StructureViews(views, _) if views.len() == 1));
3944 }
3945
3946 #[tokio::test]
3947 async fn test_dispatch_save_structure_view() {
3948 let provider = MockProvider;
3949 let args = serde_json::json!({
3950 "structureId": 1,
3951 "name": "Sprint View"
3952 });
3953 let result = dispatch_tool("save_structure_view", &args, &provider, None)
3954 .await
3955 .unwrap();
3956 assert!(
3957 matches!(result, ToolOutput::StructureViews(views, _) if views[0].name == "Sprint View")
3958 );
3959 }
3960
3961 #[tokio::test]
3962 async fn test_dispatch_create_structure() {
3963 let provider = MockProvider;
3964 let args = serde_json::json!({"name": "New Structure", "description": "Test"});
3965 let result = dispatch_tool("create_structure", &args, &provider, None)
3966 .await
3967 .unwrap();
3968 match result {
3969 ToolOutput::Structures(items, _) => {
3970 assert_eq!(items[0].name, "New Structure");
3971 assert_eq!(items[0].id, 42);
3972 }
3973 _ => panic!("expected Structures"),
3974 }
3975 }
3976
3977 #[tokio::test]
3982 async fn test_dispatch_list_project_versions_applies_paper_defaults() {
3983 let provider = MockProvider;
3985 let result = dispatch_tool(
3986 "list_project_versions",
3987 &serde_json::json!({}),
3988 &provider,
3989 None,
3990 )
3991 .await
3992 .unwrap();
3993 match result {
3994 ToolOutput::ProjectVersions(items, _) => {
3995 let echoed = &items[0].name;
3996 assert!(echoed.contains("released=None"), "got {echoed}");
3997 assert!(echoed.contains("archived=Some(false)"), "got {echoed}");
3998 assert!(echoed.contains("limit=Some(20)"), "got {echoed}");
3999 assert!(echoed.contains("expand=false"), "got {echoed}");
4000 }
4001 other => panic!("expected ProjectVersions, got {other:?}"),
4002 }
4003 }
4004
4005 #[tokio::test]
4006 async fn test_dispatch_list_project_versions_explicit_filters_override_defaults() {
4007 let provider = MockProvider;
4008 let args = serde_json::json!({
4009 "project": "PROJ",
4010 "released": "true",
4011 "archived": "all",
4012 "limit": 5,
4013 "includeIssueCount": true,
4014 });
4015 let result = dispatch_tool("list_project_versions", &args, &provider, None)
4016 .await
4017 .unwrap();
4018 match result {
4019 ToolOutput::ProjectVersions(items, _) => {
4020 let echoed = &items[0].name;
4021 assert!(echoed.contains("released=Some(true)"), "got {echoed}");
4022 assert!(echoed.contains("archived=None"), "got {echoed}");
4023 assert!(echoed.contains("limit=Some(5)"), "got {echoed}");
4024 assert!(echoed.contains("expand=true"), "got {echoed}");
4025 assert_eq!(items[0].project, "PROJ");
4026 }
4027 other => panic!("expected ProjectVersions, got {other:?}"),
4028 }
4029 }
4030
4031 #[tokio::test]
4032 async fn test_dispatch_list_project_versions_rejects_unknown_filter() {
4033 let provider = MockProvider;
4034 let err = dispatch_tool(
4035 "list_project_versions",
4036 &serde_json::json!({"released": "maybe"}),
4037 &provider,
4038 None,
4039 )
4040 .await
4041 .unwrap_err();
4042 assert!(
4043 matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("'maybe'")),
4044 "expected InvalidData about 'maybe', got {err:?}"
4045 );
4046 }
4047
4048 #[tokio::test]
4049 async fn test_dispatch_upsert_project_version_returns_single() {
4050 let provider = MockProvider;
4051 let args = serde_json::json!({
4052 "project": "PROJ",
4053 "name": "3.18.0",
4054 "description": "release notes",
4055 "released": true,
4056 "releaseDate": "2026-05-01",
4057 });
4058 let result = dispatch_tool("upsert_project_version", &args, &provider, None)
4059 .await
4060 .unwrap();
4061 match result {
4062 ToolOutput::SingleProjectVersion(v) => {
4063 assert_eq!(v.name, "3.18.0");
4064 assert_eq!(v.project, "PROJ");
4065 assert!(v.released);
4066 assert_eq!(v.release_date.as_deref(), Some("2026-05-01"));
4067 assert_eq!(v.description.as_deref(), Some("release notes"));
4068 }
4069 other => panic!("expected SingleProjectVersion, got {other:?}"),
4070 }
4071 }
4072
4073 #[tokio::test]
4074 async fn test_dispatch_upsert_project_version_requires_name() {
4075 let provider = MockProvider;
4076 let err = dispatch_tool(
4077 "upsert_project_version",
4078 &serde_json::json!({"project": "PROJ"}),
4079 &provider,
4080 None,
4081 )
4082 .await
4083 .unwrap_err();
4084 assert!(matches!(err, devboy_core::Error::InvalidData(_)));
4085 }
4086
4087 #[test]
4088 fn parse_tri_filter_accepts_canonical_strings() {
4089 assert_eq!(parse_tri_filter(None).unwrap(), None);
4090 assert_eq!(parse_tri_filter(Some("all")).unwrap(), None);
4091 assert_eq!(parse_tri_filter(Some("True")).unwrap(), Some(true));
4092 assert_eq!(parse_tri_filter(Some("false")).unwrap(), Some(false));
4093 assert_eq!(parse_tri_filter(Some("yes")).unwrap(), Some(true));
4094 assert_eq!(parse_tri_filter(Some("0")).unwrap(), Some(false));
4095 assert!(parse_tri_filter(Some("maybe")).is_err());
4096 }
4097
4098 #[test]
4099 fn validate_iso_date_accepts_yyyy_mm_dd() {
4100 assert!(validate_iso_date("releaseDate", "2026-05-04").is_ok());
4101 assert!(validate_iso_date("releaseDate", "2026-12-31").is_ok());
4102 }
4103
4104 #[test]
4105 fn validate_iso_date_rejects_other_shapes() {
4106 assert!(validate_iso_date("releaseDate", "2026/05/04").is_err());
4108 assert!(validate_iso_date("releaseDate", "2026-5-4").is_err());
4109 assert!(validate_iso_date("releaseDate", "2026-05-04T00:00:00Z").is_err());
4110 assert!(validate_iso_date("releaseDate", "tomorrow").is_err());
4111 assert!(validate_iso_date("releaseDate", "2026-13-01").is_err());
4113 assert!(validate_iso_date("releaseDate", "2026-00-15").is_err());
4114 assert!(validate_iso_date("releaseDate", "2026-05-32").is_err());
4115 }
4116
4117 #[tokio::test]
4118 async fn test_dispatch_upsert_project_version_rejects_bad_date() {
4119 let provider = MockProvider;
4120 let err = dispatch_tool(
4121 "upsert_project_version",
4122 &serde_json::json!({"name": "3.18.0", "releaseDate": "next friday"}),
4123 &provider,
4124 None,
4125 )
4126 .await
4127 .unwrap_err();
4128 assert!(
4129 matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("releaseDate")),
4130 "expected InvalidData about releaseDate, got {err:?}"
4131 );
4132 }
4133
4134 #[tokio::test]
4135 async fn test_dispatch_list_project_versions_rejects_zero_limit() {
4136 let provider = MockProvider;
4137 let err = dispatch_tool(
4138 "list_project_versions",
4139 &serde_json::json!({"limit": 0}),
4140 &provider,
4141 None,
4142 )
4143 .await
4144 .unwrap_err();
4145 assert!(
4146 matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("limit")),
4147 "expected InvalidData about limit, got {err:?}"
4148 );
4149 }
4150
4151 #[tokio::test]
4156 async fn test_dispatch_get_board_sprints_default_state_is_all() {
4157 let provider = MockProvider;
4158 let result = dispatch_tool(
4159 "get_board_sprints",
4160 &serde_json::json!({"boardId": 7}),
4161 &provider,
4162 None,
4163 )
4164 .await
4165 .unwrap();
4166 match result {
4167 ToolOutput::Sprints(items, _) => {
4168 assert_eq!(items.len(), 1);
4169 assert!(items[0].name.contains("board=7"), "got {}", items[0].name);
4170 assert!(items[0].name.contains("state=All"), "got {}", items[0].name);
4171 }
4172 other => panic!("expected Sprints, got {other:?}"),
4173 }
4174 }
4175
4176 #[tokio::test]
4177 async fn test_dispatch_get_board_sprints_state_filter_round_trips() {
4178 let provider = MockProvider;
4179 let result = dispatch_tool(
4180 "get_board_sprints",
4181 &serde_json::json!({"boardId": 9, "state": "active"}),
4182 &provider,
4183 None,
4184 )
4185 .await
4186 .unwrap();
4187 match result {
4188 ToolOutput::Sprints(items, _) => {
4189 assert!(
4190 items[0].name.contains("state=Active"),
4191 "got {}",
4192 items[0].name
4193 );
4194 }
4195 other => panic!("expected Sprints, got {other:?}"),
4196 }
4197 }
4198
4199 #[tokio::test]
4200 async fn test_dispatch_get_board_sprints_rejects_unknown_state() {
4201 let provider = MockProvider;
4202 let err = dispatch_tool(
4203 "get_board_sprints",
4204 &serde_json::json!({"boardId": 1, "state": "wat"}),
4205 &provider,
4206 None,
4207 )
4208 .await
4209 .unwrap_err();
4210 assert!(
4211 matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("wat")),
4212 "expected InvalidData mentioning the bad value, got {err:?}"
4213 );
4214 }
4215
4216 #[tokio::test]
4217 async fn test_dispatch_assign_to_sprint_returns_text_summary() {
4218 let provider = MockProvider;
4219 let result = dispatch_tool(
4220 "assign_to_sprint",
4221 &serde_json::json!({
4222 "sprintId": 42,
4223 "issueKeys": ["PROJ-1", "PROJ-2"],
4224 }),
4225 &provider,
4226 None,
4227 )
4228 .await
4229 .unwrap();
4230 match result {
4231 ToolOutput::Text(msg) => {
4232 assert!(msg.contains("2 issue"), "got {msg}");
4233 assert!(msg.contains("42"), "got {msg}");
4234 }
4235 other => panic!("expected Text, got {other:?}"),
4236 }
4237 }
4238
4239 #[tokio::test]
4240 async fn test_dispatch_assign_to_sprint_rejects_empty_issue_keys() {
4241 let provider = MockProvider;
4242 let err = dispatch_tool(
4243 "assign_to_sprint",
4244 &serde_json::json!({"sprintId": 1, "issueKeys": []}),
4245 &provider,
4246 None,
4247 )
4248 .await
4249 .unwrap_err();
4250 assert!(
4251 matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("issueKeys")),
4252 "expected InvalidData about issueKeys, got {err:?}"
4253 );
4254 }
4255
4256 #[tokio::test]
4261 async fn test_dispatch_get_custom_fields_returns_all_entries_by_default() {
4262 let provider = MockProvider;
4263 let result = dispatch_tool("get_custom_fields", &serde_json::json!({}), &provider, None)
4264 .await
4265 .unwrap();
4266 match result {
4267 ToolOutput::CustomFields(items, _) => {
4268 assert_eq!(items.len(), 3);
4269 let names: Vec<_> = items.iter().map(|f| f.name.as_str()).collect();
4270 assert!(names.contains(&"Epic Link"));
4271 assert!(names.contains(&"Sprint"));
4272 }
4273 other => panic!("expected CustomFields, got {other:?}"),
4274 }
4275 }
4276
4277 #[tokio::test]
4278 async fn test_dispatch_get_custom_fields_search_filters_by_substring() {
4279 let provider = MockProvider;
4280 let result = dispatch_tool(
4281 "get_custom_fields",
4282 &serde_json::json!({"search": "epic"}),
4283 &provider,
4284 None,
4285 )
4286 .await
4287 .unwrap();
4288 match result {
4289 ToolOutput::CustomFields(items, _) => {
4290 assert_eq!(items.len(), 2);
4291 for f in items {
4292 assert!(f.name.to_lowercase().contains("epic"));
4293 }
4294 }
4295 other => panic!("expected CustomFields, got {other:?}"),
4296 }
4297 }
4298
4299 #[tokio::test]
4300 async fn test_dispatch_get_custom_fields_rejects_zero_limit() {
4301 let provider = MockProvider;
4302 let err = dispatch_tool(
4303 "get_custom_fields",
4304 &serde_json::json!({"limit": 0}),
4305 &provider,
4306 None,
4307 )
4308 .await
4309 .unwrap_err();
4310 assert!(
4311 matches!(err, devboy_core::Error::InvalidData(ref m) if m.contains("limit")),
4312 "expected InvalidData about limit, got {err:?}"
4313 );
4314 }
4315
4316 #[test]
4321 fn parse_row_item_bare_string_becomes_item_id() {
4322 let item = parse_structure_row_item(serde_json::json!("PROJ-1")).unwrap();
4323 assert_eq!(item.item_id, "PROJ-1");
4324 assert!(item.item_type.is_none());
4325 }
4326
4327 #[test]
4328 fn parse_row_item_json_object_string_parses_fields() {
4329 let item = parse_structure_row_item(serde_json::json!(
4330 "{\"item_id\":\"PROJ-2\",\"item_type\":\"issue\"}"
4331 ))
4332 .unwrap();
4333 assert_eq!(item.item_id, "PROJ-2");
4334 assert_eq!(item.item_type.as_deref(), Some("issue"));
4335 }
4336
4337 #[test]
4338 fn parse_row_item_malformed_json_object_is_error() {
4339 let err = parse_structure_row_item(serde_json::json!("{\"wrong\":true}")).unwrap_err();
4341 assert!(matches!(err, Error::InvalidData(_)));
4342 }
4343
4344 #[test]
4345 fn parse_column_spec_bare_string_sets_field() {
4346 let col = parse_structure_column_spec(serde_json::json!("summary")).unwrap();
4347 assert_eq!(col.field.as_deref(), Some("summary"));
4348 assert!(col.formula.is_none());
4349 }
4350
4351 #[test]
4352 fn parse_column_spec_formula_json_string_parses() {
4353 let col = parse_structure_column_spec(serde_json::json!(
4354 "{\"formula\":\"SUM(\\\"Story Points\\\")\"}"
4355 ))
4356 .unwrap();
4357 assert!(col.field.is_none());
4358 assert_eq!(col.formula.as_deref(), Some("SUM(\"Story Points\")"));
4359 }
4360
4361 #[test]
4362 fn parse_column_spec_object_value_is_deserialised() {
4363 let col = parse_structure_column_spec(serde_json::json!({"field": "status", "width": 120}))
4365 .unwrap();
4366 assert_eq!(col.field.as_deref(), Some("status"));
4367 assert_eq!(col.width, Some(120));
4368 }
4369}