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    /// Whether the forge can mark this thread resolved at all. `false` for threads a forge only
166    /// bundles (GitHub's flat conversation comments, GitLab's individual notes, work-item
167    /// comments), so a frontend offers "resolve" only where it would work.
168    #[serde(default = "resolvable_by_default")]
169    pub is_resolvable: bool,
170}
171
172/// Serde default for [`CommentThread::is_resolvable`]: a thread cached before the field
173/// existed is assumed resolvable, which is the common case.
174fn resolvable_by_default() -> bool {
175    true
176}
177
178/// What happened in a timeline event — drives its icon/colour in the UI.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180pub enum TimelineEventKind {
181    Approved,
182    ChangesRequested,
183    Reviewed,
184    Commented,
185    Merged,
186    Closed,
187    Reopened,
188    StateChanged,
189    Assigned,
190    Labeled,
191    Committed,
192    Other,
193}
194
195/// A single event on a pull request or work item (a review, a merge, a state change, an
196/// assignment, …), assembled from whatever the provider's timeline / activity / history API
197/// exposes. Ordered oldest → newest.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct TimelineEvent {
200    pub actor: Option<User>,
201    pub kind: TimelineEventKind,
202    /// Human-readable one-liner, e.g. "approved", "changed status to In Progress", "merged".
203    pub summary: String,
204    pub at: Option<DateTime<Utc>>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct Reviewer {
209    pub user: User,
210    pub vote: ReviewVote,
211    pub is_required: bool,
212}
213
214#[derive(Debug, Clone, Default, Serialize, Deserialize)]
215pub struct CheckSummary {
216    pub successful: u32,
217    pub in_progress: u32,
218    pub failed: u32,
219    pub neutral: u32,
220}
221
222impl CheckSummary {
223    pub fn total(&self) -> u32 {
224        self.successful + self.in_progress + self.failed + self.neutral
225    }
226}
227
228/// A single named CI check / status on a pull request.
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct CheckRun {
231    pub name: String,
232    pub status: CheckStatus,
233    pub url: Option<String>,
234}
235
236/// A commit on a pull request.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct Commit {
239    pub sha: String,
240    pub message: String,
241    pub author: String,
242    pub date: Option<DateTime<Utc>>,
243    pub url: Option<String>,
244}
245
246/// Which side of a diff a line belongs to: the old (removed) or new (added) file.
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
248pub enum DiffSide {
249    Old,
250    New,
251}
252
253/// A pending review comment targeting a specific line of a file in a pull request.
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct LineComment {
256    pub path: String,
257    pub line: i64,
258    pub side: DiffSide,
259    pub body: String,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct FileChange {
264    pub path: String,
265    pub kind: FileChangeKind,
266    pub additions: i64,
267    pub deletions: i64,
268    /// Unified-diff patch text when the provider supplies it.
269    pub patch: Option<String>,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct PullRequest {
274    pub id: String,
275    /// The repository this pull request lives in, **connection-relative** (`acme/pay`) — see
276    /// [`crate::repo`]. `None` for providers that aren't repo-addressed (Jira, Linear).
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub repository: Option<String>,
279    pub number: Option<i64>,
280    pub title: String,
281    pub description: Option<String>,
282    pub author: User,
283    pub status: PullRequestStatus,
284    pub is_draft: bool,
285    pub source_ref: Option<String>,
286    pub target_ref: Option<String>,
287    pub reviewers: Vec<Reviewer>,
288    pub labels: Vec<String>,
289    pub checks: CheckStatus,
290    pub check_summary: Option<CheckSummary>,
291    pub mergeable: MergeableState,
292    pub changed_files: i64,
293    pub additions: i64,
294    pub deletions: i64,
295    pub created_at: Option<DateTime<Utc>>,
296    pub updated_at: Option<DateTime<Utc>>,
297    pub url: Option<String>,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct WorkItem {
302    pub id: String,
303    /// Where this item lives, **connection-relative** — a repository (`acme/pay`) for the
304    /// repo-addressed forges, or a bare project name for Azure DevOps, whose work items are
305    /// project-addressed rather than repo-addressed. `None` for Jira and Linear.
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub repository: Option<String>,
308    pub identifier: Option<String>,
309    pub title: String,
310    pub description: Option<String>,
311    pub state: String,
312    pub state_category: WorkItemStateCategory,
313    pub work_item_type: Option<String>,
314    pub assignee: Option<User>,
315    pub created_at: Option<DateTime<Utc>>,
316    pub updated_at: Option<DateTime<Utc>>,
317    pub url: Option<String>,
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct PipelineDefinition {
322    pub id: String,
323    /// The repository this definition belongs to, **connection-relative**. `None` for providers
324    /// that aren't repo-addressed.
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub repository: Option<String>,
327    pub name: String,
328    pub path: Option<String>,
329    pub url: Option<String>,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct PipelineStep {
334    pub name: String,
335    pub status: PipelineRunStatus,
336    pub started_at: Option<DateTime<Utc>>,
337    pub finished_at: Option<DateTime<Utc>>,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct PipelineJob {
342    pub id: String,
343    pub name: String,
344    pub status: PipelineRunStatus,
345    pub started_at: Option<DateTime<Utc>>,
346    pub finished_at: Option<DateTime<Utc>>,
347    pub steps: Vec<PipelineStep>,
348    /// Deep link to this job in the provider's web UI.
349    pub url: Option<String>,
350    /// A short problem summary for failed jobs (provider-specific: GitLab's
351    /// failure reason, Azure's error/warning counts, etc.).
352    pub problem: Option<String>,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct PipelineStage {
357    pub name: String,
358    pub status: PipelineRunStatus,
359    pub jobs: Vec<PipelineJob>,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct PipelineRun {
364    pub id: String,
365    /// The repository this run belongs to, **connection-relative**. `None` for providers that
366    /// aren't repo-addressed.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub repository: Option<String>,
369    pub definition_id: String,
370    pub number: Option<i64>,
371    pub name: Option<String>,
372    /// A human-readable title for the run — the triggering PR title or commit-message subject
373    /// where the provider exposes it (GitHub `display_title`). `None` when unavailable.
374    pub title: Option<String>,
375    pub status: PipelineRunStatus,
376    pub triggered_by: Option<User>,
377    pub branch: Option<String>,
378    pub commit_sha: Option<String>,
379    pub started_at: Option<DateTime<Utc>>,
380    pub finished_at: Option<DateTime<Utc>>,
381    pub url: Option<String>,
382    pub stages: Vec<PipelineStage>,
383    /// What started the run, in the provider's own words (`push`, `pull_request`,
384    /// `schedule`, Azure `individualCI`, GitLab `merge_request_event`, …). `None` when unknown.
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub event: Option<String>,
387    /// Which attempt of this run this is (GitHub `run_attempt`); `None` where the provider
388    /// doesn't number re-runs.
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub attempt: Option<u32>,
391    /// The pull/merge request this run was built for, by number, when the provider says.
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub pull_request: Option<i64>,
394}
395
396/// How serious a [`PipelineAnnotation`] is. Ordered most severe first, so sorting by it puts
397/// failures on top.
398#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
399pub enum AnnotationLevel {
400    Failure,
401    Warning,
402    Notice,
403}
404
405/// A problem a run reported, usually anchored to a file and line: a GitHub check-run
406/// annotation, a GitLab failed test case, an Azure timeline issue.
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
408pub struct PipelineAnnotation {
409    pub level: AnnotationLevel,
410    pub message: String,
411    /// Short heading where the provider gives one (a test name, an annotation title).
412    pub title: Option<String>,
413    /// Repository-relative file path, when the problem is anchored to one.
414    pub path: Option<String>,
415    pub line: Option<u32>,
416    /// The job that reported it, matching a [`PipelineJob::id`], when known.
417    pub job_id: Option<String>,
418}
419
420/// A file a run published (build output, report, installer).
421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
422pub struct PipelineArtifact {
423    pub id: String,
424    pub name: String,
425    pub size_bytes: Option<u64>,
426    pub expires_at: Option<DateTime<Utc>>,
427    /// Where a person opens or downloads it in a browser.
428    pub url: Option<String>,
429}
430
431/// A gate on a pipeline run that is waiting for a manual decision (a deployment
432/// environment reviewer, an approval check, or a manual job).
433#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
434pub struct PipelineApproval {
435    /// Provider-native identifier used to act on this gate (environment id,
436    /// approval id, or manual job id, depending on the provider).
437    pub id: String,
438    /// Human label for the gate — usually the environment or stage name.
439    pub name: String,
440    /// Whether the authenticated user is allowed to respond to this gate.
441    pub can_respond: bool,
442    /// Whether the run is held until this gate is answered. An environment approval or an Azure
443    /// check is; an optional GitLab manual job (`allow_failure: true`) isn't — the pipeline runs
444    /// on without it, so it mustn't make the run read as [`PipelineRunStatus::Waiting`].
445    #[serde(default = "holds_by_default")]
446    pub blocks_run: bool,
447}
448
449fn holds_by_default() -> bool {
450    true
451}
452
453/// Addressing helpers: each item knows the repository it came from, so a call site never has to
454/// reconstruct one. Use these instead of building an [`crate::provider::ItemRef`] by hand.
455impl PullRequest {
456    pub fn item_ref(&self) -> crate::provider::ItemRef {
457        crate::provider::ItemRef::maybe(self.repository.clone(), self.id.clone())
458    }
459}
460
461impl WorkItem {
462    pub fn item_ref(&self) -> crate::provider::ItemRef {
463        crate::provider::ItemRef::maybe(self.repository.clone(), self.id.clone())
464    }
465}
466
467impl PipelineRun {
468    pub fn item_ref(&self) -> crate::provider::ItemRef {
469        crate::provider::ItemRef::maybe(self.repository.clone(), self.id.clone())
470    }
471
472    /// Addresses this run's *definition* (for a re-run/trigger), not the run itself.
473    pub fn definition_ref(&self) -> crate::provider::ItemRef {
474        crate::provider::ItemRef::maybe(self.repository.clone(), self.definition_id.clone())
475    }
476
477    /// Whether any stage, job or step of this run is executing right now. A list row usually
478    /// carries no stages, so this is only ever evidence *for* running, never against.
479    pub fn has_running_work(&self) -> bool {
480        let running = PipelineRunStatus::Running;
481        self.stages.iter().any(|s| {
482            s.status == running || s.jobs.iter().any(|j| j.status == running || j.steps.iter().any(|t| t.status == running))
483        })
484    }
485
486    /// The status to show for this run. It reads [`Waiting`](PipelineRunStatus::Waiting) when it
487    /// is in flight, held on a gate — a pending approval that `blocks_run`, or a stage the
488    /// provider itself reports as waiting — and nothing in it is still executing: a gate further
489    /// on doesn't make a run that's still building "waiting". Every surface asks here, so the
490    /// list, the run view, the Command Center and the dashboard can't disagree.
491    pub fn shown_status(&self, blocking_gate: bool) -> PipelineRunStatus {
492        let held = blocking_gate || self.stages.iter().any(|s| s.status == PipelineRunStatus::Waiting);
493        self.status.with_pending_approval(held && !self.has_running_work())
494    }
495}
496
497impl PipelineDefinition {
498    pub fn item_ref(&self) -> crate::provider::ItemRef {
499        crate::provider::ItemRef::maybe(self.repository.clone(), self.id.clone())
500    }
501}
502
503/// A decision on a pending [`PipelineApproval`].
504#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
505pub enum ApprovalDecision {
506    Approve,
507    Reject,
508}
509
510/// Why a notification fired — each provider's native reason/action maps onto one of these.
511#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
512pub enum NotificationKind {
513    /// You were asked to review a PR/MR.
514    ReviewRequested,
515    /// You were @-mentioned (comment, description, team mention).
516    Mention,
517    /// Something was assigned to you.
518    Assigned,
519    /// CI failed on something of yours.
520    CiFailed,
521    /// A new comment/reply on something you follow.
522    Comment,
523    /// The state of something you follow changed (merged, closed, status).
524    StateChange,
525    /// Anything else the provider notified about.
526    Other,
527}
528
529/// What kind of item a notification points at — drives its icon and which source opens it.
530#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
531pub enum NotificationItemType {
532    PullRequest,
533    WorkItem,
534    Pipeline,
535    Other,
536}
537
538/// One item in the cross-provider notification inbox.
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
540pub struct Notification {
541    /// Provider-native notification id — used to mark it read.
542    pub id: String,
543    pub kind: NotificationKind,
544    pub item_type: NotificationItemType,
545    /// The underlying PR / work-item / pipeline id, for opening it in-app. `None` when the
546    /// notification has no resolvable item (then we fall back to the web URL).
547    pub item_id: Option<String>,
548    /// The repository the item lives in, **connection-relative** — needed to address `item_id`
549    /// on a connection that spans several repositories. `None` when the provider doesn't say.
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub repository: Option<String>,
552    /// Subject line — the PR/issue title.
553    pub title: String,
554    /// Where it lives: `org/repo`, or the project / team name.
555    pub context: String,
556    /// Web URL, for the browser fallback.
557    pub url: Option<String>,
558    pub unread: bool,
559    pub updated_at: Option<DateTime<Utc>>,
560}
561
562#[cfg(test)]
563mod pipeline_status_tests {
564    use super::PipelineRunStatus::{self, *};
565
566    #[test]
567    fn waiting_is_in_flight_and_skipped_is_not() {
568        for s in [Queued, Running, Waiting] {
569            assert!(s.is_active(), "{s:?}");
570        }
571        for s in [Succeeded, PartiallySucceeded, Failed, Canceled, Skipped] {
572            assert!(!s.is_active(), "{s:?}");
573        }
574    }
575
576    #[test]
577    fn a_pending_gate_holds_only_an_active_run() {
578        assert_eq!(Running.with_pending_approval(true), Waiting);
579        assert_eq!(Queued.with_pending_approval(true), Waiting);
580        assert_eq!(Running.with_pending_approval(false), Running);
581        // A finished run's leftover gate doesn't rewrite its outcome.
582        assert_eq!(Succeeded.with_pending_approval(true), Succeeded);
583        assert_eq!(Failed.with_pending_approval(true), Failed);
584    }
585
586    fn run_with(status: PipelineRunStatus, stages: Vec<super::PipelineStage>) -> super::PipelineRun {
587        super::PipelineRun {
588            event: None, attempt: None, pull_request: None, repository: None, id: "1".into(), definition_id: "d".into(),
589            number: None, name: None, title: None, status, triggered_by: None, branch: None, commit_sha: None,
590            started_at: None, finished_at: None, url: None, stages,
591        }
592    }
593
594    fn stage(status: PipelineRunStatus) -> super::PipelineStage {
595        super::PipelineStage { name: "s".into(), status, jobs: vec![] }
596    }
597
598    #[test]
599    fn a_run_reads_waiting_only_when_a_gate_holds_it_and_nothing_runs() {
600        // A list row: no stages to say otherwise, so a blocking gate parks it.
601        assert_eq!(run_with(Running, vec![]).shown_status(true), Waiting);
602        assert_eq!(run_with(Running, vec![]).shown_status(false), Running);
603        // A stage still executing keeps the run Running whatever is gated further on.
604        assert_eq!(run_with(Running, vec![stage(Succeeded), stage(Running), stage(Queued)]).shown_status(true), Running);
605        // The provider's own Waiting stage holds the run even before the approvals call answers.
606        assert_eq!(run_with(Running, vec![stage(Succeeded), stage(Waiting)]).shown_status(false), Waiting);
607        // A finished run is never rewritten.
608        assert_eq!(run_with(Canceled, vec![stage(Waiting)]).shown_status(true), Canceled);
609    }
610
611    #[test]
612    fn an_approval_from_an_older_cache_still_holds_its_run() {
613        let a: super::PipelineApproval = serde_json::from_str(r#"{ "id": "g", "name": "Prod", "can_respond": true }"#).unwrap();
614        assert!(a.blocks_run);
615    }
616
617    #[test]
618    fn new_states_round_trip_by_name() {
619        for s in [Waiting, Skipped] {
620            let json = serde_json::to_string(&s).unwrap();
621            assert_eq!(serde_json::from_str::<PipelineRunStatus>(&json).unwrap(), s);
622        }
623        assert_eq!(serde_json::to_string(&Waiting).unwrap(), "\"Waiting\"");
624    }
625}