Skip to main content

forgetop_core/
provider.rs

1//! Capability-scoped provider traits, connection descriptor, capabilities, queries,
2//! and the provider registry (mirrors `Forgetop.Core.Providers`).
3
4use std::collections::HashMap;
5use std::sync::Arc;
6
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9
10use crate::domain::*;
11use crate::error::{Error, Result};
12
13// ---- connection descriptor ----
14
15/// A configured connection: identity, optional scope, and a reference to the PAT in
16/// the secret store (never the secret itself).
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct Connection {
19    pub id: String,
20    pub provider_type: ProviderType,
21    pub display_name: String,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub base_url: Option<String>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub organization: Option<String>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub project: Option<String>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub repository: Option<String>,
30    /// Identity for Basic-auth providers (e.g. Jira email, Bitbucket username).
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub username: Option<String>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub credential_ref: Option<String>,
35    /// The repositories this connection fetches from, **connection-relative** (`acme/pay` — see
36    /// [`crate::repo`]).
37    ///
38    /// Three states, all distinct and all meaningful:
39    /// * `None` — never established. Fall back to the legacy single [`Self::repository`].
40    /// * `Some([])` — the user chose no repositories. Fetch nothing; do **not** refill from the
41    ///   legacy field, or an intentionally-emptied scope silently comes back on the next load.
42    /// * `Some([…])` — fetch these.
43    ///
44    /// Fallbacks must therefore key on the scope being *absent*, never on it being *empty*.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub repo_scope: Option<Vec<String>>,
47}
48
49impl Connection {
50    pub fn new_id(provider: ProviderType) -> String {
51        format!("{}-{}", provider.as_str().to_lowercase(), uuid::Uuid::new_v4().simple())
52    }
53
54    /// The repositories to fetch from, resolved at factory time so nothing on disk is rewritten:
55    /// an explicit scope wins whenever it is `Some`, and only its absence falls back to the
56    /// legacy single repository (via `legacy`, which each provider spells its own way). An
57    /// existing single-repository connection therefore yields a one-element scope and behaves
58    /// exactly as before.
59    pub fn resolve_repo_scope(&self, legacy: impl FnOnce() -> Option<String>) -> Vec<String> {
60        match &self.repo_scope {
61            Some(scope) => scope.clone(),
62            None => legacy().into_iter().collect(),
63        }
64    }
65}
66
67/// Addresses one item inside a connection: which repository it lives in, plus the
68/// provider-native id.
69///
70/// `repo` is always **connection-relative** (`acme/pay`) — never host-qualified. It is `None`
71/// only when the caller genuinely has no repository to give; a provider whose scope holds a
72/// single repository resolves that case, and one with a wider scope rejects it rather than
73/// guessing.
74#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
75pub struct ItemRef {
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub repo: Option<String>,
78    pub id: String,
79}
80
81impl ItemRef {
82    /// An unaddressed reference — only resolvable against a single-repository scope.
83    pub fn new(id: impl Into<String>) -> Self {
84        ItemRef { repo: None, id: id.into() }
85    }
86
87    /// A fully addressed reference. `repo` must be connection-relative.
88    pub fn in_repo(repo: impl Into<String>, id: impl Into<String>) -> Self {
89        ItemRef { repo: Some(repo.into()), id: id.into() }
90    }
91
92    /// Builds a reference from an optional connection-relative repository.
93    pub fn maybe(repo: Option<String>, id: impl Into<String>) -> Self {
94        ItemRef { repo, id: id.into() }
95    }
96}
97
98/// The repositories a connection's credentials can reach, for the scope picker.
99#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
100pub struct RepositoryPage {
101    /// Connection-relative repository paths, most-recently-active first.
102    pub repositories: Vec<String>,
103    /// True when the provider had more than we fetched, so the picker can say "5 of 500+"
104    /// rather than presenting a cap as a total. Truncation must never be silent.
105    pub truncated: bool,
106}
107
108// ---- capabilities ----
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111pub enum VoteStyle {
112    BinaryApprove,
113    NumericVotes,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Terminology {
118    pub pull_requests: String,
119    pub work_items: String,
120    pub pipelines: String,
121}
122
123impl Default for Terminology {
124    fn default() -> Self {
125        Self {
126            pull_requests: "Pull Requests".into(),
127            work_items: "Work Items".into(),
128            pipelines: "Pipelines".into(),
129        }
130    }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct Capabilities {
135    pub supports_pull_requests: bool,
136    pub supports_work_items: bool,
137    pub supports_pipelines: bool,
138    pub vote_style: VoteStyle,
139    pub supports_merge: bool,
140    pub supports_inline_comments: bool,
141    pub supports_pipeline_trigger: bool,
142    pub supports_pipeline_discovery: bool,
143    /// Whether this provider exposes a personal notification feed (GitHub / GitLab / Linear).
144    pub supports_notifications: bool,
145    pub terminology: Terminology,
146}
147
148impl Default for Capabilities {
149    fn default() -> Self {
150        Self {
151            supports_pull_requests: false,
152            supports_work_items: false,
153            supports_pipelines: false,
154            vote_style: VoteStyle::BinaryApprove,
155            supports_merge: false,
156            supports_inline_comments: false,
157            supports_pipeline_trigger: false,
158            supports_pipeline_discovery: false,
159            supports_notifications: false,
160            terminology: Terminology::default(),
161        }
162    }
163}
164
165impl Capabilities {
166    pub fn supports(&self, section: Section) -> bool {
167        match section {
168            Section::PullRequests => self.supports_pull_requests,
169            Section::WorkItems => self.supports_work_items,
170            Section::Pipelines => self.supports_pipelines,
171        }
172    }
173}
174
175// ---- queries ----
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
178pub enum PullRequestFilter {
179    #[default]
180    All,
181    Mine,
182    ReviewRequested,
183}
184
185#[derive(Debug, Clone)]
186pub struct PullRequestQuery {
187    pub filter: PullRequestFilter,
188    pub include_completed: bool,
189    pub limit: Option<u32>,
190    /// Whether to spend extra per-PR calls filling the fields a provider's *list* endpoint omits
191    /// (GitHub leaves out `mergeable`, `changed_files`, `additions`, `deletions` entirely).
192    ///
193    /// Defaults to **true**, so no caller loses decoration by accident. Callers that render
194    /// decorated fields lazily — the dashboard fetches them per visible row — turn it off, and
195    /// providers that honour it must still bound the work to the rows they finally return, so
196    /// the cost does not multiply with the size of the repository scope.
197    pub decorate: bool,
198}
199
200impl Default for PullRequestQuery {
201    fn default() -> Self {
202        Self { filter: PullRequestFilter::default(), include_completed: false, limit: None, decorate: true }
203    }
204}
205
206/// The fields a provider's list endpoint omits, fetched on demand for one pull request.
207#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208pub struct PrDecoration {
209    pub mergeable: MergeableState,
210    pub changed_files: i64,
211    pub additions: i64,
212    pub deletions: i64,
213    pub checks: CheckStatus,
214    pub check_summary: Option<CheckSummary>,
215}
216
217impl PrDecoration {
218    /// Projects the decorated fields out of a fully-fetched pull request.
219    pub fn from_pull_request(pr: &PullRequest) -> Self {
220        Self {
221            mergeable: pr.mergeable,
222            changed_files: pr.changed_files,
223            additions: pr.additions,
224            deletions: pr.deletions,
225            checks: pr.checks,
226            check_summary: pr.check_summary.clone(),
227        }
228    }
229
230    /// Writes these fields back onto a pull request whose list payload omitted them — the
231    /// inverse of [`from_pull_request`](Self::from_pull_request), for a caller that listed
232    /// without decoration and fetched it per row afterwards.
233    pub fn apply_to(&self, pr: &mut PullRequest) {
234        pr.mergeable = self.mergeable;
235        pr.changed_files = self.changed_files;
236        pr.additions = self.additions;
237        pr.deletions = self.deletions;
238        pr.checks = self.checks;
239        pr.check_summary = self.check_summary.clone();
240    }
241}
242
243#[derive(Debug, Clone, Default)]
244pub struct WorkItemQuery {
245    pub mine_only: bool,
246    pub include_completed: bool,
247    pub limit: Option<u32>,
248}
249
250#[derive(Debug, Clone, Default)]
251pub struct PipelineRunQuery {
252    pub definition_id: Option<String>,
253    /// Restricts the query to one **connection-relative** repository. `None` fans out over the
254    /// connection's whole repository scope.
255    pub repository: Option<String>,
256    pub branch: Option<String>,
257    pub limit: Option<u32>,
258}
259
260#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
261pub enum MergeStrategy {
262    #[default]
263    Merge,
264    Squash,
265    Rebase,
266}
267
268#[derive(Debug, Clone, Default)]
269pub struct MergeOptions {
270    pub strategy: MergeStrategy,
271    pub delete_source_ref: bool,
272}
273
274// ---- capability-scoped sources ----
275
276/// Pull requests from one connection. Every method that names a single pull request takes an
277/// [`ItemRef`], not a bare id: on a connection spanning several repositories the id alone does
278/// not say *which* `#7` is meant, and guessing silently opens, merges or comments on the wrong
279/// one. The `repo` on the ref is connection-relative.
280#[async_trait]
281pub trait PullRequestSource: Send + Sync {
282    async fn list(&self, query: &PullRequestQuery) -> Result<Vec<PullRequest>>;
283    /// The handle identifying the signed-in user on this connection — the same identity
284    /// [`list`](Self::list) resolves internally when it is asked for `Mine` or `ReviewRequested`.
285    ///
286    /// Exposed so a caller can fetch one *unfiltered* pool and derive those views itself, rather
287    /// than issuing a `list` per filter that re-fetches identical rows. It is matched against a
288    /// user's handle, display name or id by
289    /// [`apply_pull_request_filter`](crate::filter::apply_pull_request_filter).
290    ///
291    /// `None` — the default, and what a provider returns when it cannot establish an identity —
292    /// makes that filter pass everything through rather than silently emptying the list. A caller
293    /// deriving views locally must treat it as "cannot filter", not as "nothing matches": showing
294    /// an unfiltered list under a "Mine" heading is wrong, but quietly showing none of your pull
295    /// requests is worse, and matches what `list` already does in the same situation.
296    async fn current_user(&self) -> Result<Option<String>> {
297        Ok(None)
298    }
299    async fn get(&self, item: &ItemRef) -> Result<PullRequest>;
300    async fn threads(&self, item: &ItemRef) -> Result<Vec<CommentThread>>;
301    /// The event timeline (reviews/approvals, merges, state changes, …), oldest → newest.
302    /// Defaults to empty for providers without a timeline/activity API.
303    async fn timeline(&self, _item: &ItemRef) -> Result<Vec<TimelineEvent>> {
304        Ok(Vec::new())
305    }
306    async fn changes(&self, item: &ItemRef) -> Result<Vec<FileChange>>;
307    /// Individual named CI checks. Defaults to empty for providers that don't expose them.
308    async fn checks(&self, _item: &ItemRef) -> Result<Vec<CheckRun>> {
309        Ok(Vec::new())
310    }
311    /// Commits on the pull request. Defaults to empty.
312    async fn commits(&self, _item: &ItemRef) -> Result<Vec<Commit>> {
313        Ok(Vec::new())
314    }
315    /// Whether this provider's *list* endpoint leaves the decorated fields out, so a caller that
316    /// skipped decoration has to fetch them per row to show them.
317    ///
318    /// `false` by default, and that is the right answer for most providers: GitLab, Azure DevOps
319    /// and Bitbucket fill everything they have straight from the list payload, so asking them for
320    /// decoration is a call per row that returns what the caller already had. GitHub is the
321    /// exception — `/pulls` omits `mergeable_state`, `changed_files`, `additions` and `deletions`
322    /// entirely — and overrides this.
323    fn list_omits_decoration(&self) -> bool {
324        false
325    }
326    /// The fields the list endpoint omits, for one pull request. Defaults to projecting them out
327    /// of a full [`get`](Self::get) — providers with a cheaper route override it.
328    async fn decorate(&self, item: &ItemRef) -> Result<PrDecoration> {
329        Ok(PrDecoration::from_pull_request(&self.get(item).await?))
330    }
331    /// Files changed by a single commit on the pull request (with inline patches
332    /// where the provider supplies them). Defaults to unsupported (empty).
333    async fn commit_changes(&self, _item: &ItemRef, _sha: &str) -> Result<Vec<FileChange>> {
334        Ok(Vec::new())
335    }
336    async fn add_comment(&self, item: &ItemRef, body: &str) -> Result<()>;
337    /// Replies to an existing comment thread (returned by [`threads`](Self::threads)), so you can
338    /// answer someone else's comment in-thread rather than starting a new top-level one. Defaults
339    /// to posting a plain top-level comment — providers whose threads are really flat (e.g. a
340    /// GitHub PR *conversation*, which has no reply API) keep that behaviour; providers with real
341    /// threads (Azure, GitLab discussions, Bitbucket, GitHub *review* threads) override it.
342    async fn reply_to_thread(&self, item: &ItemRef, _thread_id: &str, body: &str) -> Result<()> {
343        self.add_comment(item, body).await
344    }
345    async fn vote(&self, item: &ItemRef, vote: ReviewVote) -> Result<()>;
346    async fn merge(&self, item: &ItemRef, options: &MergeOptions) -> Result<()>;
347    /// Reverts a merged pull request (undoes its merge commit on the target branch). Defaults to
348    /// unsupported — only providers with a revert API (GitLab, Azure DevOps) override this.
349    async fn revert(&self, _item: &ItemRef) -> Result<()> {
350        Err(Error::Provider("this provider has no revert API — revert it from the provider's web UI".into()))
351    }
352    /// Submits a review with inline line comments. `event` maps to approve /
353    /// request-changes / plain comment. Defaults to unsupported.
354    async fn submit_review(&self, _item: &ItemRef, _event: ReviewVote, _comments: &[LineComment]) -> Result<()> {
355        Err(Error::Provider("this provider doesn't support line-comment reviews".into()))
356    }
357}
358
359#[async_trait]
360pub trait WorkItemSource: Send + Sync {
361    async fn list(&self, query: &WorkItemQuery) -> Result<Vec<WorkItem>>;
362    async fn get(&self, item: &ItemRef) -> Result<WorkItem>;
363    async fn threads(&self, item: &ItemRef) -> Result<Vec<CommentThread>>;
364    /// The event history (status changes, assignments, …), oldest → newest. Defaults to empty.
365    async fn timeline(&self, _item: &ItemRef) -> Result<Vec<TimelineEvent>> {
366        Ok(Vec::new())
367    }
368    async fn set_state(&self, item: &ItemRef, state: &str) -> Result<()>;
369    async fn add_comment(&self, item: &ItemRef, body: &str) -> Result<()>;
370    /// The states this item can move to (provider-accurate). Defaults to empty,
371    /// letting the caller fall back to the states seen across the loaded items.
372    async fn available_states(&self, _item: &ItemRef) -> Result<Vec<String>> {
373        Ok(Vec::new())
374    }
375    /// Users this item can be assigned to (candidates for the assignee picker). The `id` on each
376    /// returned `User` MUST be the exact token this provider's `set_assignee` accepts. Empty by
377    /// default (no picker shown).
378    async fn assignable_users(&self, _item: &ItemRef) -> Result<Vec<User>> {
379        Ok(Vec::new())
380    }
381    /// Assign the item to a user (by an id from `assignable_users`), or `None` to unassign.
382    /// Unsupported by default.
383    async fn set_assignee(&self, _item: &ItemRef, _assignee_id: Option<&str>) -> Result<()> {
384        Err(Error::Provider("set_assignee not supported by this provider".into()))
385    }
386    /// Edit the item's title and/or description (`None` leaves that field unchanged).
387    /// Unsupported by default.
388    async fn update_fields(&self, _item: &ItemRef, _title: Option<&str>, _description: Option<&str>) -> Result<()> {
389        Err(Error::Provider("update_fields not supported by this provider".into()))
390    }
391}
392
393#[async_trait]
394pub trait PipelineSource: Send + Sync {
395    async fn discover(&self) -> Result<Vec<PipelineDefinition>>;
396    async fn list_runs(&self, query: &PipelineRunQuery) -> Result<Vec<PipelineRun>>;
397    async fn get_run(&self, run: &ItemRef) -> Result<PipelineRun>;
398    async fn logs(&self, run: &ItemRef, job_id: Option<&str>) -> Result<String>;
399    /// Triggers a pipeline. `definition` addresses the definition — its `repo` says which
400    /// repository's pipeline to start, which a connection spanning several cannot infer.
401    async fn trigger(&self, definition: &ItemRef, branch: Option<&str>) -> Result<()>;
402    /// Cancel a running or queued run. Unsupported by default.
403    async fn cancel_run(&self, _run: &ItemRef) -> Result<()> {
404        Err(Error::Provider("cancel_run not supported by this provider".into()))
405    }
406    /// Whether [`rerun_run`](Self::rerun_run) can re-run a whole run. `false` by default.
407    fn supports_rerun(&self) -> bool {
408        false
409    }
410    /// Whether [`rerun_run`](Self::rerun_run) can re-run only a run's failed jobs. `false` by
411    /// default; a provider may re-run whole runs but not failed jobs alone.
412    fn supports_rerun_failed(&self) -> bool {
413        false
414    }
415    /// Whether [`rerun_run`](Self::rerun_run) starts a *new* run rather than a new attempt of
416    /// the same one (GitLab/Azure/Bitbucket re-run a whole run by starting a fresh one). A UI
417    /// can only mark the open run as re-queued when this is `false`. `false` by default.
418    fn rerun_starts_new_run(&self, _failed_only: bool) -> bool {
419        false
420    }
421    /// Re-run a finished run — every job, or with `failed_only` just the ones that failed.
422    /// Returns the new run's id when the provider started a separate run
423    /// (see [`rerun_starts_new_run`](Self::rerun_starts_new_run)), `None` when the same run
424    /// was re-queued. Unsupported by default.
425    async fn rerun_run(&self, _run: &ItemRef, _failed_only: bool) -> Result<Option<String>> {
426        Err(Error::Provider("rerun not supported by this provider".into()))
427    }
428    /// Whether [`artifacts`](Self::artifacts) is implemented. `false` by default — the UI says
429    /// so rather than showing an empty list as if the run published nothing.
430    fn supports_artifacts(&self) -> bool {
431        false
432    }
433    /// The files a run published. Empty by default.
434    async fn artifacts(&self, _run: &ItemRef) -> Result<Vec<PipelineArtifact>> {
435        Ok(Vec::new())
436    }
437    /// Problems the run reported (annotations, failed test cases, timeline issues), most severe
438    /// first. Empty by default — callers fall back to scanning the log.
439    async fn annotations(&self, _run: &ItemRef) -> Result<Vec<PipelineAnnotation>> {
440        Ok(Vec::new())
441    }
442    /// Whether this provider can surface and act on pending run approvals/gates.
443    /// `false` by default — the UI shows the section as unsupported.
444    fn supports_approvals(&self) -> bool {
445        false
446    }
447    /// Whether the app can actually submit an approve/reject decision. Providers may
448    /// be able to *surface* a pending gate (`supports_approvals`) without being able
449    /// to act on it — e.g. Azure DevOps, where the pending environment check isn't
450    /// exposed as an actionable approval resource. Defaults to `supports_approvals()`.
451    fn can_respond_to_approvals(&self) -> bool {
452        self.supports_approvals()
453    }
454    /// Gates on a run that are waiting for a decision. Empty by default (unsupported).
455    async fn pending_approvals(&self, _run: &ItemRef) -> Result<Vec<PipelineApproval>> {
456        Ok(Vec::new())
457    }
458    /// Approve or reject a waiting gate on a run. Unsupported by default.
459    async fn respond_approval(
460        &self,
461        _run: &ItemRef,
462        _approval_id: &str,
463        _decision: ApprovalDecision,
464        _comment: Option<&str>,
465    ) -> Result<()> {
466        Err(Error::Provider("this provider doesn't support pipeline approvals".into()))
467    }
468}
469
470/// The provider's personal notification feed (GitHub notifications, GitLab todos, Linear
471/// notifications). Only some providers have one — see [`Capabilities::supports_notifications`].
472#[async_trait]
473pub trait NotificationSource: Send + Sync {
474    /// The current user's notifications, newest first.
475    async fn list(&self) -> Result<Vec<Notification>>;
476    /// Mark a single notification read by its id.
477    async fn mark_read(&self, id: &str) -> Result<()>;
478    /// Mark every notification read. Default: mark each one; providers with a bulk endpoint
479    /// (GitHub `PUT /notifications`, GitLab `/todos/mark_as_done`) should override.
480    async fn mark_all_read(&self) -> Result<()> {
481        for n in self.list().await? {
482            let _ = self.mark_read(&n.id).await;
483        }
484        Ok(())
485    }
486}
487
488/// A live, authenticated connection exposing only the sources it supports.
489#[async_trait]
490pub trait ProviderConnection: Send + Sync {
491    fn connection_id(&self) -> &str;
492    fn provider_type(&self) -> ProviderType;
493    fn display_name(&self) -> &str;
494    fn capabilities(&self) -> &Capabilities;
495    fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>>;
496    fn work_items(&self) -> Option<Arc<dyn WorkItemSource>>;
497    fn pipelines(&self) -> Option<Arc<dyn PipelineSource>>;
498    /// The notification feed, when the provider has one. Defaults to `None` (unsupported).
499    fn notifications(&self) -> Option<Arc<dyn NotificationSource>> {
500        None
501    }
502    /// The repositories these credentials can reach, most-recently-active first — the candidate
503    /// list for the connection's repository scope picker.
504    ///
505    /// Defaults to empty, which is the right answer for providers that aren't repo-addressed
506    /// (Jira is project-addressed, Linear team-addressed). Only the scope picker calls this, so
507    /// a provider whose discovery is wrong shows an empty picker; it cannot break fetching for a
508    /// connection that already has a scope.
509    async fn discover_repositories(&self) -> Result<RepositoryPage> {
510        Ok(RepositoryPage::default())
511    }
512    /// Cheap reachability/auth check for the connections health bar.
513    async fn check(&self) -> bool;
514}
515
516/// Builds a live [`ProviderConnection`] for one provider type.
517pub trait ProviderFactory: Send + Sync {
518    fn provider_type(&self) -> ProviderType;
519    fn describe_capabilities(&self) -> Capabilities;
520    fn create(&self, connection: &Connection, secret: Option<String>) -> Result<Arc<dyn ProviderConnection>>;
521}
522
523/// Resolves provider factories and creates connections by provider type.
524pub struct ProviderRegistry {
525    factories: HashMap<ProviderType, Arc<dyn ProviderFactory>>,
526}
527
528impl ProviderRegistry {
529    pub fn new(factories: Vec<Arc<dyn ProviderFactory>>) -> Self {
530        let factories = factories.into_iter().map(|f| (f.provider_type(), f)).collect();
531        Self { factories }
532    }
533
534    pub fn available(&self) -> Vec<ProviderType> {
535        self.factories.keys().copied().collect()
536    }
537
538    pub fn supports(&self, provider: ProviderType) -> bool {
539        self.factories.contains_key(&provider)
540    }
541
542    pub fn describe(&self, provider: ProviderType) -> Option<Capabilities> {
543        self.factories.get(&provider).map(|f| f.describe_capabilities())
544    }
545
546    pub fn create(&self, connection: &Connection, secret: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
547        let factory = self
548            .factories
549            .get(&connection.provider_type)
550            .ok_or_else(|| Error::Config(format!("no provider registered for '{}'", connection.provider_type.as_str())))?;
551        factory.create(connection, secret)
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use async_trait::async_trait;
559
560    struct FakeConn {
561        caps: Capabilities,
562    }
563
564    #[async_trait]
565    impl ProviderConnection for FakeConn {
566        fn connection_id(&self) -> &str {
567            "fake"
568        }
569        fn provider_type(&self) -> ProviderType {
570            ProviderType::GitHub
571        }
572        fn display_name(&self) -> &str {
573            "Fake"
574        }
575        fn capabilities(&self) -> &Capabilities {
576            &self.caps
577        }
578        fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>> {
579            None
580        }
581        fn work_items(&self) -> Option<Arc<dyn WorkItemSource>> {
582            None
583        }
584        fn pipelines(&self) -> Option<Arc<dyn PipelineSource>> {
585            None
586        }
587        async fn check(&self) -> bool {
588            true
589        }
590    }
591
592    struct FakeFactory {
593        provider: ProviderType,
594        caps: Capabilities,
595    }
596
597    impl ProviderFactory for FakeFactory {
598        fn provider_type(&self) -> ProviderType {
599            self.provider
600        }
601        fn describe_capabilities(&self) -> Capabilities {
602            self.caps.clone()
603        }
604        fn create(&self, _connection: &Connection, _secret: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
605            Ok(Arc::new(FakeConn { caps: self.caps.clone() }))
606        }
607    }
608
609    fn conn(provider: ProviderType) -> Connection {
610        Connection {
611            id: "c".into(),
612            provider_type: provider,
613            display_name: "c".into(),
614            base_url: None,
615            organization: None,
616            project: None,
617            repository: None,
618            username: None,
619            credential_ref: None,
620            repo_scope: None,
621        }
622    }
623
624    #[test]
625    fn capabilities_supports_reflects_flags() {
626        let caps = Capabilities { supports_pull_requests: true, ..Default::default() };
627        assert!(caps.supports(Section::PullRequests));
628        assert!(!caps.supports(Section::WorkItems));
629    }
630
631    #[test]
632    fn registry_dispatches_and_rejects_unknown() {
633        let caps = Capabilities { supports_pull_requests: true, ..Default::default() };
634        let registry = ProviderRegistry::new(vec![Arc::new(FakeFactory { provider: ProviderType::GitHub, caps })]);
635
636        assert!(registry.supports(ProviderType::GitHub));
637        assert!(!registry.supports(ProviderType::Linear));
638
639        let live = registry.create(&conn(ProviderType::GitHub), Some("pat".into())).unwrap();
640        assert_eq!(live.provider_type(), ProviderType::GitHub);
641        assert!(registry.create(&conn(ProviderType::Linear), None).is_err());
642    }
643
644    #[test]
645    fn scope_falls_back_only_when_absent_never_when_empty() {
646        let mut c = conn(ProviderType::GitHub);
647
648        // Never established → the legacy single repository still applies, so an existing
649        // connection keeps behaving exactly as it did.
650        c.repository = Some("acme/pay".into());
651        assert_eq!(c.resolve_repo_scope(|| c.repository.clone()), vec!["acme/pay".to_string()]);
652
653        // Chosen, and empty. This is the state a bare Vec cannot express: fetch nothing. If the
654        // fallback keyed on emptiness instead of absence, the legacy repository would silently
655        // refill it on the next load.
656        c.repo_scope = Some(vec![]);
657        assert!(c.resolve_repo_scope(|| c.repository.clone()).is_empty());
658
659        // Chosen explicitly → exactly those, legacy ignored.
660        c.repo_scope = Some(vec!["acme/pay".into(), "acme/ledger".into()]);
661        assert_eq!(c.resolve_repo_scope(|| c.repository.clone()).len(), 2);
662    }
663
664    #[test]
665    fn item_ref_carries_the_connection_relative_repo() {
666        let r = ItemRef::in_repo("acme/pay", "7");
667        assert_eq!(r.repo.as_deref(), Some("acme/pay"));
668        assert_eq!(ItemRef::new("7").repo, None);
669        assert_eq!(ItemRef::maybe(None, "7"), ItemRef::new("7"));
670    }
671
672    #[test]
673    fn pull_request_query_decorates_by_default() {
674        // Nothing loses decoration by accident — a caller must opt out on purpose.
675        assert!(PullRequestQuery::default().decorate);
676    }
677
678    #[test]
679    fn new_id_is_prefixed() {
680        assert!(Connection::new_id(ProviderType::GitHub).starts_with("github-"));
681    }
682}