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/// Which of the optional pull-request writes a connection can perform. All `false` by default:
275/// a frontend shows the key for each only where the provider says it works, so a forge without
276/// an API for one (Bitbucket cannot reopen a declined pull request; GitLab has no draft flag
277/// beyond the title prefix) never offers a key that would only fail.
278#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
279pub struct PrWriteSupport {
280    /// [`PullRequestSource::resolve_thread`] works.
281    pub resolve_threads: bool,
282    /// [`PullRequestSource::set_draft`] works in both directions.
283    pub draft: bool,
284    /// [`PullRequestSource::set_closed`] can close an open pull request.
285    pub close: bool,
286    /// [`PullRequestSource::set_closed`] can reopen a closed one.
287    pub reopen: bool,
288    /// [`PullRequestSource::reviewable_users`] and [`PullRequestSource::request_reviewer`] work.
289    pub request_reviewer: bool,
290}
291
292impl PrWriteSupport {
293    /// Every write supported — what a full-featured forge (and the demo) reports.
294    pub const ALL: PrWriteSupport = PrWriteSupport { resolve_threads: true, draft: true, close: true, reopen: true, request_reviewer: true };
295}
296
297// ---- capability-scoped sources ----
298
299/// Pull requests from one connection. Every method that names a single pull request takes an
300/// [`ItemRef`], not a bare id: on a connection spanning several repositories the id alone does
301/// not say *which* `#7` is meant, and guessing silently opens, merges or comments on the wrong
302/// one. The `repo` on the ref is connection-relative.
303#[async_trait]
304pub trait PullRequestSource: Send + Sync {
305    async fn list(&self, query: &PullRequestQuery) -> Result<Vec<PullRequest>>;
306    /// The handle identifying the signed-in user on this connection — the same identity
307    /// [`list`](Self::list) resolves internally when it is asked for `Mine` or `ReviewRequested`.
308    ///
309    /// Exposed so a caller can fetch one *unfiltered* pool and derive those views itself, rather
310    /// than issuing a `list` per filter that re-fetches identical rows. It is matched against a
311    /// user's handle, display name or id by
312    /// [`apply_pull_request_filter`](crate::filter::apply_pull_request_filter).
313    ///
314    /// `None` — the default, and what a provider returns when it cannot establish an identity —
315    /// makes that filter pass everything through rather than silently emptying the list. A caller
316    /// deriving views locally must treat it as "cannot filter", not as "nothing matches": showing
317    /// an unfiltered list under a "Mine" heading is wrong, but quietly showing none of your pull
318    /// requests is worse, and matches what `list` already does in the same situation.
319    async fn current_user(&self) -> Result<Option<String>> {
320        Ok(None)
321    }
322    async fn get(&self, item: &ItemRef) -> Result<PullRequest>;
323    async fn threads(&self, item: &ItemRef) -> Result<Vec<CommentThread>>;
324    /// The event timeline (reviews/approvals, merges, state changes, …), oldest → newest.
325    /// Defaults to empty for providers without a timeline/activity API.
326    async fn timeline(&self, _item: &ItemRef) -> Result<Vec<TimelineEvent>> {
327        Ok(Vec::new())
328    }
329    async fn changes(&self, item: &ItemRef) -> Result<Vec<FileChange>>;
330    /// Individual named CI checks. Defaults to empty for providers that don't expose them.
331    async fn checks(&self, _item: &ItemRef) -> Result<Vec<CheckRun>> {
332        Ok(Vec::new())
333    }
334    /// Commits on the pull request. Defaults to empty.
335    async fn commits(&self, _item: &ItemRef) -> Result<Vec<Commit>> {
336        Ok(Vec::new())
337    }
338    /// Whether this provider's *list* endpoint leaves the decorated fields out, so a caller that
339    /// skipped decoration has to fetch them per row to show them.
340    ///
341    /// `false` by default, and that is the right answer for most providers: GitLab, Azure DevOps
342    /// and Bitbucket fill everything they have straight from the list payload, so asking them for
343    /// decoration is a call per row that returns what the caller already had. GitHub is the
344    /// exception — `/pulls` omits `mergeable_state`, `changed_files`, `additions` and `deletions`
345    /// entirely — and overrides this.
346    fn list_omits_decoration(&self) -> bool {
347        false
348    }
349    /// Whether [`list`](Self::list) asks the provider for the rows of a `Mine` or
350    /// `ReviewRequested` filter, rather than fetching a page per repository and filtering it.
351    ///
352    /// `false` by default, for a provider that fetches the newest page of each repository in
353    /// scope and keeps the rows that match: its filtered `list` returns a subset of what an
354    /// unfiltered one would have, and a caller holding the unfiltered rows gains nothing by
355    /// asking again. On a busy repository that page is a window — the newest 50 pull requests —
356    /// and your own fall out of it as soon as 50 newer ones exist. A provider that addresses the
357    /// filter itself overrides this (GitHub searches `author:@me` across the scope; GitLab, Azure
358    /// DevOps and Bitbucket pass the author or reviewer to each repository's list), so a caller
359    /// that pools the unfiltered rows knows those views are worth fetching on their own.
360    fn list_targets_filter(&self) -> bool {
361        false
362    }
363    /// Whether giving your review takes a pull request out of the `ReviewRequested` view.
364    ///
365    /// `false` by default: on GitLab, Azure DevOps and Bitbucket you stay a reviewer after you
366    /// have voted, so the merge request keeps naming you and keeps its place in the view. GitHub
367    /// is the exception — `requested_reviewers` only names those who have *not* reviewed yet, and
368    /// the view is built on it — so a pull request leaves the view the moment you review it. A
369    /// caller that knows this can take the row off its list as soon as the review is sent,
370    /// rather than on the refetch that would.
371    fn review_clears_request(&self) -> bool {
372        false
373    }
374    /// The fields the list endpoint omits, for one pull request. Defaults to projecting them out
375    /// of a full [`get`](Self::get) — providers with a cheaper route override it.
376    async fn decorate(&self, item: &ItemRef) -> Result<PrDecoration> {
377        Ok(PrDecoration::from_pull_request(&self.get(item).await?))
378    }
379    /// Files changed by a single commit on the pull request (with inline patches
380    /// where the provider supplies them). Defaults to unsupported (empty).
381    async fn commit_changes(&self, _item: &ItemRef, _sha: &str) -> Result<Vec<FileChange>> {
382        Ok(Vec::new())
383    }
384    /// The full text of `path` as of commit `sha` — the pull request's head when `sha` is
385    /// `None` — so a diff can show the unchanged lines between its hunks. Fetched on demand,
386    /// never on a reload. `None` when the forge can't say: a binary or oversized file, a path
387    /// missing at that commit, or (the default) a provider without a content API.
388    async fn file_text(&self, _item: &ItemRef, _path: &str, _sha: Option<&str>) -> Result<Option<String>> {
389        Ok(None)
390    }
391    async fn add_comment(&self, item: &ItemRef, body: &str) -> Result<()>;
392    /// Replies to an existing comment thread (returned by [`threads`](Self::threads)), so you can
393    /// answer someone else's comment in-thread rather than starting a new top-level one. Defaults
394    /// to posting a plain top-level comment — providers whose threads are really flat (e.g. a
395    /// GitHub PR *conversation*, which has no reply API) keep that behaviour; providers with real
396    /// threads (Azure, GitLab discussions, Bitbucket, GitHub *review* threads) override it.
397    async fn reply_to_thread(&self, item: &ItemRef, _thread_id: &str, body: &str) -> Result<()> {
398        self.add_comment(item, body).await
399    }
400    async fn vote(&self, item: &ItemRef, vote: ReviewVote) -> Result<()>;
401    async fn merge(&self, item: &ItemRef, options: &MergeOptions) -> Result<()>;
402    /// Reverts a merged pull request (undoes its merge commit on the target branch). Defaults to
403    /// unsupported — only providers with a revert API (GitLab, Azure DevOps) override this.
404    async fn revert(&self, _item: &ItemRef) -> Result<()> {
405        Err(Error::Provider("this provider has no revert API — revert it from the provider's web UI".into()))
406    }
407    /// Submits a review with inline line comments. `event` maps to approve /
408    /// request-changes / plain comment. Defaults to unsupported.
409    async fn submit_review(&self, _item: &ItemRef, _event: ReviewVote, _comments: &[LineComment]) -> Result<()> {
410        Err(Error::Provider("this provider doesn't support line-comment reviews".into()))
411    }
412    /// Which of the writes below this connection can perform. Nothing by default; a provider
413    /// reports exactly the ones it implements so a frontend offers only those.
414    fn pr_writes(&self) -> PrWriteSupport {
415        PrWriteSupport::default()
416    }
417    /// Marks a comment thread (by the id [`threads`](Self::threads) gave it) resolved or, with
418    /// `resolved = false`, open again. Defaults to unsupported.
419    async fn resolve_thread(&self, _item: &ItemRef, _thread_id: &str, _resolved: bool) -> Result<()> {
420        Err(Error::Provider("this provider doesn't support resolving comment threads".into()))
421    }
422    /// Converts an open pull request to a draft (`draft = true`) or marks a draft ready for
423    /// review (`draft = false`). Defaults to unsupported.
424    async fn set_draft(&self, _item: &ItemRef, _draft: bool) -> Result<()> {
425        Err(Error::Provider("this provider doesn't support changing a pull request's draft state".into()))
426    }
427    /// Closes an open pull request without merging (`closed = true`) or reopens a closed one
428    /// (`closed = false`). Defaults to unsupported.
429    async fn set_closed(&self, _item: &ItemRef, _closed: bool) -> Result<()> {
430        Err(Error::Provider("this provider doesn't support closing or reopening pull requests".into()))
431    }
432    /// The people who can be asked to review this pull request — the repository's collaborators,
433    /// members or team, depending on the forge. Fetched once per repository and kept for the
434    /// run by a caller; a provider should likewise cache it on its client. Defaults to none.
435    async fn reviewable_users(&self, _item: &ItemRef) -> Result<Vec<User>> {
436        Ok(Vec::new())
437    }
438    /// Asks `user_id` (an id from [`reviewable_users`](Self::reviewable_users)) to review the
439    /// pull request, keeping any reviewers already on it. Defaults to unsupported.
440    async fn request_reviewer(&self, _item: &ItemRef, _user_id: &str) -> Result<()> {
441        Err(Error::Provider("this provider doesn't support requesting reviewers".into()))
442    }
443}
444
445#[async_trait]
446pub trait WorkItemSource: Send + Sync {
447    async fn list(&self, query: &WorkItemQuery) -> Result<Vec<WorkItem>>;
448    async fn get(&self, item: &ItemRef) -> Result<WorkItem>;
449    async fn threads(&self, item: &ItemRef) -> Result<Vec<CommentThread>>;
450    /// The event history (status changes, assignments, …), oldest → newest. Defaults to empty.
451    async fn timeline(&self, _item: &ItemRef) -> Result<Vec<TimelineEvent>> {
452        Ok(Vec::new())
453    }
454    async fn set_state(&self, item: &ItemRef, state: &str) -> Result<()>;
455    async fn add_comment(&self, item: &ItemRef, body: &str) -> Result<()>;
456    /// The states this item can move to (provider-accurate). Defaults to empty,
457    /// letting the caller fall back to the states seen across the loaded items.
458    async fn available_states(&self, _item: &ItemRef) -> Result<Vec<String>> {
459        Ok(Vec::new())
460    }
461    /// Users this item can be assigned to (candidates for the assignee picker). The `id` on each
462    /// returned `User` MUST be the exact token this provider's `set_assignee` accepts. Empty by
463    /// default (no picker shown).
464    async fn assignable_users(&self, _item: &ItemRef) -> Result<Vec<User>> {
465        Ok(Vec::new())
466    }
467    /// Assign the item to a user (by an id from `assignable_users`), or `None` to unassign.
468    /// Unsupported by default.
469    async fn set_assignee(&self, _item: &ItemRef, _assignee_id: Option<&str>) -> Result<()> {
470        Err(Error::Provider("set_assignee not supported by this provider".into()))
471    }
472    /// Edit the item's title and/or description (`None` leaves that field unchanged).
473    /// Unsupported by default.
474    async fn update_fields(&self, _item: &ItemRef, _title: Option<&str>, _description: Option<&str>) -> Result<()> {
475        Err(Error::Provider("update_fields not supported by this provider".into()))
476    }
477}
478
479#[async_trait]
480pub trait PipelineSource: Send + Sync {
481    async fn discover(&self) -> Result<Vec<PipelineDefinition>>;
482    async fn list_runs(&self, query: &PipelineRunQuery) -> Result<Vec<PipelineRun>>;
483    async fn get_run(&self, run: &ItemRef) -> Result<PipelineRun>;
484    /// The signed-in user's identity, as [`PullRequestSource::current_user`] gives it — matched
485    /// against a run's `triggered_by` by [`run_triggered_by`](crate::filter::run_triggered_by) so
486    /// the Command Center shows only the runs you started.
487    ///
488    /// `None` — the default — means the identity cannot be established. Unlike the pull-request
489    /// filter, that shows *none* of the connection's runs there: a "your work" list full of other
490    /// people's builds is worse than an empty one, and the Pipelines page still lists them all.
491    async fn current_user(&self) -> Result<Option<String>> {
492        Ok(None)
493    }
494    async fn logs(&self, run: &ItemRef, job_id: Option<&str>) -> Result<String>;
495    /// Triggers a pipeline. `definition` addresses the definition — its `repo` says which
496    /// repository's pipeline to start, which a connection spanning several cannot infer.
497    async fn trigger(&self, definition: &ItemRef, branch: Option<&str>) -> Result<()>;
498    /// Cancel a running or queued run. Unsupported by default.
499    async fn cancel_run(&self, _run: &ItemRef) -> Result<()> {
500        Err(Error::Provider("cancel_run not supported by this provider".into()))
501    }
502    /// Whether [`rerun_run`](Self::rerun_run) can re-run a whole run. `false` by default.
503    fn supports_rerun(&self) -> bool {
504        false
505    }
506    /// Whether [`rerun_run`](Self::rerun_run) can re-run only a run's failed jobs. `false` by
507    /// default; a provider may re-run whole runs but not failed jobs alone.
508    fn supports_rerun_failed(&self) -> bool {
509        false
510    }
511    /// Whether [`rerun_run`](Self::rerun_run) starts a *new* run rather than a new attempt of
512    /// the same one (GitLab/Azure/Bitbucket re-run a whole run by starting a fresh one). A UI
513    /// can only mark the open run as re-queued when this is `false`. `false` by default.
514    fn rerun_starts_new_run(&self, _failed_only: bool) -> bool {
515        false
516    }
517    /// Re-run a finished run — every job, or with `failed_only` just the ones that failed.
518    /// Returns the new run's id when the provider started a separate run
519    /// (see [`rerun_starts_new_run`](Self::rerun_starts_new_run)), `None` when the same run
520    /// was re-queued. Unsupported by default.
521    async fn rerun_run(&self, _run: &ItemRef, _failed_only: bool) -> Result<Option<String>> {
522        Err(Error::Provider("rerun not supported by this provider".into()))
523    }
524    /// Whether [`artifacts`](Self::artifacts) is implemented. `false` by default — the UI says
525    /// so rather than showing an empty list as if the run published nothing.
526    fn supports_artifacts(&self) -> bool {
527        false
528    }
529    /// The files a run published. Empty by default.
530    async fn artifacts(&self, _run: &ItemRef) -> Result<Vec<PipelineArtifact>> {
531        Ok(Vec::new())
532    }
533    /// Problems the run reported (annotations, failed test cases, timeline issues), most severe
534    /// first. Empty by default — callers fall back to scanning the log.
535    async fn annotations(&self, _run: &ItemRef) -> Result<Vec<PipelineAnnotation>> {
536        Ok(Vec::new())
537    }
538    /// Whether this provider can surface and act on pending run approvals/gates.
539    /// `false` by default — the UI shows the section as unsupported.
540    fn supports_approvals(&self) -> bool {
541        false
542    }
543    /// Whether the app can actually submit an approve/reject decision. Providers may
544    /// be able to *surface* a pending gate (`supports_approvals`) without being able
545    /// to act on it — e.g. Azure DevOps, where the pending environment check isn't
546    /// exposed as an actionable approval resource. Defaults to `supports_approvals()`.
547    fn can_respond_to_approvals(&self) -> bool {
548        self.supports_approvals()
549    }
550    /// Gates on a run that are waiting for a decision. Empty by default (unsupported).
551    async fn pending_approvals(&self, _run: &ItemRef) -> Result<Vec<PipelineApproval>> {
552        Ok(Vec::new())
553    }
554    /// Approve or reject a waiting gate on a run. Unsupported by default.
555    async fn respond_approval(
556        &self,
557        _run: &ItemRef,
558        _approval_id: &str,
559        _decision: ApprovalDecision,
560        _comment: Option<&str>,
561    ) -> Result<()> {
562        Err(Error::Provider("this provider doesn't support pipeline approvals".into()))
563    }
564}
565
566/// The provider's personal notification feed (GitHub notifications, GitLab todos, Linear
567/// notifications). Only some providers have one — see [`Capabilities::supports_notifications`].
568#[async_trait]
569pub trait NotificationSource: Send + Sync {
570    /// The current user's notifications, newest first.
571    async fn list(&self) -> Result<Vec<Notification>>;
572    /// Mark a single notification read by its id.
573    async fn mark_read(&self, id: &str) -> Result<()>;
574    /// Mark every notification read. Default: mark each one; providers with a bulk endpoint
575    /// (GitHub `PUT /notifications`, GitLab `/todos/mark_as_done`) should override.
576    async fn mark_all_read(&self) -> Result<()> {
577        for n in self.list().await? {
578            let _ = self.mark_read(&n.id).await;
579        }
580        Ok(())
581    }
582}
583
584/// A live, authenticated connection exposing only the sources it supports.
585#[async_trait]
586pub trait ProviderConnection: Send + Sync {
587    fn connection_id(&self) -> &str;
588    fn provider_type(&self) -> ProviderType;
589    fn display_name(&self) -> &str;
590    fn capabilities(&self) -> &Capabilities;
591    fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>>;
592    fn work_items(&self) -> Option<Arc<dyn WorkItemSource>>;
593    fn pipelines(&self) -> Option<Arc<dyn PipelineSource>>;
594    /// The notification feed, when the provider has one. Defaults to `None` (unsupported).
595    fn notifications(&self) -> Option<Arc<dyn NotificationSource>> {
596        None
597    }
598    /// The repositories these credentials can reach, most-recently-active first — the candidate
599    /// list for the connection's repository scope picker.
600    ///
601    /// Defaults to empty, which is the right answer for providers that aren't repo-addressed
602    /// (Jira is project-addressed, Linear team-addressed). Only the scope picker calls this, so
603    /// a provider whose discovery is wrong shows an empty picker; it cannot break fetching for a
604    /// connection that already has a scope.
605    async fn discover_repositories(&self) -> Result<RepositoryPage> {
606        Ok(RepositoryPage::default())
607    }
608    /// Cheap reachability/auth check for the connections health bar.
609    async fn check(&self) -> bool;
610}
611
612/// Builds a live [`ProviderConnection`] for one provider type.
613pub trait ProviderFactory: Send + Sync {
614    fn provider_type(&self) -> ProviderType;
615    fn describe_capabilities(&self) -> Capabilities;
616    fn create(&self, connection: &Connection, secret: Option<String>) -> Result<Arc<dyn ProviderConnection>>;
617}
618
619/// Resolves provider factories and creates connections by provider type.
620pub struct ProviderRegistry {
621    factories: HashMap<ProviderType, Arc<dyn ProviderFactory>>,
622}
623
624impl ProviderRegistry {
625    pub fn new(factories: Vec<Arc<dyn ProviderFactory>>) -> Self {
626        let factories = factories.into_iter().map(|f| (f.provider_type(), f)).collect();
627        Self { factories }
628    }
629
630    pub fn available(&self) -> Vec<ProviderType> {
631        self.factories.keys().copied().collect()
632    }
633
634    pub fn supports(&self, provider: ProviderType) -> bool {
635        self.factories.contains_key(&provider)
636    }
637
638    pub fn describe(&self, provider: ProviderType) -> Option<Capabilities> {
639        self.factories.get(&provider).map(|f| f.describe_capabilities())
640    }
641
642    pub fn create(&self, connection: &Connection, secret: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
643        let factory = self
644            .factories
645            .get(&connection.provider_type)
646            .ok_or_else(|| Error::Config(format!("no provider registered for '{}'", connection.provider_type.as_str())))?;
647        factory.create(connection, secret)
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use async_trait::async_trait;
655
656    struct FakeConn {
657        caps: Capabilities,
658    }
659
660    #[async_trait]
661    impl ProviderConnection for FakeConn {
662        fn connection_id(&self) -> &str {
663            "fake"
664        }
665        fn provider_type(&self) -> ProviderType {
666            ProviderType::GitHub
667        }
668        fn display_name(&self) -> &str {
669            "Fake"
670        }
671        fn capabilities(&self) -> &Capabilities {
672            &self.caps
673        }
674        fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>> {
675            None
676        }
677        fn work_items(&self) -> Option<Arc<dyn WorkItemSource>> {
678            None
679        }
680        fn pipelines(&self) -> Option<Arc<dyn PipelineSource>> {
681            None
682        }
683        async fn check(&self) -> bool {
684            true
685        }
686    }
687
688    struct FakeFactory {
689        provider: ProviderType,
690        caps: Capabilities,
691    }
692
693    impl ProviderFactory for FakeFactory {
694        fn provider_type(&self) -> ProviderType {
695            self.provider
696        }
697        fn describe_capabilities(&self) -> Capabilities {
698            self.caps.clone()
699        }
700        fn create(&self, _connection: &Connection, _secret: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
701            Ok(Arc::new(FakeConn { caps: self.caps.clone() }))
702        }
703    }
704
705    fn conn(provider: ProviderType) -> Connection {
706        Connection {
707            id: "c".into(),
708            provider_type: provider,
709            display_name: "c".into(),
710            base_url: None,
711            organization: None,
712            project: None,
713            repository: None,
714            username: None,
715            credential_ref: None,
716            repo_scope: None,
717        }
718    }
719
720    #[test]
721    fn capabilities_supports_reflects_flags() {
722        let caps = Capabilities { supports_pull_requests: true, ..Default::default() };
723        assert!(caps.supports(Section::PullRequests));
724        assert!(!caps.supports(Section::WorkItems));
725    }
726
727    #[test]
728    fn registry_dispatches_and_rejects_unknown() {
729        let caps = Capabilities { supports_pull_requests: true, ..Default::default() };
730        let registry = ProviderRegistry::new(vec![Arc::new(FakeFactory { provider: ProviderType::GitHub, caps })]);
731
732        assert!(registry.supports(ProviderType::GitHub));
733        assert!(!registry.supports(ProviderType::Linear));
734
735        let live = registry.create(&conn(ProviderType::GitHub), Some("pat".into())).unwrap();
736        assert_eq!(live.provider_type(), ProviderType::GitHub);
737        assert!(registry.create(&conn(ProviderType::Linear), None).is_err());
738    }
739
740    #[test]
741    fn scope_falls_back_only_when_absent_never_when_empty() {
742        let mut c = conn(ProviderType::GitHub);
743
744        // Never established → the legacy single repository still applies, so an existing
745        // connection keeps behaving exactly as it did.
746        c.repository = Some("acme/pay".into());
747        assert_eq!(c.resolve_repo_scope(|| c.repository.clone()), vec!["acme/pay".to_string()]);
748
749        // Chosen, and empty. This is the state a bare Vec cannot express: fetch nothing. If the
750        // fallback keyed on emptiness instead of absence, the legacy repository would silently
751        // refill it on the next load.
752        c.repo_scope = Some(vec![]);
753        assert!(c.resolve_repo_scope(|| c.repository.clone()).is_empty());
754
755        // Chosen explicitly → exactly those, legacy ignored.
756        c.repo_scope = Some(vec!["acme/pay".into(), "acme/ledger".into()]);
757        assert_eq!(c.resolve_repo_scope(|| c.repository.clone()).len(), 2);
758    }
759
760    #[test]
761    fn item_ref_carries_the_connection_relative_repo() {
762        let r = ItemRef::in_repo("acme/pay", "7");
763        assert_eq!(r.repo.as_deref(), Some("acme/pay"));
764        assert_eq!(ItemRef::new("7").repo, None);
765        assert_eq!(ItemRef::maybe(None, "7"), ItemRef::new("7"));
766    }
767
768    #[test]
769    fn pull_request_query_decorates_by_default() {
770        // Nothing loses decoration by accident — a caller must opt out on purpose.
771        assert!(PullRequestQuery::default().decorate);
772    }
773
774    #[test]
775    fn new_id_is_prefixed() {
776        assert!(Connection::new_id(ProviderType::GitHub).starts_with("github-"));
777    }
778
779    /// A source that implements only the required methods: the optional writes default to
780    /// "unsupported", and `pr_writes` says so, so a frontend offers none of their keys.
781    struct BarePrs;
782
783    #[async_trait]
784    impl PullRequestSource for BarePrs {
785        async fn list(&self, _q: &PullRequestQuery) -> Result<Vec<PullRequest>> {
786            Ok(Vec::new())
787        }
788        async fn get(&self, item: &ItemRef) -> Result<PullRequest> {
789            Err(Error::NotFound(item.id.clone()))
790        }
791        async fn threads(&self, _item: &ItemRef) -> Result<Vec<CommentThread>> {
792            Ok(Vec::new())
793        }
794        async fn changes(&self, _item: &ItemRef) -> Result<Vec<FileChange>> {
795            Ok(Vec::new())
796        }
797        async fn add_comment(&self, _item: &ItemRef, _body: &str) -> Result<()> {
798            Ok(())
799        }
800        async fn vote(&self, _item: &ItemRef, _vote: ReviewVote) -> Result<()> {
801            Ok(())
802        }
803        async fn merge(&self, _item: &ItemRef, _options: &MergeOptions) -> Result<()> {
804            Ok(())
805        }
806    }
807
808    #[tokio::test]
809    async fn optional_pull_request_writes_default_to_unsupported() {
810        let src = BarePrs;
811        let item = ItemRef::new("1");
812        assert_eq!(src.pr_writes(), PrWriteSupport::default(), "nothing advertised");
813        assert!(!PrWriteSupport::default().resolve_threads && !PrWriteSupport::default().request_reviewer);
814        assert!(src.resolve_thread(&item, "t1", true).await.is_err());
815        assert!(src.set_draft(&item, false).await.is_err());
816        assert!(src.set_closed(&item, true).await.is_err());
817        assert!(src.request_reviewer(&item, "u1").await.is_err());
818        assert!(src.reviewable_users(&item).await.unwrap().is_empty(), "no people rather than an error, so a picker opens empty");
819        let all = PrWriteSupport::ALL;
820        assert!(all.resolve_threads && all.draft && all.close && all.reopen && all.request_reviewer);
821    }
822}