Skip to main content

forgetop_core/
domain.rs

1//! Provider-neutral domain model (mirrors the .NET `Forgetop.Core.Domain`).
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6/// The three top-level sections, each independently bound to a provider.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
8pub enum Section {
9    #[default]
10    PullRequests,
11    WorkItems,
12    Pipelines,
13}
14
15/// The platforms forgetop can talk to.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub enum ProviderType {
18    Demo,
19    GitHub,
20    AzureDevOps,
21    Linear,
22    GitLab,
23    Bitbucket,
24    Jira,
25}
26
27impl ProviderType {
28    pub fn as_str(&self) -> &'static str {
29        match self {
30            ProviderType::Demo => "Demo",
31            ProviderType::GitHub => "GitHub",
32            ProviderType::AzureDevOps => "AzureDevOps",
33            ProviderType::Linear => "Linear",
34            ProviderType::GitLab => "GitLab",
35            ProviderType::Bitbucket => "Bitbucket",
36            ProviderType::Jira => "Jira",
37        }
38    }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42pub enum PullRequestStatus {
43    Open,
44    Draft,
45    Merged,
46    Closed,
47}
48
49/// Provider-neutral reviewer vote (ADO numeric votes / GitHub review states map here).
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51pub enum ReviewVote {
52    Rejected,
53    WaitingForAuthor,
54    NoVote,
55    ApprovedWithSuggestions,
56    Approved,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60pub enum WorkItemStateCategory {
61    Triage,
62    Backlog,
63    Unstarted,
64    Started,
65    Completed,
66    Canceled,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70pub enum PipelineRunStatus {
71    Queued,
72    Running,
73    /// In flight but parked: held on an approval gate, so nothing is running and nothing will
74    /// until someone acts. A stage behind the gate reads this too.
75    Waiting,
76    Succeeded,
77    PartiallySucceeded,
78    Failed,
79    Canceled,
80    /// Never ran — skipped by its condition, or because something before it failed. Not a
81    /// failure in its own right.
82    Skipped,
83}
84
85impl PipelineRunStatus {
86    /// Still in flight: queued, running, or waiting on a gate. Only these can be cancelled or
87    /// held by an approval.
88    pub fn is_active(self) -> bool {
89        matches!(self, Self::Queued | Self::Running | Self::Waiting)
90    }
91
92    /// An active status, held: [`Waiting`](Self::Waiting). Callers with a whole run should use
93    /// [`PipelineRun::shown_status`], which also checks nothing is still executing.
94    pub fn with_pending_approval(self, pending: bool) -> Self {
95        if pending && self.is_active() {
96            Self::Waiting
97        } else {
98            self
99        }
100    }
101}
102
103/// Roll-up CI/check state for a pull request.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
105pub enum CheckStatus {
106    #[default]
107    None,
108    Pending,
109    Passed,
110    Failed,
111}
112
113/// Whether a pull request can be merged.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
115pub enum MergeableState {
116    #[default]
117    Unknown,
118    Mergeable,
119    Blocked,
120    Conflicting,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124pub enum FileChangeKind {
125    Added,
126    Modified,
127    Deleted,
128    Renamed,
129}
130
131// ---- entities ----
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct User {
135    pub id: String,
136    pub display_name: String,
137    pub handle: Option<String>,
138    pub avatar_url: Option<String>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct Repository {
143    pub id: String,
144    pub name: String,
145    pub full_name: Option<String>,
146    pub default_branch: Option<String>,
147    pub url: Option<String>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct Comment {
152    pub id: String,
153    pub author: User,
154    pub body: String,
155    pub created_at: Option<DateTime<Utc>>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct CommentThread {
160    pub id: String,
161    pub comments: Vec<Comment>,
162    pub file_path: Option<String>,
163    pub line: Option<i64>,
164    pub is_resolved: bool,
165}
166
167/// What happened in a timeline event — drives its icon/colour in the UI.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169pub enum TimelineEventKind {
170    Approved,
171    ChangesRequested,
172    Reviewed,
173    Commented,
174    Merged,
175    Closed,
176    Reopened,
177    StateChanged,
178    Assigned,
179    Labeled,
180    Committed,
181    Other,
182}
183
184/// A single event on a pull request or work item (a review, a merge, a state change, an
185/// assignment, …), assembled from whatever the provider's timeline / activity / history API
186/// exposes. Ordered oldest → newest.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct TimelineEvent {
189    pub actor: Option<User>,
190    pub kind: TimelineEventKind,
191    /// Human-readable one-liner, e.g. "approved", "changed status to In Progress", "merged".
192    pub summary: String,
193    pub at: Option<DateTime<Utc>>,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct Reviewer {
198    pub user: User,
199    pub vote: ReviewVote,
200    pub is_required: bool,
201}
202
203#[derive(Debug, Clone, Default, Serialize, Deserialize)]
204pub struct CheckSummary {
205    pub successful: u32,
206    pub in_progress: u32,
207    pub failed: u32,
208    pub neutral: u32,
209}
210
211impl CheckSummary {
212    pub fn total(&self) -> u32 {
213        self.successful + self.in_progress + self.failed + self.neutral
214    }
215}
216
217/// A single named CI check / status on a pull request.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct CheckRun {
220    pub name: String,
221    pub status: CheckStatus,
222    pub url: Option<String>,
223}
224
225/// A commit on a pull request.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct Commit {
228    pub sha: String,
229    pub message: String,
230    pub author: String,
231    pub date: Option<DateTime<Utc>>,
232    pub url: Option<String>,
233}
234
235/// Which side of a diff a line belongs to: the old (removed) or new (added) file.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
237pub enum DiffSide {
238    Old,
239    New,
240}
241
242/// A pending review comment targeting a specific line of a file in a pull request.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct LineComment {
245    pub path: String,
246    pub line: i64,
247    pub side: DiffSide,
248    pub body: String,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct FileChange {
253    pub path: String,
254    pub kind: FileChangeKind,
255    pub additions: i64,
256    pub deletions: i64,
257    /// Unified-diff patch text when the provider supplies it.
258    pub patch: Option<String>,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct PullRequest {
263    pub id: String,
264    /// The repository this pull request lives in, **connection-relative** (`acme/pay`) — see
265    /// [`crate::repo`]. `None` for providers that aren't repo-addressed (Jira, Linear).
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub repository: Option<String>,
268    pub number: Option<i64>,
269    pub title: String,
270    pub description: Option<String>,
271    pub author: User,
272    pub status: PullRequestStatus,
273    pub is_draft: bool,
274    pub source_ref: Option<String>,
275    pub target_ref: Option<String>,
276    pub reviewers: Vec<Reviewer>,
277    pub labels: Vec<String>,
278    pub checks: CheckStatus,
279    pub check_summary: Option<CheckSummary>,
280    pub mergeable: MergeableState,
281    pub changed_files: i64,
282    pub additions: i64,
283    pub deletions: i64,
284    pub created_at: Option<DateTime<Utc>>,
285    pub updated_at: Option<DateTime<Utc>>,
286    pub url: Option<String>,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct WorkItem {
291    pub id: String,
292    /// Where this item lives, **connection-relative** — a repository (`acme/pay`) for the
293    /// repo-addressed forges, or a bare project name for Azure DevOps, whose work items are
294    /// project-addressed rather than repo-addressed. `None` for Jira and Linear.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub repository: Option<String>,
297    pub identifier: Option<String>,
298    pub title: String,
299    pub description: Option<String>,
300    pub state: String,
301    pub state_category: WorkItemStateCategory,
302    pub work_item_type: Option<String>,
303    pub assignee: Option<User>,
304    pub created_at: Option<DateTime<Utc>>,
305    pub updated_at: Option<DateTime<Utc>>,
306    pub url: Option<String>,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct PipelineDefinition {
311    pub id: String,
312    /// The repository this definition belongs to, **connection-relative**. `None` for providers
313    /// that aren't repo-addressed.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub repository: Option<String>,
316    pub name: String,
317    pub path: Option<String>,
318    pub url: Option<String>,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct PipelineStep {
323    pub name: String,
324    pub status: PipelineRunStatus,
325    pub started_at: Option<DateTime<Utc>>,
326    pub finished_at: Option<DateTime<Utc>>,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct PipelineJob {
331    pub id: String,
332    pub name: String,
333    pub status: PipelineRunStatus,
334    pub started_at: Option<DateTime<Utc>>,
335    pub finished_at: Option<DateTime<Utc>>,
336    pub steps: Vec<PipelineStep>,
337    /// Deep link to this job in the provider's web UI.
338    pub url: Option<String>,
339    /// A short problem summary for failed jobs (provider-specific: GitLab's
340    /// failure reason, Azure's error/warning counts, etc.).
341    pub problem: Option<String>,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct PipelineStage {
346    pub name: String,
347    pub status: PipelineRunStatus,
348    pub jobs: Vec<PipelineJob>,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct PipelineRun {
353    pub id: String,
354    /// The repository this run belongs to, **connection-relative**. `None` for providers that
355    /// aren't repo-addressed.
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub repository: Option<String>,
358    pub definition_id: String,
359    pub number: Option<i64>,
360    pub name: Option<String>,
361    /// A human-readable title for the run — the triggering PR title or commit-message subject
362    /// where the provider exposes it (GitHub `display_title`). `None` when unavailable.
363    pub title: Option<String>,
364    pub status: PipelineRunStatus,
365    pub triggered_by: Option<User>,
366    pub branch: Option<String>,
367    pub commit_sha: Option<String>,
368    pub started_at: Option<DateTime<Utc>>,
369    pub finished_at: Option<DateTime<Utc>>,
370    pub url: Option<String>,
371    pub stages: Vec<PipelineStage>,
372    /// What started the run, in the provider's own words (`push`, `pull_request`,
373    /// `schedule`, Azure `individualCI`, GitLab `merge_request_event`, …). `None` when unknown.
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub event: Option<String>,
376    /// Which attempt of this run this is (GitHub `run_attempt`); `None` where the provider
377    /// doesn't number re-runs.
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub attempt: Option<u32>,
380    /// The pull/merge request this run was built for, by number, when the provider says.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub pull_request: Option<i64>,
383}
384
385/// How serious a [`PipelineAnnotation`] is. Ordered most severe first, so sorting by it puts
386/// failures on top.
387#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
388pub enum AnnotationLevel {
389    Failure,
390    Warning,
391    Notice,
392}
393
394/// A problem a run reported, usually anchored to a file and line: a GitHub check-run
395/// annotation, a GitLab failed test case, an Azure timeline issue.
396#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
397pub struct PipelineAnnotation {
398    pub level: AnnotationLevel,
399    pub message: String,
400    /// Short heading where the provider gives one (a test name, an annotation title).
401    pub title: Option<String>,
402    /// Repository-relative file path, when the problem is anchored to one.
403    pub path: Option<String>,
404    pub line: Option<u32>,
405    /// The job that reported it, matching a [`PipelineJob::id`], when known.
406    pub job_id: Option<String>,
407}
408
409/// A file a run published (build output, report, installer).
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
411pub struct PipelineArtifact {
412    pub id: String,
413    pub name: String,
414    pub size_bytes: Option<u64>,
415    pub expires_at: Option<DateTime<Utc>>,
416    /// Where a person opens or downloads it in a browser.
417    pub url: Option<String>,
418}
419
420/// A gate on a pipeline run that is waiting for a manual decision (a deployment
421/// environment reviewer, an approval check, or a manual job).
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
423pub struct PipelineApproval {
424    /// Provider-native identifier used to act on this gate (environment id,
425    /// approval id, or manual job id, depending on the provider).
426    pub id: String,
427    /// Human label for the gate — usually the environment or stage name.
428    pub name: String,
429    /// Whether the authenticated user is allowed to respond to this gate.
430    pub can_respond: bool,
431    /// Whether the run is held until this gate is answered. An environment approval or an Azure
432    /// check is; an optional GitLab manual job (`allow_failure: true`) isn't — the pipeline runs
433    /// on without it, so it mustn't make the run read as [`PipelineRunStatus::Waiting`].
434    #[serde(default = "holds_by_default")]
435    pub blocks_run: bool,
436}
437
438fn holds_by_default() -> bool {
439    true
440}
441
442/// Addressing helpers: each item knows the repository it came from, so a call site never has to
443/// reconstruct one. Use these instead of building an [`crate::provider::ItemRef`] by hand.
444impl PullRequest {
445    pub fn item_ref(&self) -> crate::provider::ItemRef {
446        crate::provider::ItemRef::maybe(self.repository.clone(), self.id.clone())
447    }
448}
449
450impl WorkItem {
451    pub fn item_ref(&self) -> crate::provider::ItemRef {
452        crate::provider::ItemRef::maybe(self.repository.clone(), self.id.clone())
453    }
454}
455
456impl PipelineRun {
457    pub fn item_ref(&self) -> crate::provider::ItemRef {
458        crate::provider::ItemRef::maybe(self.repository.clone(), self.id.clone())
459    }
460
461    /// Addresses this run's *definition* (for a re-run/trigger), not the run itself.
462    pub fn definition_ref(&self) -> crate::provider::ItemRef {
463        crate::provider::ItemRef::maybe(self.repository.clone(), self.definition_id.clone())
464    }
465
466    /// Whether any stage, job or step of this run is executing right now. A list row usually
467    /// carries no stages, so this is only ever evidence *for* running, never against.
468    pub fn has_running_work(&self) -> bool {
469        let running = PipelineRunStatus::Running;
470        self.stages.iter().any(|s| {
471            s.status == running || s.jobs.iter().any(|j| j.status == running || j.steps.iter().any(|t| t.status == running))
472        })
473    }
474
475    /// The status to show for this run. It reads [`Waiting`](PipelineRunStatus::Waiting) when it
476    /// is in flight, held on a gate — a pending approval that `blocks_run`, or a stage the
477    /// provider itself reports as waiting — and nothing in it is still executing: a gate further
478    /// on doesn't make a run that's still building "waiting". Every surface asks here, so the
479    /// list, the run view, the Command Center and the dashboard can't disagree.
480    pub fn shown_status(&self, blocking_gate: bool) -> PipelineRunStatus {
481        let held = blocking_gate || self.stages.iter().any(|s| s.status == PipelineRunStatus::Waiting);
482        self.status.with_pending_approval(held && !self.has_running_work())
483    }
484}
485
486impl PipelineDefinition {
487    pub fn item_ref(&self) -> crate::provider::ItemRef {
488        crate::provider::ItemRef::maybe(self.repository.clone(), self.id.clone())
489    }
490}
491
492/// A decision on a pending [`PipelineApproval`].
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
494pub enum ApprovalDecision {
495    Approve,
496    Reject,
497}
498
499/// Why a notification fired — each provider's native reason/action maps onto one of these.
500#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
501pub enum NotificationKind {
502    /// You were asked to review a PR/MR.
503    ReviewRequested,
504    /// You were @-mentioned (comment, description, team mention).
505    Mention,
506    /// Something was assigned to you.
507    Assigned,
508    /// CI failed on something of yours.
509    CiFailed,
510    /// A new comment/reply on something you follow.
511    Comment,
512    /// The state of something you follow changed (merged, closed, status).
513    StateChange,
514    /// Anything else the provider notified about.
515    Other,
516}
517
518/// What kind of item a notification points at — drives its icon and which source opens it.
519#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
520pub enum NotificationItemType {
521    PullRequest,
522    WorkItem,
523    Pipeline,
524    Other,
525}
526
527/// One item in the cross-provider notification inbox.
528#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
529pub struct Notification {
530    /// Provider-native notification id — used to mark it read.
531    pub id: String,
532    pub kind: NotificationKind,
533    pub item_type: NotificationItemType,
534    /// The underlying PR / work-item / pipeline id, for opening it in-app. `None` when the
535    /// notification has no resolvable item (then we fall back to the web URL).
536    pub item_id: Option<String>,
537    /// The repository the item lives in, **connection-relative** — needed to address `item_id`
538    /// on a connection that spans several repositories. `None` when the provider doesn't say.
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub repository: Option<String>,
541    /// Subject line — the PR/issue title.
542    pub title: String,
543    /// Where it lives: `org/repo`, or the project / team name.
544    pub context: String,
545    /// Web URL, for the browser fallback.
546    pub url: Option<String>,
547    pub unread: bool,
548    pub updated_at: Option<DateTime<Utc>>,
549}
550
551#[cfg(test)]
552mod pipeline_status_tests {
553    use super::PipelineRunStatus::{self, *};
554
555    #[test]
556    fn waiting_is_in_flight_and_skipped_is_not() {
557        for s in [Queued, Running, Waiting] {
558            assert!(s.is_active(), "{s:?}");
559        }
560        for s in [Succeeded, PartiallySucceeded, Failed, Canceled, Skipped] {
561            assert!(!s.is_active(), "{s:?}");
562        }
563    }
564
565    #[test]
566    fn a_pending_gate_holds_only_an_active_run() {
567        assert_eq!(Running.with_pending_approval(true), Waiting);
568        assert_eq!(Queued.with_pending_approval(true), Waiting);
569        assert_eq!(Running.with_pending_approval(false), Running);
570        // A finished run's leftover gate doesn't rewrite its outcome.
571        assert_eq!(Succeeded.with_pending_approval(true), Succeeded);
572        assert_eq!(Failed.with_pending_approval(true), Failed);
573    }
574
575    fn run_with(status: PipelineRunStatus, stages: Vec<super::PipelineStage>) -> super::PipelineRun {
576        super::PipelineRun {
577            event: None, attempt: None, pull_request: None, repository: None, id: "1".into(), definition_id: "d".into(),
578            number: None, name: None, title: None, status, triggered_by: None, branch: None, commit_sha: None,
579            started_at: None, finished_at: None, url: None, stages,
580        }
581    }
582
583    fn stage(status: PipelineRunStatus) -> super::PipelineStage {
584        super::PipelineStage { name: "s".into(), status, jobs: vec![] }
585    }
586
587    #[test]
588    fn a_run_reads_waiting_only_when_a_gate_holds_it_and_nothing_runs() {
589        // A list row: no stages to say otherwise, so a blocking gate parks it.
590        assert_eq!(run_with(Running, vec![]).shown_status(true), Waiting);
591        assert_eq!(run_with(Running, vec![]).shown_status(false), Running);
592        // A stage still executing keeps the run Running whatever is gated further on.
593        assert_eq!(run_with(Running, vec![stage(Succeeded), stage(Running), stage(Queued)]).shown_status(true), Running);
594        // The provider's own Waiting stage holds the run even before the approvals call answers.
595        assert_eq!(run_with(Running, vec![stage(Succeeded), stage(Waiting)]).shown_status(false), Waiting);
596        // A finished run is never rewritten.
597        assert_eq!(run_with(Canceled, vec![stage(Waiting)]).shown_status(true), Canceled);
598    }
599
600    #[test]
601    fn an_approval_from_an_older_cache_still_holds_its_run() {
602        let a: super::PipelineApproval = serde_json::from_str(r#"{ "id": "g", "name": "Prod", "can_respond": true }"#).unwrap();
603        assert!(a.blocks_run);
604    }
605
606    #[test]
607    fn new_states_round_trip_by_name() {
608        for s in [Waiting, Skipped] {
609            let json = serde_json::to_string(&s).unwrap();
610            assert_eq!(serde_json::from_str::<PipelineRunStatus>(&json).unwrap(), s);
611        }
612        assert_eq!(serde_json::to_string(&Waiting).unwrap(), "\"Waiting\"");
613    }
614}