Skip to main content

devboy_executor/
output.rs

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