forgetop-core 1.10.2

Domain model, configuration, and keychain-backed token storage for forgetop. Internal crate: no API stability guarantees, use the `forgetop` binary instead.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
//! Capability-scoped provider traits, connection descriptor, capabilities, queries,
//! and the provider registry (mirrors `Forgetop.Core.Providers`).

use std::collections::HashMap;
use std::sync::Arc;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use crate::domain::*;
use crate::error::{Error, Result};

// ---- connection descriptor ----

/// A configured connection: identity, optional scope, and a reference to the PAT in
/// the secret store (never the secret itself).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Connection {
    pub id: String,
    pub provider_type: ProviderType,
    pub display_name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub base_url: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub organization: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repository: Option<String>,
    /// Identity for Basic-auth providers (e.g. Jira email, Bitbucket username).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub username: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub credential_ref: Option<String>,
    /// The repositories this connection fetches from, **connection-relative** (`acme/pay` — see
    /// [`crate::repo`]).
    ///
    /// Three states, all distinct and all meaningful:
    /// * `None` — never established. Fall back to the legacy single [`Self::repository`].
    /// * `Some([])` — the user chose no repositories. Fetch nothing; do **not** refill from the
    ///   legacy field, or an intentionally-emptied scope silently comes back on the next load.
    /// * `Some([…])` — fetch these.
    ///
    /// Fallbacks must therefore key on the scope being *absent*, never on it being *empty*.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repo_scope: Option<Vec<String>>,
}

impl Connection {
    pub fn new_id(provider: ProviderType) -> String {
        format!("{}-{}", provider.as_str().to_lowercase(), uuid::Uuid::new_v4().simple())
    }

    /// The repositories to fetch from, resolved at factory time so nothing on disk is rewritten:
    /// an explicit scope wins whenever it is `Some`, and only its absence falls back to the
    /// legacy single repository (via `legacy`, which each provider spells its own way). An
    /// existing single-repository connection therefore yields a one-element scope and behaves
    /// exactly as before.
    pub fn resolve_repo_scope(&self, legacy: impl FnOnce() -> Option<String>) -> Vec<String> {
        match &self.repo_scope {
            Some(scope) => scope.clone(),
            None => legacy().into_iter().collect(),
        }
    }
}

/// Addresses one item inside a connection: which repository it lives in, plus the
/// provider-native id.
///
/// `repo` is always **connection-relative** (`acme/pay`) — never host-qualified. It is `None`
/// only when the caller genuinely has no repository to give; a provider whose scope holds a
/// single repository resolves that case, and one with a wider scope rejects it rather than
/// guessing.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ItemRef {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repo: Option<String>,
    pub id: String,
}

impl ItemRef {
    /// An unaddressed reference — only resolvable against a single-repository scope.
    pub fn new(id: impl Into<String>) -> Self {
        ItemRef { repo: None, id: id.into() }
    }

    /// A fully addressed reference. `repo` must be connection-relative.
    pub fn in_repo(repo: impl Into<String>, id: impl Into<String>) -> Self {
        ItemRef { repo: Some(repo.into()), id: id.into() }
    }

    /// Builds a reference from an optional connection-relative repository.
    pub fn maybe(repo: Option<String>, id: impl Into<String>) -> Self {
        ItemRef { repo, id: id.into() }
    }
}

/// The repositories a connection's credentials can reach, for the scope picker.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryPage {
    /// Connection-relative repository paths, most-recently-active first.
    pub repositories: Vec<String>,
    /// True when the provider had more than we fetched, so the picker can say "5 of 500+"
    /// rather than presenting a cap as a total. Truncation must never be silent.
    pub truncated: bool,
}

