Skip to main content

devboy_executor/
output.rs

1use devboy_core::{
2    Comment, CustomFieldDescriptor, Discussion, FileDiff, ForestModifyResult, Issue,
3    IssueRelations, IssueStatus, JobLogOutput, KbPage, KbPageContent, KbSpace, MeetingNote,
4    MeetingTranscript, MergeRequest, MessengerChat, MessengerMessage, Pagination, PipelineInfo,
5    ProjectVersion, SortInfo, Sprint, Structure, StructureForest, StructureValues, StructureView,
6    User,
7};
8
9/// Metadata from provider result (pagination + sort info).
10#[derive(Debug, Clone, Default)]
11pub struct ResultMeta {
12    pub pagination: Option<Pagination>,
13    pub sort_info: Option<SortInfo>,
14}
15
16/// Typed result of tool execution.
17///
18/// Each variant carries structured data from the provider.
19/// The caller (MCP server, NAPI bridge, HTTP handler) decides
20/// how to format the output (pipeline text, JSON, etc.).
21#[derive(Debug)]
22pub enum ToolOutput {
23    MergeRequests(Vec<MergeRequest>, Option<ResultMeta>),
24    SingleMergeRequest(Box<MergeRequest>),
25    /// MR/PR discussions with comments and code positions
26    Discussions(Vec<Discussion>, Option<ResultMeta>),
27    Diffs(Vec<FileDiff>, Option<ResultMeta>),
28    /// List of issues / tasks
29    Issues(Vec<Issue>, Option<ResultMeta>),
30    /// Single issue / task
31    SingleIssue(Box<Issue>),
32    Comments(Vec<Comment>, Option<ResultMeta>),
33    /// CI/CD pipeline status with jobs
34    Pipeline(Box<PipelineInfo>),
35    JobLog(Box<JobLogOutput>),
36    Statuses(Vec<IssueStatus>, Option<ResultMeta>),
37    Users(Vec<User>, Option<ResultMeta>),
38    MeetingNotes(Vec<MeetingNote>, Option<ResultMeta>),
39    /// Single meeting transcript with sentences
40    MeetingTranscript(Box<MeetingTranscript>),
41    KnowledgeBaseSpaces(Vec<KbSpace>, Option<ResultMeta>),
42    KnowledgeBasePages(Vec<KbPage>, Option<ResultMeta>),
43    /// Single knowledge base page summary
44    KnowledgeBasePageSummary(Box<KbPage>),
45    /// Single knowledge base page with content
46    KnowledgeBasePage(Box<KbPageContent>),
47    /// Issue relations (parent, subtasks, linked issues)
48    Relations(Box<IssueRelations>),
49    MessengerChats(Vec<MessengerChat>, Option<ResultMeta>),
50    MessengerMessages(Vec<MessengerMessage>, Option<ResultMeta>),
51    /// Single sent message
52    SingleMessage(Box<MessengerMessage>),
53    AssetList {
54        /// Serialized attachment objects from the provider.
55        attachments: Vec<serde_json::Value>,
56        /// Number of attachments.
57        count: usize,
58        /// Provider-reported asset capabilities.
59        capabilities: serde_json::Value,
60    },
61    /// Asset downloaded (cached locally or base64)
62    AssetDownloaded {
63        /// Provider-specific asset identifier.
64        asset_id: String,
65        size: usize,
66        /// Absolute path when cached locally.
67        local_path: Option<String>,
68        /// Base64-encoded content when no cache is available.
69        data: Option<String>,
70        /// Whether the result came from local cache.
71        cached: bool,
72    },
73    AssetUploaded {
74        /// Provider-returned URL for the uploaded file.
75        url: String,
76        filename: String,
77        size: usize,
78    },
79    AssetDeleted {
80        /// Deleted asset identifier.
81        asset_id: String,
82        /// Human-readable confirmation message.
83        message: String,
84    },
85    Structures(Vec<Structure>, Option<ResultMeta>),
86    /// List of project versions / fixVersion targets (Jira releases)
87    ProjectVersions(Vec<ProjectVersion>, Option<ResultMeta>),
88    /// Single project version (returned by upsert_project_version)
89    SingleProjectVersion(Box<ProjectVersion>),
90    /// Sprints visible on a Jira agile board (issue #198)
91    Sprints(Vec<Sprint>, Option<ResultMeta>),
92    /// Custom-field descriptors discovered on the issue tracker
93    CustomFields(Vec<CustomFieldDescriptor>, Option<ResultMeta>),
94    /// Structure forest (hierarchy tree)
95    StructureForest(Box<StructureForest>),
96    /// Structure column values
97    StructureValues(Box<StructureValues>),
98    StructureViews(Vec<StructureView>, Option<ResultMeta>),
99    /// Forest modification result (add/move rows)
100    ForestModified(ForestModifyResult),
101    /// Plain text result (e.g., "Comment created successfully")
102    Text(String),
103}
104
105impl ToolOutput {
106    /// Returns the number of items in collection outputs, or 1 for single items.
107    pub fn item_count(&self) -> usize {
108        match self {
109            Self::MergeRequests(v, _) => v.len(),
110            Self::Discussions(v, _) => v.len(),
111            Self::Diffs(v, _) => v.len(),
112            Self::Issues(v, _) => v.len(),
113            Self::Comments(v, _) => v.len(),
114            Self::Statuses(v, _) => v.len(),
115            Self::Users(v, _) => v.len(),
116            Self::MeetingNotes(v, _) => v.len(),
117            Self::KnowledgeBaseSpaces(v, _) => v.len(),
118            Self::KnowledgeBasePages(v, _) => v.len(),
119            Self::MessengerChats(v, _) => v.len(),
120            Self::MessengerMessages(v, _) => v.len(),
121            Self::Structures(v, _) => v.len(),
122            Self::StructureViews(v, _) => v.len(),
123            Self::ProjectVersions(v, _) => v.len(),
124            Self::Sprints(v, _) => v.len(),
125            Self::CustomFields(v, _) => v.len(),
126            Self::AssetList { count, .. } => *count,
127            Self::SingleMergeRequest(_)
128            | Self::SingleIssue(_)
129            | Self::Pipeline(_)
130            | Self::JobLog(_)
131            | Self::MeetingTranscript(_)
132            | Self::KnowledgeBasePageSummary(_)
133            | Self::KnowledgeBasePage(_)
134            | Self::Relations(_)
135            | Self::SingleMessage(_)
136            | Self::StructureForest(_)
137            | Self::StructureValues(_)
138            | Self::ForestModified(_)
139            | Self::SingleProjectVersion(_)
140            | Self::AssetDownloaded { .. }
141            | Self::AssetUploaded { .. }
142            | Self::AssetDeleted { .. }
143            | Self::Text(_) => 1,
144        }
145    }
146
147    /// Returns a human-readable type name for this output.
148    pub fn type_name(&self) -> &'static str {
149        match self {
150            Self::MergeRequests(..) => "merge_requests",
151            Self::SingleMergeRequest(_) => "merge_request",
152            Self::Discussions(..) => "discussions",
153            Self::Diffs(..) => "diffs",
154            Self::Issues(..) => "issues",
155            Self::SingleIssue(_) => "issue",
156            Self::Comments(..) => "comments",
157            Self::Pipeline(_) => "pipeline",
158            Self::JobLog(_) => "job_log",
159            Self::Statuses(..) => "statuses",
160            Self::Users(..) => "users",
161            Self::MeetingNotes(..) => "meeting_notes",
162            Self::MeetingTranscript(_) => "meeting_transcript",
163            Self::KnowledgeBaseSpaces(..) => "knowledge_base_spaces",
164            Self::KnowledgeBasePages(..) => "knowledge_base_pages",
165            Self::KnowledgeBasePageSummary(_) => "knowledge_base_page_summary",
166            Self::KnowledgeBasePage(_) => "knowledge_base_page",
167            Self::Relations(_) => "issue_relations",
168            Self::MessengerChats(..) => "messenger_chats",
169            Self::MessengerMessages(..) => "messenger_messages",
170            Self::SingleMessage(_) => "messenger_message",
171            Self::Structures(..) => "structures",
172            Self::StructureForest(_) => "structure_forest",
173            Self::StructureValues(_) => "structure_values",
174            Self::StructureViews(..) => "structure_views",
175            Self::ForestModified(_) => "forest_modified",
176            Self::ProjectVersions(..) => "project_versions",
177            Self::SingleProjectVersion(_) => "project_version",
178            Self::Sprints(..) => "sprints",
179            Self::CustomFields(..) => "custom_fields",
180            Self::AssetList { .. } => "asset_list",
181            Self::AssetDownloaded { .. } => "asset_downloaded",
182            Self::AssetUploaded { .. } => "asset_uploaded",
183            Self::AssetDeleted { .. } => "asset_deleted",
184            Self::Text(_) => "text",
185        }
186    }
187
188    /// Returns the result metadata (pagination + sort info) if present.
189    pub fn result_meta(&self) -> Option<&ResultMeta> {
190        match self {
191            Self::MergeRequests(_, meta)
192            | Self::Discussions(_, meta)
193            | Self::Diffs(_, meta)
194            | Self::Issues(_, meta)
195            | Self::Comments(_, meta)
196            | Self::Statuses(_, meta)
197            | Self::Users(_, meta)
198            | Self::MeetingNotes(_, meta)
199            | Self::KnowledgeBaseSpaces(_, meta)
200            | Self::KnowledgeBasePages(_, meta)
201            | Self::MessengerChats(_, meta)
202            | Self::MessengerMessages(_, meta)
203            | Self::Structures(_, meta)
204            | Self::StructureViews(_, meta)
205            | Self::ProjectVersions(_, meta)
206            | Self::Sprints(_, meta)
207            | Self::CustomFields(_, meta) => meta.as_ref(),
208            _ => None,
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use devboy_core::{Issue, IssueStatus, KbPage, KbPageContent, KbSpace, MergeRequest, User};
217
218    fn issue() -> Issue {
219        Issue {
220            key: "gh#1".into(),
221            title: "T".into(),
222            description: None,
223            state: "open".into(),
224            source: "mock".into(),
225            priority: None,
226            labels: vec![],
227            author: None,
228            assignees: vec![],
229            url: None,
230            created_at: None,
231            updated_at: None,
232            attachments_count: None,
233            parent: None,
234            subtasks: vec![],
235            custom_fields: std::collections::HashMap::new(),
236            ..Default::default()
237        }
238    }
239
240    fn mr() -> MergeRequest {
241        MergeRequest {
242            key: "pr#1".into(),
243            title: "T".into(),
244            description: None,
245            state: "open".into(),
246            source: "mock".into(),
247            source_branch: "f".into(),
248            target_branch: "m".into(),
249            author: None,
250            assignees: vec![],
251            reviewers: vec![],
252            labels: vec![],
253            draft: false,
254            url: None,
255            created_at: None,
256            updated_at: None,
257        }
258    }
259
260    fn kb_space() -> KbSpace {
261        KbSpace {
262            id: "space-1".into(),
263            key: "ENG".into(),
264            name: "Engineering".into(),
265            ..Default::default()
266        }
267    }
268
269    fn kb_page() -> KbPage {
270        KbPage {
271            id: "page-1".into(),
272            title: "Architecture".into(),
273            space_key: Some("ENG".into()),
274            ..Default::default()
275        }
276    }
277
278    fn kb_page_content() -> KbPageContent {
279        KbPageContent {
280            page: kb_page(),
281            content: "<p>hello</p>".into(),
282            content_type: "storage".into(),
283            ancestors: vec![],
284            labels: vec!["docs".into()],
285        }
286    }
287
288    #[test]
289    fn test_item_count_all_variants() {
290        assert_eq!(
291            ToolOutput::Issues(vec![issue(), issue()], None).item_count(),
292            2
293        );
294        assert_eq!(ToolOutput::MergeRequests(vec![], None).item_count(), 0);
295        assert_eq!(ToolOutput::SingleIssue(Box::new(issue())).item_count(), 1);
296        assert_eq!(
297            ToolOutput::SingleMergeRequest(Box::new(mr())).item_count(),
298            1
299        );
300        assert_eq!(ToolOutput::Discussions(vec![], None).item_count(), 0);
301        assert_eq!(ToolOutput::Diffs(vec![], None).item_count(), 0);
302        assert_eq!(ToolOutput::Comments(vec![], None).item_count(), 0);
303        assert_eq!(
304            ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
305                id: "1".into(),
306                status: devboy_core::PipelineStatus::Success,
307                reference: "main".into(),
308                sha: "abc".into(),
309                url: None,
310                duration: None,
311                coverage: None,
312                summary: devboy_core::PipelineSummary::default(),
313                stages: vec![],
314                failed_jobs: vec![],
315            }))
316            .item_count(),
317            1
318        );
319        assert_eq!(
320            ToolOutput::JobLog(Box::new(devboy_core::JobLogOutput {
321                job_id: "1".into(),
322                job_name: None,
323                content: "log".into(),
324                mode: "smart".into(),
325                total_lines: None,
326            }))
327            .item_count(),
328            1
329        );
330        assert_eq!(
331            ToolOutput::Statuses(
332                vec![IssueStatus {
333                    id: "1".into(),
334                    name: "Open".into(),
335                    category: "open".into(),
336                    color: None,
337                    order: None,
338                }],
339                None
340            )
341            .item_count(),
342            1
343        );
344        assert_eq!(ToolOutput::Statuses(vec![], None).item_count(), 0);
345        assert_eq!(
346            ToolOutput::Users(
347                vec![User {
348                    id: "1".into(),
349                    username: "test".into(),
350                    name: None,
351                    email: None,
352                    avatar_url: None,
353                }],
354                None
355            )
356            .item_count(),
357            1
358        );
359        assert_eq!(ToolOutput::Users(vec![], None).item_count(), 0);
360        assert_eq!(
361            ToolOutput::KnowledgeBaseSpaces(vec![kb_space()], None).item_count(),
362            1
363        );
364        assert_eq!(
365            ToolOutput::KnowledgeBasePages(vec![kb_page()], None).item_count(),
366            1
367        );
368        assert_eq!(
369            ToolOutput::KnowledgeBasePageSummary(Box::new(kb_page())).item_count(),
370            1
371        );
372        assert_eq!(
373            ToolOutput::KnowledgeBasePage(Box::new(kb_page_content())).item_count(),
374            1
375        );
376        assert_eq!(ToolOutput::Relations(Box::default()).item_count(), 1);
377        assert_eq!(ToolOutput::Structures(vec![], None).item_count(), 0);
378        assert_eq!(
379            ToolOutput::Structures(
380                vec![devboy_core::Structure {
381                    id: 1,
382                    name: "S".into(),
383                    description: None
384                }],
385                None
386            )
387            .item_count(),
388            1
389        );
390        assert_eq!(
391            ToolOutput::StructureForest(Box::<devboy_core::StructureForest>::default())
392                .item_count(),
393            1
394        );
395        assert_eq!(
396            ToolOutput::StructureValues(Box::<devboy_core::StructureValues>::default())
397                .item_count(),
398            1
399        );
400        assert_eq!(ToolOutput::StructureViews(vec![], None).item_count(), 0);
401        assert_eq!(
402            ToolOutput::ForestModified(devboy_core::ForestModifyResult::default()).item_count(),
403            1
404        );
405        assert_eq!(ToolOutput::Text("x".into()).item_count(), 1);
406    }
407
408    #[test]
409    fn test_type_name_all_variants() {
410        assert_eq!(ToolOutput::Issues(vec![], None).type_name(), "issues");
411        assert_eq!(
412            ToolOutput::MergeRequests(vec![], None).type_name(),
413            "merge_requests"
414        );
415        assert_eq!(
416            ToolOutput::SingleIssue(Box::new(issue())).type_name(),
417            "issue"
418        );
419        assert_eq!(
420            ToolOutput::SingleMergeRequest(Box::new(mr())).type_name(),
421            "merge_request"
422        );
423        assert_eq!(
424            ToolOutput::Discussions(vec![], None).type_name(),
425            "discussions"
426        );
427        assert_eq!(ToolOutput::Diffs(vec![], None).type_name(), "diffs");
428        assert_eq!(ToolOutput::Comments(vec![], None).type_name(), "comments");
429        assert_eq!(
430            ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
431                id: "1".into(),
432                status: devboy_core::PipelineStatus::Success,
433                reference: "main".into(),
434                sha: "abc".into(),
435                url: None,
436                duration: None,
437                coverage: None,
438                summary: devboy_core::PipelineSummary::default(),
439                stages: vec![],
440                failed_jobs: vec![],
441            }))
442            .type_name(),
443            "pipeline"
444        );
445        assert_eq!(
446            ToolOutput::JobLog(Box::new(devboy_core::JobLogOutput {
447                job_id: "1".into(),
448                job_name: None,
449                content: "log".into(),
450                mode: "smart".into(),
451                total_lines: None,
452            }))
453            .type_name(),
454            "job_log"
455        );
456        assert_eq!(ToolOutput::Statuses(vec![], None).type_name(), "statuses");
457        assert_eq!(ToolOutput::Users(vec![], None).type_name(), "users");
458        assert_eq!(
459            ToolOutput::KnowledgeBaseSpaces(vec![], None).type_name(),
460            "knowledge_base_spaces"
461        );
462        assert_eq!(
463            ToolOutput::KnowledgeBasePages(vec![], None).type_name(),
464            "knowledge_base_pages"
465        );
466        assert_eq!(
467            ToolOutput::KnowledgeBasePageSummary(Box::new(kb_page())).type_name(),
468            "knowledge_base_page_summary"
469        );
470        assert_eq!(
471            ToolOutput::KnowledgeBasePage(Box::new(kb_page_content())).type_name(),
472            "knowledge_base_page"
473        );
474        assert_eq!(
475            ToolOutput::Relations(Box::default()).type_name(),
476            "issue_relations"
477        );
478        assert_eq!(ToolOutput::Text("x".into()).type_name(), "text");
479        assert_eq!(
480            ToolOutput::Structures(vec![], None).type_name(),
481            "structures"
482        );
483        assert_eq!(
484            ToolOutput::StructureForest(Box::<devboy_core::StructureForest>::default()).type_name(),
485            "structure_forest"
486        );
487        assert_eq!(
488            ToolOutput::StructureValues(Box::<devboy_core::StructureValues>::default()).type_name(),
489            "structure_values"
490        );
491        assert_eq!(
492            ToolOutput::StructureViews(vec![], None).type_name(),
493            "structure_views"
494        );
495        assert_eq!(
496            ToolOutput::ForestModified(devboy_core::ForestModifyResult::default()).type_name(),
497            "forest_modified"
498        );
499    }
500
501    #[test]
502    fn test_result_meta_present() {
503        let meta = ResultMeta {
504            pagination: Some(devboy_core::Pagination {
505                offset: 0,
506                limit: 10,
507                total: Some(50),
508                has_more: true,
509                next_cursor: None,
510            }),
511            sort_info: Some(devboy_core::SortInfo {
512                sort_by: Some("created_at".into()),
513                sort_order: devboy_core::SortOrder::Desc,
514                available_sorts: vec!["created_at".into()],
515            }),
516        };
517
518        let output = ToolOutput::Issues(vec![issue()], Some(meta));
519        let rm = output.result_meta().unwrap();
520        assert!(rm.pagination.is_some());
521        assert!(rm.sort_info.is_some());
522        assert_eq!(rm.pagination.as_ref().unwrap().total, Some(50));
523    }
524
525    #[test]
526    fn test_result_meta_none_for_single_variants() {
527        assert!(
528            ToolOutput::SingleIssue(Box::new(issue()))
529                .result_meta()
530                .is_none()
531        );
532        assert!(
533            ToolOutput::SingleMergeRequest(Box::new(mr()))
534                .result_meta()
535                .is_none()
536        );
537        assert!(ToolOutput::Text("hello".into()).result_meta().is_none());
538        assert!(
539            ToolOutput::Relations(Box::default())
540                .result_meta()
541                .is_none()
542        );
543    }
544
545    #[test]
546    fn test_result_meta_none_when_not_provided() {
547        assert!(ToolOutput::Issues(vec![], None).result_meta().is_none());
548        assert!(
549            ToolOutput::MergeRequests(vec![], None)
550                .result_meta()
551                .is_none()
552        );
553        assert!(
554            ToolOutput::Discussions(vec![], None)
555                .result_meta()
556                .is_none()
557        );
558        assert!(ToolOutput::Diffs(vec![], None).result_meta().is_none());
559        assert!(ToolOutput::Comments(vec![], None).result_meta().is_none());
560        assert!(ToolOutput::Statuses(vec![], None).result_meta().is_none());
561        assert!(ToolOutput::Users(vec![], None).result_meta().is_none());
562        assert!(
563            ToolOutput::MeetingNotes(vec![], None)
564                .result_meta()
565                .is_none()
566        );
567        assert!(
568            ToolOutput::KnowledgeBaseSpaces(vec![], None)
569                .result_meta()
570                .is_none()
571        );
572        assert!(
573            ToolOutput::KnowledgeBasePages(vec![], None)
574                .result_meta()
575                .is_none()
576        );
577        assert!(ToolOutput::Structures(vec![], None).result_meta().is_none());
578        assert!(
579            ToolOutput::StructureViews(vec![], None)
580                .result_meta()
581                .is_none()
582        );
583        assert!(
584            ToolOutput::StructureForest(Box::<devboy_core::StructureForest>::default())
585                .result_meta()
586                .is_none()
587        );
588    }
589}