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