// ---- capabilities ----

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VoteStyle {
    BinaryApprove,
    NumericVotes,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Terminology {
    pub pull_requests: String,
    pub work_items: String,
    pub pipelines: String,
}

impl Default for Terminology {
    fn default() -> Self {
        Self {
            pull_requests: "Pull Requests".into(),
            work_items: "Work Items".into(),
            pipelines: "Pipelines".into(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Capabilities {
    pub supports_pull_requests: bool,
    pub supports_work_items: bool,
    pub supports_pipelines: bool,
    pub vote_style: VoteStyle,
    pub supports_merge: bool,
    pub supports_inline_comments: bool,
    pub supports_pipeline_trigger: bool,
    pub supports_pipeline_discovery: bool,
    /// Whether this provider exposes a personal notification feed (GitHub / GitLab / Linear).
    pub supports_notifications: bool,
    pub terminology: Terminology,
}

impl Default for Capabilities {
    fn default() -> Self {
        Self {
            supports_pull_requests: false,
            supports_work_items: false,
            supports_pipelines: false,
            vote_style: VoteStyle::BinaryApprove,
            supports_merge: false,
            supports_inline_comments: false,
            supports_pipeline_trigger: false,
            supports_pipeline_discovery: false,
            supports_notifications: false,
            terminology: Terminology::default(),
        }
    }
}

impl Capabilities {
    pub fn supports(&self, section: Section) -> bool {
        match section {
            Section::PullRequests => self.supports_pull_requests,
            Section::WorkItems => self.supports_work_items,
            Section::Pipelines => self.supports_pipelines,
        }
    }
}

// ---- queries ----

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PullRequestFilter {
    #[default]
    All,
    Mine,
    ReviewRequested,
}

#[derive(Debug, Clone)]
pub struct PullRequestQuery {
    pub filter: PullRequestFilter,
    pub include_completed: bool,
    pub limit: Option<u32>,
    /// Whether to spend extra per-PR calls filling the fields a provider's *list* endpoint omits
    /// (GitHub leaves out `mergeable`, `changed_files`, `additions`, `deletions` entirely).
    ///
    /// Defaults to **true**, so no caller loses decoration by accident. Callers that render
    /// decorated fields lazily — the dashboard fetches them per visible row — turn it off, and
    /// providers that honour it must still bound the work to the rows they finally return, so
    /// the cost does not multiply with the size of the repository scope.
    pub decorate: bool,
}

impl Default for PullRequestQuery {
    fn default() -> Self {
        Self { filter: PullRequestFilter::default(), include_completed: false, limit: None, decorate: true }
    }
}

/// The fields a provider's list endpoint omits, fetched on demand for one pull request.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PrDecoration {
    pub mergeable: MergeableState,
    pub changed_files: i64,
    pub additions: i64,
    pub deletions: i64,
    pub checks: CheckStatus,
    pub check_summary: Option<CheckSummary>,
}

impl PrDecoration {
    /// Projects the decorated fields out of a fully-fetched pull request.
    pub fn from_pull_request(pr: &PullRequest) -> Self {
        Self {
            mergeable: pr.mergeable,
            changed_files: pr.changed_files,
            additions: pr.additions,
            deletions: pr.deletions,
            checks: pr.checks,
            check_summary: pr.check_summary.clone(),
        }
    }

    /// Writes these fields back onto a pull request whose list payload omitted them — the
    /// inverse of [`from_pull_request`](Self::from_pull_request), for a caller that listed
    /// without decoration and fetched it per row afterwards.
    pub fn apply_to(&self, pr: &mut PullRequest) {
        pr.mergeable = self.mergeable;
        pr.changed_files = self.changed_files;
        pr.additions = self.additions;
        pr.deletions = self.deletions;
        pr.checks = self.checks;
        pr.check_summary = self.check_summary.clone();
    }
}

#[derive(Debug, Clone, Default)]
pub struct WorkItemQuery {
    pub mine_only: bool,
    pub include_completed: bool,
    pub limit: Option<u32>,
}

#[derive(Debug, Clone, Default)]
pub struct PipelineRunQuery {
    pub definition_id: Option<String>,
    /// Restricts the query to one **connection-relative** repository. `None` fans out over the
    /// connection's whole repository scope.
    pub repository: Option<String>,
    pub branch: Option<String>,
    pub limit: Option<u32>,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum MergeStrategy {
    #[default]
    Merge,
    Squash,
    Rebase,
}

#[derive(Debug, Clone, Default)]
pub struct MergeOptions {
    pub strategy: MergeStrategy,
    pub delete_source_ref: bool,
}

/// Which of the optional pull-request writes a connection can perform. All `false` by default:
/// a frontend shows the key for each only where the provider says it works, so a forge without
/// an API for one (Bitbucket cannot reopen a declined pull request; GitLab has no draft flag
/// beyond the title prefix) never offers a key that would only fail.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrWriteSupport {
    /// [`PullRequestSource::resolve_thread`] works.
    pub resolve_threads: bool,
    /// [`PullRequestSource::set_draft`] works in both directions.
    pub draft: bool,
    /// [`PullRequestSource::set_closed`] can close an open pull request.
    pub close: bool,
    /// [`PullRequestSource::set_closed`] can reopen a closed one.
    pub reopen: bool,
    /// [`PullRequestSource::reviewable_users`] and [`PullRequestSource::request_reviewer`] work.
    pub request_reviewer: bool,
}

impl PrWriteSupport {
    /// Every write supported — what a full-featured forge (and the demo) reports.
    pub const ALL: PrWriteSupport = PrWriteSupport { resolve_threads: true, draft: true, close: true, reopen: true, request_reviewer: true };
}

// ---- capability-scoped sources ----

/// Pull requests from one connection. Every method that names a single pull request takes an
/// [`ItemRef`], not a bare id: on a connection spanning several repositories the id alone does
/// not say *which* `#7` is meant, and guessing silently opens, merges or comments on the wrong
/// one. The `repo` on the ref is connection-relative.
#[async_trait]
pub trait PullRequestSource: Send + Sync {
    async fn list(&self, query: &PullRequestQuery) -> Result<Vec<PullRequest>>;
    /// The handle identifying the signed-in user on this connection — the same identity
    /// [`list`](Self::list) resolves internally when it is asked for `Mine` or `ReviewRequested`.
    ///
    /// Exposed so a caller can fetch one *unfiltered* pool and derive those views itself, rather
    /// than issuing a `list` per filter that re-fetches identical rows. It is matched against a
    /// user's handle, display name or id by
    /// [`apply_pull_request_filter`](crate::filter::apply_pull_request_filter).
    ///
    /// `None` — the default, and what a provider returns when it cannot establish an identity —
    /// makes that filter pass everything through rather than silently emptying the list. A caller
    /// deriving views locally must treat it as "cannot filter", not as "nothing matches": showing
    /// an unfiltered list under a "Mine" heading is wrong, but quietly showing none of your pull
    /// requests is worse, and matches what `list` already does in the same situation.
    async fn current_user(&self) -> Result<Option<String>> {
        Ok(None)
    }
    async fn get(&self, item: &ItemRef) -> Result<PullRequest>;
    async fn threads(&self, item: &ItemRef) -> Result<Vec<CommentThread>>;
    /// The event timeline (reviews/approvals, merges, state changes, …), oldest → newest.
    /// Defaults to empty for providers without a timeline/activity API.
    async fn timeline(&self, _item: &ItemRef) -> Result<Vec<TimelineEvent>> {
        Ok(Vec::new())
    }
    async fn changes(&self, item: &ItemRef) -> Result<Vec<FileChange>>;
    /// Individual named CI checks. Defaults to empty for providers that don't expose them.
    async fn checks(&self, _item: &ItemRef) -> Result<Vec<CheckRun>> {
        Ok(Vec::new())
    }
    /// Commits on the pull request. Defaults to empty.
    async fn commits(&self, _item: &ItemRef) -> Result<Vec<Commit>> {
        Ok(Vec::new())
    }
    /// Whether this provider's *list* endpoint leaves the decorated fields out, so a caller that
    /// skipped decoration has to fetch them per row to show them.
    ///
    /// `false` by default, and that is the right answer for most providers: GitLab, Azure DevOps
    /// and Bitbucket fill everything they have straight from the list payload, so asking them for
    /// decoration is a call per row that returns what the caller already had. GitHub is the
    /// exception — `/pulls` omits `mergeable_state`, `changed_files`, `additions` and `deletions`
    /// entirely — and overrides this.
    fn list_omits_decoration(&self) -> bool {
        false
    }
    /// Whether [`list`](Self::list) asks the provider for the rows of a `Mine` or
    /// `ReviewRequested` filter, rather than fetching a page per repository and filtering it.
    ///
    /// `false` by default, for a provider that fetches the newest page of each repository in
    /// scope and keeps the rows that match: its filtered `list` returns a subset of what an
    /// unfiltered one would have, and a caller holding the unfiltered rows gains nothing by
    /// asking again. On a busy repository that page is a window — the newest 50 pull requests —
    /// and your own fall out of it as soon as 50 newer ones exist. A provider that addresses the
    /// filter itself overrides this (GitHub searches `author:@me` across the scope; GitLab, Azure
    /// DevOps and Bitbucket pass the author or reviewer to each repository's list), so a caller
    /// that pools the unfiltered rows knows those views are worth fetching on their own.
    fn list_targets_filter(&self) -> bool {
        false
    }
    /// Whether giving your review takes a pull request out of the `ReviewRequested` view.
    ///
    /// `false` by default: on GitLab, Azure DevOps and Bitbucket you stay a reviewer after you
    /// have voted, so the merge request keeps naming you and keeps its place in the view. GitHub
    /// is the exception — `requested_reviewers` only names those who have *not* reviewed yet, and
    /// the view is built on it — so a pull request leaves the view the moment you review it. A
    /// caller that knows this can take the row off its list as soon as the review is sent,
    /// rather than on the refetch that would.
    fn review_clears_request(&self) -> bool {
        false
    }
    /// The fields the list endpoint omits, for one pull request. Defaults to projecting them out
    /// of a full [`get`](Self::get) — providers with a cheaper route override it.
    async fn decorate(&self, item: &ItemRef) -> Result<PrDecoration> {
        Ok(PrDecoration::from_pull_request(&self.get(item).await?))
    }
    /// Files changed by a single commit on the pull request (with inline patches
    /// where the provider supplies them). Defaults to unsupported (empty).
    async fn commit_changes(&self, _item: &ItemRef, _sha: &str) -> Result<Vec<FileChange>> {
        Ok(Vec::new())
    }
    /// The full text of `path` as of commit `sha` — the pull request's head when `sha` is
    /// `None` — so a diff can show the unchanged lines between its hunks. Fetched on demand,
    /// never on a reload. `None` when the forge can't say: a binary or oversized file, a path
    /// missing at that commit, or (the default) a provider without a content API.
    async fn file_text(&self, _item: &ItemRef, _path: &str, _sha: Option<&str>) -> Result<Option<String>> {
        Ok(None)
    }
    async fn add_comment(&self, item: &ItemRef, body: &str) -> Result<()>;
    /// Replies to an existing comment thread (returned by [`threads`](Self::threads)), so you can
    /// answer someone else's comment in-thread rather than starting a new top-level one. Defaults
    /// to posting a plain top-level comment — providers whose threads are really flat (e.g. a
    /// GitHub PR *conversation*, which has no reply API) keep that behaviour; providers with real
    /// threads (Azure, GitLab discussions, Bitbucket, GitHub *review* threads) override it.
    async fn reply_to_thread(&self, item: &ItemRef, _thread_id: &str, body: &str) -> Result<()> {
        self.add_comment(item, body).await
    }
    async fn vote(&self, item: &ItemRef, vote: ReviewVote) -> Result<()>;
    async fn merge(&self, item: &ItemRef, options: &MergeOptions) -> Result<()>;
    /// Reverts a merged pull request (undoes its merge commit on the target branch). Defaults to
    /// unsupported — only providers with a revert API (GitLab, Azure DevOps) override this.
    async fn revert(&self, _item: &ItemRef) -> Result<()> {
        Err(Error::Provider("this provider has no revert API — revert it from the provider's web UI".into()))
    }
    /// Submits a review with inline line comments. `event` maps to approve /
    /// request-changes / plain comment. Defaults to unsupported.
    async fn submit_review(&self, _item: &ItemRef, _event: ReviewVote, _comments: &[LineComment]) -> Result<()> {
        Err(Error::Provider("this provider doesn't support line-comment reviews".into()))
    }
    /// Which of the writes below this connection can perform. Nothing by default; a provider
    /// reports exactly the ones it implements so a frontend offers only those.
    fn pr_writes(&self) -> PrWriteSupport {
        PrWriteSupport::default()
    }
    /// Marks a comment thread (by the id [`threads`](Self::threads) gave it) resolved or, with
    /// `resolved = false`, open again. Defaults to unsupported.
    async fn resolve_thread(&self, _item: &ItemRef, _thread_id: &str, _resolved: bool) -> Result<()> {
        Err(Error::Provider("this provider doesn't support resolving comment threads".into()))
    }
    /// Converts an open pull request to a draft (`draft = true`) or marks a draft ready for
    /// review (`draft = false`). Defaults to unsupported.
    async fn set_draft(&self, _item: &ItemRef, _draft: bool) -> Result<()> {
        Err(Error::Provider("this provider doesn't support changing a pull request's draft state".into()))
    }
    /// Closes an open pull request without merging (`closed = true`) or reopens a closed one
    /// (`closed = false`). Defaults to unsupported.
    async fn set_closed(&self, _item: &ItemRef, _closed: bool) -> Result<()> {
        Err(Error::Provider("this provider doesn't support closing or reopening pull requests".into()))
    }
    /// The people who can be asked to review this pull request — the repository's collaborators,
    /// members or team, depending on the forge. Fetched once per repository and kept for the
    /// run by a caller; a provider should likewise cache it on its client. Defaults to none.
    async fn reviewable_users(&self, _item: &ItemRef) -> Result<Vec<User>> {
        Ok(Vec::new())
    }
    /// Asks `user_id` (an id from [`reviewable_users`](Self::reviewable_users)) to review the
    /// pull request, keeping any reviewers already on it. Defaults to unsupported.
    async fn request_reviewer(&self, _item: &ItemRef, _user_id: &str) -> Result<()> {
        Err(Error::Provider("this provider doesn't support requesting reviewers".into()))
    }
}

#[async_trait]
pub trait WorkItemSource: Send + Sync {
    async fn list(&self, query: &WorkItemQuery) -> Result<Vec<WorkItem>>;
    async fn get(&self, item: &ItemRef) -> Result<WorkItem>;
    async fn threads(&self, item: &ItemRef) -> Result<Vec<CommentThread>>;
    /// The event history (status changes, assignments, …), oldest → newest. Defaults to empty.
    async fn timeline(&self, _item: &ItemRef) -> Result<Vec<TimelineEvent>> {
        Ok(Vec::new())
    }
    async fn set_state(&self, item: &ItemRef, state: &str) -> Result<()>;
    async fn add_comment(&self, item: &ItemRef, body: &str) -> Result<()>;
    /// The states this item can move to (provider-accurate). Defaults to empty,
    /// letting the caller fall back to the states seen across the loaded items.
    async fn available_states(&self, _item: &ItemRef) -> Result<Vec<String>> {
        Ok(Vec::new())
    }
    /// Users this item can be assigned to (candidates for the assignee picker). The `id` on each
    /// returned `User` MUST be the exact token this provider's `set_assignee` accepts. Empty by
    /// default (no picker shown).
    async fn assignable_users(&self, _item: &ItemRef) -> Result<Vec<User>> {
        Ok(Vec::new())
    }
    /// Assign the item to a user (by an id from `assignable_users`), or `None` to unassign.
    /// Unsupported by default.
    async fn set_assignee(&self, _item: &ItemRef, _assignee_id: Option<&str>) -> Result<()> {
        Err(Error::Provider("set_assignee not supported by this provider".into()))
    }
    /// Edit the item's title and/or description (`None` leaves that field unchanged).
    /// Unsupported by default.
    async fn update_fields(&self, _item: &ItemRef, _title: Option<&str>, _description: Option<&str>) -> Result<()> {
        Err(Error::Provider("update_fields not supported by this provider".into()))
    }
}

#[async_trait]
pub trait PipelineSource: Send + Sync {
    async fn discover(&self) -> Result<Vec<PipelineDefinition>>;
    async fn list_runs(&self, query: &PipelineRunQuery) -> Result<Vec<PipelineRun>>;
    async fn get_run(&self, run: &ItemRef) -> Result<PipelineRun>;
    /// The signed-in user's identity, as [`PullRequestSource::current_user`] gives it — matched
    /// against a run's `triggered_by` by [`run_triggered_by`](crate::filter::run_triggered_by) so
    /// the Command Center shows only the runs you started.
    ///
    /// `None` — the default — means the identity cannot be established. Unlike the pull-request
    /// filter, that shows *none* of the connection's runs there: a "your work" list full of other
    /// people's builds is worse than an empty one, and the Pipelines page still lists them all.
    async fn current_user(&self) -> Result<Option<String>> {
        Ok(None)
    }
    async fn logs(&self, run: &ItemRef, job_id: Option<&str>) -> Result<String>;
    /// Triggers a pipeline. `definition` addresses the definition — its `repo` says which
    /// repository's pipeline to start, which a connection spanning several cannot infer.
    async fn trigger(&self, definition: &ItemRef, branch: Option<&str>) -> Result<()>;
    /// Cancel a running or queued run. Unsupported by default.
    async fn cancel_run(&self, _run: &ItemRef) -> Result<()> {
        Err(Error::Provider("cancel_run not supported by this provider".into()))
    }
    /// Whether [`rerun_run`](Self::rerun_run) can re-run a whole run. `false` by default.
    fn supports_rerun(&self) -> bool {
        false
    }
    /// Whether [`rerun_run`](Self::rerun_run) can re-run only a run's failed jobs. `false` by
    /// default; a provider may re-run whole runs but not failed jobs alone.
    fn supports_rerun_failed(&self) -> bool {
        false
    }
    /// Whether [`rerun_run`](Self::rerun_run) starts a *new* run rather than a new attempt of
    /// the same one (GitLab/Azure/Bitbucket re-run a whole run by starting a fresh one). A UI
    /// can only mark the open run as re-queued when this is `false`. `false` by default.
    fn rerun_starts_new_run(&self, _failed_only: bool) -> bool {
        false
    }
    /// Re-run a finished run — every job, or with `failed_only` just the ones that failed.
    /// Returns the new run's id when the provider started a separate run
    /// (see [`rerun_starts_new_run`](Self::rerun_starts_new_run)), `None` when the same run
    /// was re-queued. Unsupported by default.
    async fn rerun_run(&self, _run: &ItemRef, _failed_only: bool) -> Result<Option<String>> {
        Err(Error::Provider("rerun not supported by this provider".into()))
    }
    /// Whether [`artifacts`](Self::artifacts) is implemented. `false` by default — the UI says
    /// so rather than showing an empty list as if the run published nothing.
    fn supports_artifacts(&self) -> bool {
        false
    }
    /// The files a run published. Empty by default.
    async fn artifacts(&self, _run: &ItemRef) -> Result<Vec<PipelineArtifact>> {
        Ok(Vec::new())
    }
    /// Problems the run reported (annotations, failed test cases, timeline issues), most severe
    /// first. Empty by default — callers fall back to scanning the log.
    async fn annotations(&self, _run: &ItemRef) -> Result<Vec<PipelineAnnotation>> {
        Ok(Vec::new())
    }
    /// Whether this provider can surface and act on pending run approvals/gates.
    /// `false` by default — the UI shows the section as unsupported.
    fn supports_approvals(&self) -> bool {
        false
    }
    /// Whether the app can actually submit an approve/reject decision. Providers may
    /// be able to *surface* a pending gate (`supports_approvals`) without being able
    /// to act on it — e.g. Azure DevOps, where the pending environment check isn't
    /// exposed as an actionable approval resource. Defaults to `supports_approvals()`.
    fn can_respond_to_approvals(&self) -> bool {
        self.supports_approvals()
    }
    /// Gates on a run that are waiting for a decision. Empty by default (unsupported).
    async fn pending_approvals(&self, _run: &ItemRef) -> Result<Vec<PipelineApproval>> {
        Ok(Vec::new())
    }
    /// Approve or reject a waiting gate on a run. Unsupported by default.
    async fn respond_approval(
        &self,
        _run: &ItemRef,
        _approval_id: &str,
        _decision: ApprovalDecision,
        _comment: Option<&str>,
    ) -> Result<()> {
        Err(Error::Provider("this provider doesn't support pipeline approvals".into()))
    }
}

/// The provider's personal notification feed (GitHub notifications, GitLab todos, Linear
/// notifications). Only some providers have one — see [`Capabilities::supports_notifications`].
#[async_trait]
pub trait NotificationSource: Send + Sync {
    /// The current user's notifications, newest first.
    async fn list(&self) -> Result<Vec<Notification>>;
    /// Mark a single notification read by its id.
    async fn mark_read(&self, id: &str) -> Result<()>;
    /// Mark every notification read. Default: mark each one; providers with a bulk endpoint
    /// (GitHub `PUT /notifications`, GitLab `/todos/mark_as_done`) should override.
    async fn mark_all_read(&self) -> Result<()> {
        for n in self.list().await? {
            let _ = self.mark_read(&n.id).await;
        }
        Ok(())
    }
}

/// A live, authenticated connection exposing only the sources it supports.
#[async_trait]
pub trait ProviderConnection: Send + Sync {
    fn connection_id(&self) -> &str;
    fn provider_type(&self) -> ProviderType;
    fn display_name(&self) -> &str;
    fn capabilities(&self) -> &Capabilities;
    fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>>;
    fn work_items(&self) -> Option<Arc<dyn WorkItemSource>>;
    fn pipelines(&self) -> Option<Arc<dyn PipelineSource>>;
    /// The notification feed, when the provider has one. Defaults to `None` (unsupported).
    fn notifications(&self) -> Option<Arc<dyn NotificationSource>> {
        None
    }
    /// The repositories these credentials can reach, most-recently-active first — the candidate
    /// list for the connection's repository scope picker.
    ///
    /// Defaults to empty, which is the right answer for providers that aren't repo-addressed
    /// (Jira is project-addressed, Linear team-addressed). Only the scope picker calls this, so
    /// a provider whose discovery is wrong shows an empty picker; it cannot break fetching for a
    /// connection that already has a scope.
    async fn discover_repositories(&self) -> Result<RepositoryPage> {
        Ok(RepositoryPage::default())
    }
    /// Cheap reachability/auth check for the connections health bar.
    async fn check(&self) -> bool;
}

/// Builds a live [`ProviderConnection`] for one provider type.
pub trait ProviderFactory: Send + Sync {
    fn provider_type(&self) -> ProviderType;
    fn describe_capabilities(&self) -> Capabilities;
    fn create(&self, connection: &Connection, secret: Option<String>) -> Result<Arc<dyn ProviderConnection>>;
}

/// Resolves provider factories and creates connections by provider type.
pub struct ProviderRegistry {
    factories: HashMap<ProviderType, Arc<dyn ProviderFactory>>,
}

impl ProviderRegistry {
    pub fn new(factories: Vec<Arc<dyn ProviderFactory>>) -> Self {
        let factories = factories.into_iter().map(|f| (f.provider_type(), f)).collect();
        Self { factories }
    }

    pub fn available(&self) -> Vec<ProviderType> {
        self.factories.keys().copied().collect()
    }

    pub fn supports(&self, provider: ProviderType) -> bool {
        self.factories.contains_key(&provider)
    }

    pub fn describe(&self, provider: ProviderType) -> Option<Capabilities> {
        self.factories.get(&provider).map(|f| f.describe_capabilities())
    }

    pub fn create(&self, connection: &Connection, secret: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
        let factory = self
            .factories
            .get(&connection.provider_type)
            .ok_or_else(|| Error::Config(format!("no provider registered for '{}'", connection.provider_type.as_str())))?;
        factory.create(connection, secret)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use async_trait::async_trait;

    struct FakeConn {
        caps: Capabilities,
    }

    #[async_trait]
    impl ProviderConnection for FakeConn {
        fn connection_id(&self) -> &str {
            "fake"
        }
        fn provider_type(&self) -> ProviderType {
            ProviderType::GitHub
        }
        fn display_name(&self) -> &str {
            "Fake"
        }
        fn capabilities(&self) -> &Capabilities {
            &self.caps
        }
        fn pull_requests(&self) -> Option<Arc<dyn PullRequestSource>> {
            None
        }
        fn work_items(&self) -> Option<Arc<dyn WorkItemSource>> {
            None
        }
        fn pipelines(&self) -> Option<Arc<dyn PipelineSource>> {
            None
        }
        async fn check(&self) -> bool {
            true
        }
    }

    struct FakeFactory {
        provider: ProviderType,
        caps: Capabilities,
    }

    impl ProviderFactory for FakeFactory {
        fn provider_type(&self) -> ProviderType {
            self.provider
        }
        fn describe_capabilities(&self) -> Capabilities {
            self.caps.clone()
        }
        fn create(&self, _connection: &Connection, _secret: Option<String>) -> Result<Arc<dyn ProviderConnection>> {
            Ok(Arc::new(FakeConn { caps: self.caps.clone() }))
        }
    }

    fn conn(provider: ProviderType) -> Connection {
        Connection {
            id: "c".into(),
            provider_type: provider,
            display_name: "c".into(),
            base_url: None,
            organization: None,
            project: None,
            repository: None,
            username: None,
            credential_ref: None,
            repo_scope: None,
        }
    }

    #[test]
    fn capabilities_supports_reflects_flags() {
        let caps = Capabilities { supports_pull_requests: true, ..Default::default() };
        assert!(caps.supports(Section::PullRequests));
        assert!(!caps.supports(Section::WorkItems));
    }

    #[test]
    fn registry_dispatches_and_rejects_unknown() {
        let caps = Capabilities { supports_pull_requests: true, ..Default::default() };
        let registry = ProviderRegistry::new(vec![Arc::new(FakeFactory { provider: ProviderType::GitHub, caps })]);

        assert!(registry.supports(ProviderType::GitHub));
        assert!(!registry.supports(ProviderType::Linear));

        let live = registry.create(&conn(ProviderType::GitHub), Some("pat".into())).unwrap();
        assert_eq!(live.provider_type(), ProviderType::GitHub);
        assert!(registry.create(&conn(ProviderType::Linear), None).is_err());
    }

    #[test]
    fn scope_falls_back_only_when_absent_never_when_empty() {
        let mut c = conn(ProviderType::GitHub);

        // Never established → the legacy single repository still applies, so an existing
        // connection keeps behaving exactly as it did.
        c.repository = Some("acme/pay".into());
        assert_eq!(c.resolve_repo_scope(|| c.repository.clone()), vec!["acme/pay".to_string()]);

        // Chosen, and empty. This is the state a bare Vec cannot express: fetch nothing. If the
        // fallback keyed on emptiness instead of absence, the legacy repository would silently
        // refill it on the next load.
        c.repo_scope = Some(vec![]);
        assert!(c.resolve_repo_scope(|| c.repository.clone()).is_empty());

        // Chosen explicitly → exactly those, legacy ignored.
        c.repo_scope = Some(vec!["acme/pay".into(), "acme/ledger".into()]);
        assert_eq!(c.resolve_repo_scope(|| c.repository.clone()).len(), 2);
    }

    #[test]
    fn item_ref_carries_the_connection_relative_repo() {
        let r = ItemRef::in_repo("acme/pay", "7");
        assert_eq!(r.repo.as_deref(), Some("acme/pay"));
        assert_eq!(ItemRef::new("7").repo, None);
        assert_eq!(ItemRef::maybe(None, "7"), ItemRef::new("7"));
    }

    #[test]
    fn pull_request_query_decorates_by_default() {
        // Nothing loses decoration by accident — a caller must opt out on purpose.
        assert!(PullRequestQuery::default().decorate);
    }

    #[test]
    fn new_id_is_prefixed() {
        assert!(Connection::new_id(ProviderType::GitHub).starts_with("github-"));
    }

    /// A source that implements only the required methods: the optional writes default to
    /// "unsupported", and `pr_writes` says so, so a frontend offers none of their keys.
    struct BarePrs;

    #[async_trait]
    impl PullRequestSource for BarePrs {
        async fn list(&self, _q: &PullRequestQuery) -> Result<Vec<PullRequest>> {
            Ok(Vec::new())
        }
        async fn get(&self, item: &ItemRef) -> Result<PullRequest> {
            Err(Error::NotFound(item.id.clone()))
        }
        async fn threads(&self, _item: &ItemRef) -> Result<Vec<CommentThread>> {
            Ok(Vec::new())
        }
        async fn changes(&self, _item: &ItemRef) -> Result<Vec<FileChange>> {
            Ok(Vec::new())
        }
        async fn add_comment(&self, _item: &ItemRef, _body: &str) -> Result<()> {
            Ok(())
        }
        async fn vote(&self, _item: &ItemRef, _vote: ReviewVote) -> Result<()> {
            Ok(())
        }
        async fn merge(&self, _item: &ItemRef, _options: &MergeOptions) -> Result<()> {
            Ok(())
        }
    }

    #[tokio::test]
    async fn optional_pull_request_writes_default_to_unsupported() {
        let src = BarePrs;
        let item = ItemRef::new("1");
        assert_eq!(src.pr_writes(), PrWriteSupport::default(), "nothing advertised");
        assert!(!PrWriteSupport::default().resolve_threads && !PrWriteSupport::default().request_reviewer);
        assert!(src.resolve_thread(&item, "t1", true).await.is_err());
        assert!(src.set_draft(&item, false).await.is_err());
        assert!(src.set_closed(&item, true).await.is_err());
        assert!(src.request_reviewer(&item, "u1").await.is_err());
        assert!(src.reviewable_users(&item).await.unwrap().is_empty(), "no people rather than an error, so a picker opens empty");
        let all = PrWriteSupport::ALL;
        assert!(all.resolve_threads && all.draft && all.close && all.reopen && all.request_reviewer);
    }
}