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        }
237    }
238
239    fn mr() -> MergeRequest {
240        MergeRequest {
241            key: "pr#1".into(),
242            title: "T".into(),
243            description: None,
244            state: "open".into(),
245            source: "mock".into(),
246            source_branch: "f".into(),
247            target_branch: "m".into(),
248            author: None,
249            assignees: vec![],
250            reviewers: vec![],
251            labels: vec![],
252            draft: false,
253            url: None,
254            created_at: None,
255            updated_at: None,
256        }
257    }
258
259    fn kb_space() -> KbSpace {
260        KbSpace {
261            id: "space-1".into(),
262            key: "ENG".into(),
263            name: "Engineering".into(),
264            ..Default::default()
265        }
266    }
267
268    fn kb_page() -> KbPage {
269        KbPage {
270            id: "page-1".into(),
271            title: "Architecture".into(),
272            space_key: Some("ENG".into()),
273            ..Default::default()
274        }
275    }
276
277    fn kb_page_content() -> KbPageContent {
278        KbPageContent {
279            page: kb_page(),
280            content: "<p>hello</p>".into(),
281            content_type: "storage".into(),
282            ancestors: vec![],
283            labels: vec!["docs".into()],
284        }
285    }
286
287    #[test]
288    fn test_item_count_all_variants() {
289        assert_eq!(
290            ToolOutput::Issues(vec![issue(), issue()], None).item_count(),
291            2
292        );
293        assert_eq!(ToolOutput::MergeRequests(vec![], None).item_count(), 0);
294        assert_eq!(ToolOutput::SingleIssue(Box::new(issue())).item_count(), 1);
295        assert_eq!(
296            ToolOutput::SingleMergeRequest(Box::new(mr())).item_count(),
297            1
298        );
299        assert_eq!(ToolOutput::Discussions(vec![], None).item_count(), 0);
300        assert_eq!(ToolOutput::Diffs(vec![], None).item_count(), 0);
301        assert_eq!(ToolOutput::Comments(vec![], None).item_count(), 0);
302        assert_eq!(
303            ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
304                id: "1".into(),
305                status: devboy_core::PipelineStatus::Success,
306                reference: "main".into(),
307                sha: "abc".into(),
308                url: None,
309                duration: None,
310                coverage: None,
311                summary: devboy_core::PipelineSummary::default(),
312                stages: vec![],
313                failed_jobs: vec![],
314            }))
315            .item_count(),
316            1
317        );
318        assert_eq!(
319            ToolOutput::JobLog(Box::new(devboy_core::JobLogOutput {
320                job_id: "1".into(),
321                job_name: None,
322                content: "log".into(),
323                mode: "smart".into(),
324                total_lines: None,
325            }))
326            .item_count(),
327            1
328        );
329        assert_eq!(
330            ToolOutput::Statuses(
331                vec![IssueStatus {
332                    id: "1".into(),
333                    name: "Open".into(),
334                    category: "open".into(),
335                    color: None,
336                    order: None,
337                }],
338                None
339            )
340            .item_count(),
341            1
342        );
343        assert_eq!(ToolOutput::Statuses(vec![], None).item_count(), 0);
344        assert_eq!(
345            ToolOutput::Users(
346                vec![User {
347                    id: "1".into(),
348                    username: "test".into(),
349                    name: None,
350                    email: None,
351                    avatar_url: None,
352                }],
353                None
354            )
355            .item_count(),
356            1
357        );
358        assert_eq!(ToolOutput::Users(vec![], None).item_count(), 0);
359        assert_eq!(
360            ToolOutput::KnowledgeBaseSpaces(vec![kb_space()], None).item_count(),
361            1
362        );
363        assert_eq!(
364            ToolOutput::KnowledgeBasePages(vec![kb_page()], None).item_count(),
365            1
366        );
367        assert_eq!(
368            ToolOutput::KnowledgeBasePageSummary(Box::new(kb_page())).item_count(),
369            1
370        );
371        assert_eq!(
372            ToolOutput::KnowledgeBasePage(Box::new(kb_page_content())).item_count(),
373            1
374        );
375        assert_eq!(ToolOutput::Relations(Box::default()).item_count(), 1);
376        assert_eq!(ToolOutput::Structures(vec![], None).item_count(), 0);
377        assert_eq!(
378            ToolOutput::Structures(
379                vec![devboy_core::Structure {
380                    id: 1,
381                    name: "S".into(),
382                    description: None
383                }],
384                None
385            )
386            .item_count(),
387            1
388        );
389        assert_eq!(
390            ToolOutput::StructureForest(Box::<devboy_core::StructureForest>::default())
391                .item_count(),
392            1
393        );
394        assert_eq!(
395            ToolOutput::StructureValues(Box::<devboy_core::StructureValues>::default())
396                .item_count(),
397            1
398        );
399        assert_eq!(ToolOutput::StructureViews(vec![], None).item_count(), 0);
400        assert_eq!(
401            ToolOutput::ForestModified(devboy_core::ForestModifyResult::default()).item_count(),
402            1
403        );
404        assert_eq!(ToolOutput::Text("x".into()).item_count(), 1);
405    }
406
407    #[test]
408    fn test_type_name_all_variants() {
409        assert_eq!(ToolOutput::Issues(vec![], None).type_name(), "issues");
410        assert_eq!(
411            ToolOutput::MergeRequests(vec![], None).type_name(),
412            "merge_requests"
413        );
414        assert_eq!(
415            ToolOutput::SingleIssue(Box::new(issue())).type_name(),
416            "issue"
417        );
418        assert_eq!(
419            ToolOutput::SingleMergeRequest(Box::new(mr())).type_name(),
420            "merge_request"
421        );
422        assert_eq!(
423            ToolOutput::Discussions(vec![], None).type_name(),
424            "discussions"
425        );
426        assert_eq!(ToolOutput::Diffs(vec![], None).type_name(), "diffs");
427        assert_eq!(ToolOutput::Comments(vec![], None).type_name(), "comments");
428        assert_eq!(
429            ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
430                id: "1".into(),
431                status: devboy_core::PipelineStatus::Success,
432                reference: "main".into(),
433                sha: "abc".into(),
434                url: None,
435                duration: None,
436                coverage: None,
437                summary: devboy_core::PipelineSummary::default(),
438                stages: vec![],
439                failed_jobs: vec![],
440            }))
441            .type_name(),
442            "pipeline"
443        );
444        assert_eq!(
445            ToolOutput::JobLog(Box::new(devboy_core::JobLogOutput {
446                job_id: "1".into(),
447                job_name: None,
448                content: "log".into(),
449                mode: "smart".into(),
450                total_lines: None,
451            }))
452            .type_name(),
453            "job_log"
454        );
455        assert_eq!(ToolOutput::Statuses(vec![], None).type_name(), "statuses");
456        assert_eq!(ToolOutput::Users(vec![], None).type_name(), "users");
457        assert_eq!(
458            ToolOutput::KnowledgeBaseSpaces(vec![], None).type_name(),
459            "knowledge_base_spaces"
460        );
461        assert_eq!(
462            ToolOutput::KnowledgeBasePages(vec![], None).type_name(),
463            "knowledge_base_pages"
464        );
465        assert_eq!(
466            ToolOutput::KnowledgeBasePageSummary(Box::new(kb_page())).type_name(),
467            "knowledge_base_page_summary"
468        );
469        assert_eq!(
470            ToolOutput::KnowledgeBasePage(Box::new(kb_page_content())).type_name(),
471            "knowledge_base_page"
472        );
473        assert_eq!(
474            ToolOutput::Relations(Box::default()).type_name(),
475            "issue_relations"
476        );
477        assert_eq!(ToolOutput::Text("x".into()).type_name(), "text");
478        assert_eq!(
479            ToolOutput::Structures(vec![], None).type_name(),
480            "structures"
481        );
482        assert_eq!(
483            ToolOutput::StructureForest(Box::<devboy_core::StructureForest>::default()).type_name(),
484            "structure_forest"
485        );
486        assert_eq!(
487            ToolOutput::StructureValues(Box::<devboy_core::StructureValues>::default()).type_name(),
488            "structure_values"
489        );
490        assert_eq!(
491            ToolOutput::StructureViews(vec![], None).type_name(),
492            "structure_views"
493        );
494        assert_eq!(
495            ToolOutput::ForestModified(devboy_core::ForestModifyResult::default()).type_name(),
496            "forest_modified"
497        );
498    }
499
500    #[test]
501    fn test_result_meta_present() {
502        let meta = ResultMeta {
503            pagination: Some(devboy_core::Pagination {
504                offset: 0,
505                limit: 10,
506                total: Some(50),
507                has_more: true,
508                next_cursor: None,
509            }),
510            sort_info: Some(devboy_core::SortInfo {
511                sort_by: Some("created_at".into()),
512                sort_order: devboy_core::SortOrder::Desc,
513                available_sorts: vec!["created_at".into()],
514            }),
515        };
516
517        let output = ToolOutput::Issues(vec![issue()], Some(meta));
518        let rm = output.result_meta().unwrap();
519        assert!(rm.pagination.is_some());
520        assert!(rm.sort_info.is_some());
521        assert_eq!(rm.pagination.as_ref().unwrap().total, Some(50));
522    }
523
524    #[test]
525    fn test_result_meta_none_for_single_variants() {
526        assert!(
527            ToolOutput::SingleIssue(Box::new(issue()))
528                .result_meta()
529                .is_none()
530        );
531        assert!(
532            ToolOutput::SingleMergeRequest(Box::new(mr()))
533                .result_meta()
534                .is_none()
535        );
536        assert!(ToolOutput::Text("hello".into()).result_meta().is_none());
537        assert!(
538            ToolOutput::Relations(Box::default())
539                .result_meta()
540                .is_none()
541        );
542    }
543
544    #[test]
545    fn test_result_meta_none_when_not_provided() {
546        assert!(ToolOutput::Issues(vec![], None).result_meta().is_none());
547        assert!(
548            ToolOutput::MergeRequests(vec![], None)
549                .result_meta()
550                .is_none()
551        );
552        assert!(
553            ToolOutput::Discussions(vec![], None)
554                .result_meta()
555                .is_none()
556        );
557        assert!(ToolOutput::Diffs(vec![], None).result_meta().is_none());
558        assert!(ToolOutput::Comments(vec![], None).result_meta().is_none());
559        assert!(ToolOutput::Statuses(vec![], None).result_meta().is_none());
560        assert!(ToolOutput::Users(vec![], None).result_meta().is_none());
561        assert!(
562            ToolOutput::MeetingNotes(vec![], None)
563                .result_meta()
564                .is_none()
565        );
566        assert!(
567            ToolOutput::KnowledgeBaseSpaces(vec![], None)
568                .result_meta()
569                .is_none()
570        );
571        assert!(
572            ToolOutput::KnowledgeBasePages(vec![], None)
573                .result_meta()
574                .is_none()
575        );
576        assert!(ToolOutput::Structures(vec![], None).result_meta().is_none());
577        assert!(
578            ToolOutput::StructureViews(vec![], None)
579                .result_meta()
580                .is_none()
581        );
582        assert!(
583            ToolOutput::StructureForest(Box::<devboy_core::StructureForest>::default())
584                .result_meta()
585                .is_none()
586        );
587    }
588}