Skip to main content

devboy_core/
provider.rs

1//! Provider traits for external services.
2//!
3//! These traits define the interface for interacting with issue trackers
4//! and merge request systems like GitLab, GitHub, ClickUp, and Jira.
5
6use async_trait::async_trait;
7
8use crate::asset::{AssetCapabilities, AssetMeta};
9use crate::error::{Error, Result};
10#[cfg(test)]
11use crate::types::JobLogMode;
12use crate::types::{
13    AddStructureGeneratorInput, AddStructureRowsInput, AssignToSprintInput, Comment,
14    CreateCommentInput, CreateIssueInput, CreateMergeRequestInput, CreatePageParams,
15    CreateStructureInput, Discussion, FileDiff, ForestModifyResult, GetChatsParams,
16    GetForestOptions, GetMessagesParams, GetPipelineInput, GetStructureValuesInput,
17    GetUsersOptions, Issue, IssueFilter, IssueRelations, IssueStatus, JobLogOptions, JobLogOutput,
18    KbPage, KbPageContent, KbSpace, ListPagesParams, ListProjectVersionsParams, MeetingFilter,
19    MeetingNote, MeetingTranscript, MergeRequest, MessengerChat, MessengerMessage,
20    MoveStructureRowsInput, MrFilter, PipelineInfo, ProjectVersion, ProviderResult, Release,
21    SaveStructureViewInput, SearchKbParams, SearchMessagesParams, SendMessageParams, Sprint,
22    SprintState, Structure, StructureForest, StructureGenerator, StructureValues, StructureView,
23    SyncStructureGeneratorInput, UpdateIssueInput, UpdateMergeRequestInput, UpdatePageParams,
24    UpdateStructureAutomationInput, UpsertProjectVersionInput, User,
25};
26
27/// Provider for working with issues.
28///
29/// Implementations include GitLab, GitHub, ClickUp, and Jira providers.
30#[async_trait]
31pub trait IssueProvider: Send + Sync {
32    /// Get a list of issues with optional filters.
33    async fn get_issues(&self, filter: IssueFilter) -> Result<ProviderResult<Issue>>;
34
35    /// Get a single issue by key (e.g., "gitlab#123", "gh#456").
36    async fn get_issue(&self, key: &str) -> Result<Issue>;
37
38    /// Create a new issue.
39    async fn create_issue(&self, input: CreateIssueInput) -> Result<Issue>;
40
41    /// Update an existing issue.
42    async fn update_issue(&self, key: &str, input: UpdateIssueInput) -> Result<Issue>;
43
44    async fn get_comments(&self, issue_key: &str) -> Result<ProviderResult<Comment>>;
45
46    async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment>;
47
48    /// Get available statuses for the issue tracker.
49    /// Default returns ProviderUnsupported — override in providers that support statuses.
50    async fn get_statuses(&self) -> Result<ProviderResult<IssueStatus>> {
51        Err(Error::ProviderUnsupported {
52            provider: self.provider_name().to_string(),
53            operation: "get_statuses".to_string(),
54        })
55    }
56
57    /// Link two issues together.
58    async fn link_issues(
59        &self,
60        _source_key: &str,
61        _target_key: &str,
62        _link_type: &str,
63    ) -> Result<()> {
64        Err(Error::ProviderUnsupported {
65            provider: self.provider_name().to_string(),
66            operation: "link_issues".to_string(),
67        })
68    }
69
70    /// Remove a link between two issues.
71    async fn unlink_issues(
72        &self,
73        _source_key: &str,
74        _target_key: &str,
75        _link_type: &str,
76    ) -> Result<()> {
77        Err(Error::ProviderUnsupported {
78            provider: self.provider_name().to_string(),
79            operation: "unlink_issues".to_string(),
80        })
81    }
82
83    /// Get users from the issue tracker (Jira only).
84    async fn get_users(&self, _options: GetUsersOptions) -> Result<ProviderResult<User>> {
85        Err(Error::ProviderUnsupported {
86            provider: self.provider_name().to_string(),
87            operation: "get_users".to_string(),
88        })
89    }
90
91    /// Upload a file attachment to an issue. Returns the download URL.
92    /// Default returns ProviderUnsupported — override in providers that support attachments.
93    async fn upload_attachment(
94        &self,
95        _issue_key: &str,
96        _filename: &str,
97        _data: &[u8],
98    ) -> Result<String> {
99        Err(Error::ProviderUnsupported {
100            provider: self.provider_name().to_string(),
101            operation: "upload_attachment".to_string(),
102        })
103    }
104
105    /// List attachments currently attached to an issue (body + comments).
106    ///
107    /// Returns provider-agnostic [`AssetMeta`] values. Default returns
108    /// ProviderUnsupported; providers that can parse or fetch their own
109    /// attachment listings override this.
110    async fn get_issue_attachments(&self, _issue_key: &str) -> Result<Vec<AssetMeta>> {
111        Err(Error::ProviderUnsupported {
112            provider: self.provider_name().to_string(),
113            operation: "get_issue_attachments".to_string(),
114        })
115    }
116
117    /// Download the raw bytes of an attachment belonging to an issue.
118    ///
119    /// `asset_id` is the provider-specific identifier returned from
120    /// [`IssueProvider::get_issue_attachments`] (ClickUp attachment id,
121    /// Jira attachment id, GitLab upload URL, etc.).
122    async fn download_attachment(&self, _issue_key: &str, _asset_id: &str) -> Result<Vec<u8>> {
123        Err(Error::ProviderUnsupported {
124            provider: self.provider_name().to_string(),
125            operation: "download_attachment".to_string(),
126        })
127    }
128
129    /// Delete an attachment from an issue.
130    ///
131    /// Not all providers expose a delete endpoint for attachments (ClickUp
132    /// doesn't, GitLab file uploads are immutable) — the default returns
133    /// `ProviderUnsupported` and callers can consult
134    /// [`asset_capabilities`](Self::asset_capabilities) beforehand.
135    async fn delete_attachment(&self, _issue_key: &str, _asset_id: &str) -> Result<()> {
136        Err(Error::ProviderUnsupported {
137            provider: self.provider_name().to_string(),
138            operation: "delete_attachment".to_string(),
139        })
140    }
141
142    /// Describe which asset operations this provider supports for each
143    /// context. Used by the enricher to surface per-provider capabilities
144    /// in tool schemas so agents can adapt their behaviour before making
145    /// calls that would fail with `ProviderUnsupported`.
146    fn asset_capabilities(&self) -> AssetCapabilities {
147        AssetCapabilities::default()
148    }
149
150    /// Set custom fields on an issue. Each entry: `{"id": "field_id", "value": <value>}`.
151    /// Default is no-op — override in providers that support custom fields (e.g., ClickUp).
152    async fn set_custom_fields(
153        &self,
154        _issue_key: &str,
155        _fields: &[serde_json::Value],
156    ) -> Result<()> {
157        Ok(()) // No-op by default
158    }
159
160    /// Get issue relations (parent, subtasks, linked issues).
161    async fn get_issue_relations(&self, _issue_key: &str) -> Result<IssueRelations> {
162        Err(Error::ProviderUnsupported {
163            provider: self.provider_name().to_string(),
164            operation: "get_issue_relations".to_string(),
165        })
166    }
167
168    // --- Jira Structure plugin methods ---
169    // Default: ProviderUnsupported. Only JiraClient overrides these.
170
171    /// List all available structures.
172    async fn get_structures(&self) -> Result<ProviderResult<Structure>> {
173        Err(Error::ProviderUnsupported {
174            provider: self.provider_name().to_string(),
175            operation: "get_structures".to_string(),
176        })
177    }
178
179    /// Get a structure's forest (hierarchy tree).
180    async fn get_structure_forest(
181        &self,
182        _structure_id: u64,
183        _options: GetForestOptions,
184    ) -> Result<StructureForest> {
185        Err(Error::ProviderUnsupported {
186            provider: self.provider_name().to_string(),
187            operation: "get_structure_forest".to_string(),
188        })
189    }
190
191    /// Add rows to a structure's forest.
192    async fn add_structure_rows(
193        &self,
194        _structure_id: u64,
195        _input: AddStructureRowsInput,
196    ) -> Result<ForestModifyResult> {
197        Err(Error::ProviderUnsupported {
198            provider: self.provider_name().to_string(),
199            operation: "add_structure_rows".to_string(),
200        })
201    }
202
203    /// Move rows within a structure's forest.
204    async fn move_structure_rows(
205        &self,
206        _structure_id: u64,
207        _input: MoveStructureRowsInput,
208    ) -> Result<ForestModifyResult> {
209        Err(Error::ProviderUnsupported {
210            provider: self.provider_name().to_string(),
211            operation: "move_structure_rows".to_string(),
212        })
213    }
214
215    /// Remove a row from a structure's forest.
216    async fn remove_structure_row(&self, _structure_id: u64, _row_id: u64) -> Result<()> {
217        Err(Error::ProviderUnsupported {
218            provider: self.provider_name().to_string(),
219            operation: "remove_structure_row".to_string(),
220        })
221    }
222
223    /// Batch-read column values (including formulas) for structure rows.
224    async fn get_structure_values(
225        &self,
226        _input: GetStructureValuesInput,
227    ) -> Result<StructureValues> {
228        Err(Error::ProviderUnsupported {
229            provider: self.provider_name().to_string(),
230            operation: "get_structure_values".to_string(),
231        })
232    }
233
234    /// Get views for a structure, optionally a specific view by ID.
235    async fn get_structure_views(
236        &self,
237        _structure_id: u64,
238        _view_id: Option<u64>,
239    ) -> Result<Vec<StructureView>> {
240        Err(Error::ProviderUnsupported {
241            provider: self.provider_name().to_string(),
242            operation: "get_structure_views".to_string(),
243        })
244    }
245
246    /// Create or update a structure view.
247    async fn save_structure_view(&self, _input: SaveStructureViewInput) -> Result<StructureView> {
248        Err(Error::ProviderUnsupported {
249            provider: self.provider_name().to_string(),
250            operation: "save_structure_view".to_string(),
251        })
252    }
253
254    /// Create a new structure.
255    async fn create_structure(&self, _input: CreateStructureInput) -> Result<Structure> {
256        Err(Error::ProviderUnsupported {
257            provider: self.provider_name().to_string(),
258            operation: "create_structure".to_string(),
259        })
260    }
261
262    // --- Structure generators (issue #179) -----------------------------
263
264    /// List generators configured on a structure.
265    async fn get_structure_generators(
266        &self,
267        _structure_id: u64,
268    ) -> Result<ProviderResult<StructureGenerator>> {
269        Err(Error::ProviderUnsupported {
270            provider: self.provider_name().to_string(),
271            operation: "get_structure_generators".to_string(),
272        })
273    }
274
275    /// Attach a new generator to a structure.
276    async fn add_structure_generator(
277        &self,
278        _input: AddStructureGeneratorInput,
279    ) -> Result<StructureGenerator> {
280        Err(Error::ProviderUnsupported {
281            provider: self.provider_name().to_string(),
282            operation: "add_structure_generator".to_string(),
283        })
284    }
285
286    /// Force a generator to refresh its produced rows.
287    async fn sync_structure_generator(&self, _input: SyncStructureGeneratorInput) -> Result<()> {
288        Err(Error::ProviderUnsupported {
289            provider: self.provider_name().to_string(),
290            operation: "sync_structure_generator".to_string(),
291        })
292    }
293
294    // --- Structure delete + automation (issue #180) --------------------
295
296    /// Delete a structure permanently.
297    async fn delete_structure(&self, _structure_id: u64) -> Result<()> {
298        Err(Error::ProviderUnsupported {
299            provider: self.provider_name().to_string(),
300            operation: "delete_structure".to_string(),
301        })
302    }
303
304    /// Replace a structure's automation configuration.
305    async fn update_structure_automation(
306        &self,
307        _input: UpdateStructureAutomationInput,
308    ) -> Result<()> {
309        Err(Error::ProviderUnsupported {
310            provider: self.provider_name().to_string(),
311            operation: "update_structure_automation".to_string(),
312        })
313    }
314
315    /// Run a structure's automation pass on demand.
316    async fn trigger_structure_automation(&self, _structure_id: u64) -> Result<()> {
317        Err(Error::ProviderUnsupported {
318            provider: self.provider_name().to_string(),
319            operation: "trigger_structure_automation".to_string(),
320        })
321    }
322
323    // --- Project versions / fixVersion (issue #238) --------------------
324    //
325    // List + upsert form a deliberately small surface: read returns a
326    // rich per-version payload so a separate get-by-id is unnecessary
327    // (Paper 3 — Context Enrichment Hypothesis), and write is name-keyed
328    // so the LLM never deals with numeric ids. See `docs/research/`.
329
330    /// List versions ("releases" / `fixVersion` targets) for a project.
331    /// Default: ProviderUnsupported.
332    async fn list_project_versions(
333        &self,
334        _params: ListProjectVersionsParams,
335    ) -> Result<ProviderResult<ProjectVersion>> {
336        Err(Error::ProviderUnsupported {
337            provider: self.provider_name().to_string(),
338            operation: "list_project_versions".to_string(),
339        })
340    }
341
342    /// Create-or-update a project version, keyed by `(project, name)`.
343    /// Partial update: optional fields left as `None` are not touched.
344    /// Default: ProviderUnsupported.
345    async fn upsert_project_version(
346        &self,
347        _input: UpsertProjectVersionInput,
348    ) -> Result<ProjectVersion> {
349        Err(Error::ProviderUnsupported {
350            provider: self.provider_name().to_string(),
351            operation: "upsert_project_version".to_string(),
352        })
353    }
354
355    // --- Agile / Sprint (issue #198) -----------------------------------
356
357    /// List sprints visible on a board, optionally filtered by state.
358    async fn get_board_sprints(
359        &self,
360        _board_id: u64,
361        _state: SprintState,
362    ) -> Result<ProviderResult<Sprint>> {
363        Err(Error::ProviderUnsupported {
364            provider: self.provider_name().to_string(),
365            operation: "get_board_sprints".to_string(),
366        })
367    }
368
369    /// Move one or more issues onto a sprint.
370    async fn assign_to_sprint(&self, _input: AssignToSprintInput) -> Result<()> {
371        Err(Error::ProviderUnsupported {
372            provider: self.provider_name().to_string(),
373            operation: "assign_to_sprint".to_string(),
374        })
375    }
376
377    /// Get the provider name for logging (e.g., "gitlab", "github").
378    fn provider_name(&self) -> &'static str;
379}
380
381/// Provider for working with user profiles across issue trackers and
382/// messengers (issue #177).
383///
384/// Existing providers expose users piecemeal: `IssueProvider::get_users`
385/// returns a paginated list scoped to an issue tracker, `MessengerProvider`
386/// resolves user IDs inside a chat. This trait standardises the "fetch a
387/// `User` by stable id / email" surface so cross-provider lookups (e.g.
388/// when a meeting participant mentioned by email needs to be matched to a
389/// Slack handle) have a single contract.
390///
391/// Default methods return [`Error::ProviderUnsupported`] so providers only
392/// implement what they actually support.
393#[async_trait]
394pub trait UserProvider: Send + Sync {
395    /// Provider name for logging / error reporting.
396    fn provider_name(&self) -> &'static str;
397
398    /// Resolve a user by their provider-native id (Slack `U0123`, Jira
399    /// `accountId` / `name`, ClickUp user id, etc.).
400    async fn get_user_profile(&self, _user_id: &str) -> Result<User> {
401        Err(Error::ProviderUnsupported {
402            provider: self.provider_name().to_string(),
403            operation: "get_user_profile".to_string(),
404        })
405    }
406
407    /// Look up a user by email. Returns `None` if the provider can issue
408    /// the query but there is no match, [`Error::ProviderUnsupported`]
409    /// when the provider simply doesn't expose an email lookup.
410    async fn lookup_user_by_email(&self, _email: &str) -> Result<Option<User>> {
411        Err(Error::ProviderUnsupported {
412            provider: self.provider_name().to_string(),
413            operation: "lookup_user_by_email".to_string(),
414        })
415    }
416}
417
418/// Provider for working with merge requests / pull requests.
419///
420/// Only `provider_name()` is required. All other methods have default implementations
421/// that return `Error::ProviderUnsupported`, so providers like ClickUp and Jira
422/// only need to override the methods they actually support.
423#[async_trait]
424pub trait MergeRequestProvider: Send + Sync {
425    /// Get the provider name for logging.
426    fn provider_name(&self) -> &'static str;
427
428    /// Get a list of merge requests with optional filters.
429    async fn get_merge_requests(&self, _filter: MrFilter) -> Result<ProviderResult<MergeRequest>> {
430        Err(Error::ProviderUnsupported {
431            provider: self.provider_name().to_string(),
432            operation: "get_merge_requests".to_string(),
433        })
434    }
435
436    /// Get a single merge request by key (e.g., "mr#123", "pr#456").
437    async fn get_merge_request(&self, _key: &str) -> Result<MergeRequest> {
438        Err(Error::ProviderUnsupported {
439            provider: self.provider_name().to_string(),
440            operation: "get_merge_request".to_string(),
441        })
442    }
443
444    /// Get discussions/comments for a merge request.
445    async fn get_discussions(&self, _mr_key: &str) -> Result<ProviderResult<Discussion>> {
446        Err(Error::ProviderUnsupported {
447            provider: self.provider_name().to_string(),
448            operation: "get_discussions".to_string(),
449        })
450    }
451
452    async fn get_diffs(&self, _mr_key: &str) -> Result<ProviderResult<FileDiff>> {
453        Err(Error::ProviderUnsupported {
454            provider: self.provider_name().to_string(),
455            operation: "get_diffs".to_string(),
456        })
457    }
458
459    async fn add_comment(&self, _mr_key: &str, _input: CreateCommentInput) -> Result<Comment> {
460        Err(Error::ProviderUnsupported {
461            provider: self.provider_name().to_string(),
462            operation: "add_merge_request_comment".to_string(),
463        })
464    }
465
466    /// Create a new merge request / pull request.
467    async fn create_merge_request(&self, _input: CreateMergeRequestInput) -> Result<MergeRequest> {
468        Err(Error::ProviderUnsupported {
469            provider: self.provider_name().to_string(),
470            operation: "create_merge_request".to_string(),
471        })
472    }
473
474    /// Update an existing merge request / pull request.
475    async fn update_merge_request(
476        &self,
477        _key: &str,
478        _input: UpdateMergeRequestInput,
479    ) -> Result<MergeRequest> {
480        Err(Error::ProviderUnsupported {
481            provider: self.provider_name().to_string(),
482            operation: "update_merge_request".to_string(),
483        })
484    }
485
486    /// Get releases/tags for the repository.
487    async fn get_releases(&self) -> Result<ProviderResult<Release>> {
488        Err(Error::ProviderUnsupported {
489            provider: self.provider_name().to_string(),
490            operation: "get_releases".to_string(),
491        })
492    }
493
494    /// List attachments on a merge request (body + discussions).
495    async fn get_mr_attachments(&self, _mr_key: &str) -> Result<Vec<AssetMeta>> {
496        Err(Error::ProviderUnsupported {
497            provider: self.provider_name().to_string(),
498            operation: "get_mr_attachments".to_string(),
499        })
500    }
501
502    async fn download_mr_attachment(&self, _mr_key: &str, _asset_id: &str) -> Result<Vec<u8>> {
503        Err(Error::ProviderUnsupported {
504            provider: self.provider_name().to_string(),
505            operation: "download_mr_attachment".to_string(),
506        })
507    }
508
509    async fn delete_mr_attachment(&self, _mr_key: &str, _asset_id: &str) -> Result<()> {
510        Err(Error::ProviderUnsupported {
511            provider: self.provider_name().to_string(),
512            operation: "delete_mr_attachment".to_string(),
513        })
514    }
515}
516
517/// Provider for CI/CD pipeline status and job logs.
518///
519/// Implemented by GitLab (Pipelines API) and GitHub (Actions API).
520/// All methods have default implementations returning `ProviderUnsupported`.
521#[async_trait]
522pub trait PipelineProvider: Send + Sync {
523    /// Get the provider name for logging.
524    fn provider_name(&self) -> &'static str;
525
526    async fn get_pipeline(&self, _input: GetPipelineInput) -> Result<PipelineInfo> {
527        Err(Error::ProviderUnsupported {
528            provider: self.provider_name().to_string(),
529            operation: "get_pipeline".to_string(),
530        })
531    }
532
533    /// Get job logs with search, pagination, or smart extraction.
534    async fn get_job_logs(&self, _job_id: &str, _options: JobLogOptions) -> Result<JobLogOutput> {
535        Err(Error::ProviderUnsupported {
536            provider: self.provider_name().to_string(),
537            operation: "get_job_logs".to_string(),
538        })
539    }
540}
541
542/// Combined provider trait for services that support issues, merge requests, and pipelines.
543///
544/// This is implemented by GitLab and GitHub providers.
545#[async_trait]
546pub trait Provider: IssueProvider + MergeRequestProvider + PipelineProvider {
547    /// Get the current authenticated user.
548    async fn get_current_user(&self) -> Result<User>;
549}
550
551/// Provider for meeting notes and transcripts.
552///
553/// Implementations include Fireflies.ai.
554#[async_trait]
555pub trait MeetingNotesProvider: Send + Sync {
556    /// Get the provider name for logging (e.g., "fireflies").
557    fn provider_name(&self) -> &'static str;
558
559    /// Get a list of meeting notes with optional filters.
560    async fn get_meetings(&self, filter: MeetingFilter) -> Result<ProviderResult<MeetingNote>>;
561
562    /// Get the full transcript for a meeting.
563    async fn get_transcript(&self, meeting_id: &str) -> Result<MeetingTranscript>;
564
565    /// Search meetings by keyword across titles, action items, keywords, and topics.
566    async fn search_meetings(
567        &self,
568        query: &str,
569        filter: MeetingFilter,
570    ) -> Result<ProviderResult<MeetingNote>>;
571}
572
573/// Provider for knowledge bases and internal wiki/documentation systems.
574///
575/// Implementations include Confluence Server / Data Center.
576#[async_trait]
577pub trait KnowledgeBaseProvider: Send + Sync {
578    /// Get the provider name for logging (e.g. "confluence").
579    fn provider_name(&self) -> &'static str;
580
581    /// List available spaces / knowledge base containers.
582    async fn get_spaces(&self) -> Result<ProviderResult<KbSpace>>;
583
584    /// List pages in a space with pagination.
585    async fn list_pages(&self, params: ListPagesParams) -> Result<ProviderResult<KbPage>>;
586
587    /// Fetch a single page with full body content and metadata.
588    async fn get_page(&self, page_id: &str) -> Result<KbPageContent>;
589
590    /// Create a new page.
591    async fn create_page(&self, params: CreatePageParams) -> Result<KbPage>;
592
593    /// Update an existing page.
594    async fn update_page(&self, params: UpdatePageParams) -> Result<KbPage>;
595
596    /// Search pages across spaces or within a specific space.
597    async fn search(&self, params: SearchKbParams) -> Result<ProviderResult<KbPage>>;
598}
599
600/// Provider for team messenger systems.
601///
602/// Implementations include Slack.
603#[async_trait]
604pub trait MessengerProvider: Send + Sync {
605    /// Get the provider name for logging (e.g. "slack").
606    fn provider_name(&self) -> &'static str;
607
608    /// Get available chats, channels, groups, or DMs.
609    async fn get_chats(&self, params: GetChatsParams) -> Result<ProviderResult<MessengerChat>>;
610
611    /// Get message history for a specific chat.
612    async fn get_messages(
613        &self,
614        params: GetMessagesParams,
615    ) -> Result<ProviderResult<MessengerMessage>>;
616
617    /// Search messages across chats.
618    async fn search_messages(
619        &self,
620        params: SearchMessagesParams,
621    ) -> Result<ProviderResult<MessengerMessage>>;
622
623    /// Send a message to a chat or thread.
624    async fn send_message(&self, params: SendMessageParams) -> Result<MessengerMessage>;
625}
626
627// ============================================================================
628// Default-method coverage
629// ============================================================================
630//
631// The traits above expose a lot of default methods that return
632// `ProviderUnsupported` so that concrete providers only have to
633// override what they actually implement. The unit tests below pin the
634// contract of that default set so a future refactor cannot silently
635// turn an unsupported operation into a panic, a silent success, or a
636// wrong error variant.
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    /// A `Provider` that only overrides `provider_name()` and nothing
643    /// else — every other method should fall through to the default
644    /// `ProviderUnsupported` return.
645    struct DummyProvider;
646
647    #[async_trait]
648    impl IssueProvider for DummyProvider {
649        async fn get_issues(&self, _: IssueFilter) -> Result<ProviderResult<Issue>> {
650            unreachable!("the dispatcher should never call this in these tests")
651        }
652        async fn get_issue(&self, _: &str) -> Result<Issue> {
653            unreachable!()
654        }
655        async fn create_issue(&self, _: CreateIssueInput) -> Result<Issue> {
656            unreachable!()
657        }
658        async fn update_issue(&self, _: &str, _: UpdateIssueInput) -> Result<Issue> {
659            unreachable!()
660        }
661        async fn get_comments(&self, _: &str) -> Result<ProviderResult<Comment>> {
662            unreachable!()
663        }
664        async fn add_comment(&self, _: &str, _: &str) -> Result<Comment> {
665            unreachable!()
666        }
667        fn provider_name(&self) -> &'static str {
668            "dummy"
669        }
670    }
671
672    #[async_trait]
673    impl MergeRequestProvider for DummyProvider {
674        fn provider_name(&self) -> &'static str {
675            "dummy"
676        }
677    }
678
679    #[async_trait]
680    impl PipelineProvider for DummyProvider {
681        fn provider_name(&self) -> &'static str {
682            "dummy"
683        }
684    }
685
686    /// Assert that a result is `ProviderUnsupported { provider, operation }`
687    /// and that both fields carry the expected values.
688    fn assert_unsupported<T: std::fmt::Debug>(result: Result<T>, expected_op: &str) {
689        match result {
690            Err(Error::ProviderUnsupported {
691                provider,
692                operation,
693            }) => {
694                assert_eq!(provider, "dummy");
695                assert_eq!(operation, expected_op);
696            }
697            other => panic!("expected ProviderUnsupported({expected_op}), got {other:?}"),
698        }
699    }
700
701    // ------------------------------------------------------------------
702    // IssueProvider defaults
703    // ------------------------------------------------------------------
704
705    #[tokio::test]
706    async fn issue_provider_defaults_return_unsupported() {
707        let p = DummyProvider;
708
709        assert_unsupported(p.get_statuses().await, "get_statuses");
710        assert_unsupported(p.link_issues("a", "b", "blocks").await, "link_issues");
711        assert_unsupported(p.unlink_issues("a", "b", "blocks").await, "unlink_issues");
712        assert_unsupported(p.get_users(GetUsersOptions::default()).await, "get_users");
713        assert_unsupported(
714            p.upload_attachment("k", "f.png", b"x").await,
715            "upload_attachment",
716        );
717        assert_unsupported(p.get_issue_attachments("k").await, "get_issue_attachments");
718        assert_unsupported(p.download_attachment("k", "1").await, "download_attachment");
719        assert_unsupported(p.delete_attachment("k", "1").await, "delete_attachment");
720        assert_unsupported(p.get_issue_relations("k").await, "get_issue_relations");
721        assert_unsupported(
722            p.list_project_versions(crate::types::ListProjectVersionsParams {
723                project: "PROJ".into(),
724                ..Default::default()
725            })
726            .await,
727            "list_project_versions",
728        );
729        assert_unsupported(
730            p.upsert_project_version(crate::types::UpsertProjectVersionInput {
731                project: "PROJ".into(),
732                name: "1.0.0".into(),
733                ..Default::default()
734            })
735            .await,
736            "upsert_project_version",
737        );
738    }
739
740    #[tokio::test]
741    async fn issue_provider_set_custom_fields_is_no_op_by_default() {
742        // Distinct from every other default: this one returns Ok(()).
743        let p = DummyProvider;
744        p.set_custom_fields("k", &[]).await.unwrap();
745    }
746
747    #[test]
748    fn issue_provider_default_asset_capabilities_is_empty() {
749        let caps = IssueProvider::asset_capabilities(&DummyProvider);
750        assert_eq!(caps, AssetCapabilities::default());
751    }
752
753    // ------------------------------------------------------------------
754    // MergeRequestProvider defaults
755    // ------------------------------------------------------------------
756
757    #[tokio::test]
758    async fn merge_request_provider_defaults_return_unsupported() {
759        let p = DummyProvider;
760        assert_unsupported(
761            p.get_merge_requests(MrFilter::default()).await,
762            "get_merge_requests",
763        );
764        assert_unsupported(p.get_merge_request("mr#1").await, "get_merge_request");
765        assert_unsupported(p.get_discussions("mr#1").await, "get_discussions");
766        assert_unsupported(p.get_diffs("mr#1").await, "get_diffs");
767        assert_unsupported(
768            MergeRequestProvider::add_comment(
769                &p,
770                "mr#1",
771                CreateCommentInput {
772                    body: "".into(),
773                    position: None,
774                    discussion_id: None,
775                },
776            )
777            .await,
778            "add_merge_request_comment",
779        );
780        assert_unsupported(
781            p.create_merge_request(CreateMergeRequestInput::default())
782                .await,
783            "create_merge_request",
784        );
785        assert_unsupported(
786            p.update_merge_request("mr#1", UpdateMergeRequestInput::default())
787                .await,
788            "update_merge_request",
789        );
790        assert_unsupported(p.get_releases().await, "get_releases");
791        assert_unsupported(p.get_mr_attachments("mr#1").await, "get_mr_attachments");
792        assert_unsupported(
793            p.download_mr_attachment("mr#1", "1").await,
794            "download_mr_attachment",
795        );
796        assert_unsupported(
797            p.delete_mr_attachment("mr#1", "1").await,
798            "delete_mr_attachment",
799        );
800    }
801
802    // ------------------------------------------------------------------
803    // PipelineProvider defaults
804    // ------------------------------------------------------------------
805
806    #[tokio::test]
807    async fn pipeline_provider_defaults_return_unsupported() {
808        let p = DummyProvider;
809        assert_unsupported(
810            p.get_pipeline(GetPipelineInput::default()).await,
811            "get_pipeline",
812        );
813        assert_unsupported(
814            p.get_job_logs(
815                "1",
816                JobLogOptions {
817                    mode: JobLogMode::Smart,
818                },
819            )
820            .await,
821            "get_job_logs",
822        );
823    }
824}