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