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    Succeeded,
74    PartiallySucceeded,
75    Failed,
76    Canceled,
77}
78
79/// Roll-up CI/check state for a pull request.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
81pub enum CheckStatus {
82    #[default]
83    None,
84    Pending,
85    Passed,
86    Failed,
87}
88
89/// Whether a pull request can be merged.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
91pub enum MergeableState {
92    #[default]
93    Unknown,
94    Mergeable,
95    Blocked,
96    Conflicting,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
100pub enum FileChangeKind {
101    Added,
102    Modified,
103    Deleted,
104    Renamed,
105}
106
107// ---- entities ----
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct User {
111    pub id: String,
112    pub display_name: String,
113    pub handle: Option<String>,
114    pub avatar_url: Option<String>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct Repository {
119    pub id: String,
120    pub name: String,
121    pub full_name: Option<String>,
122    pub default_branch: Option<String>,
123    pub url: Option<String>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct Comment {
128    pub id: String,
129    pub author: User,
130    pub body: String,
131    pub created_at: Option<DateTime<Utc>>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct CommentThread {
136    pub id: String,
137    pub comments: Vec<Comment>,
138    pub file_path: Option<String>,
139    pub line: Option<i64>,
140    pub is_resolved: bool,
141}
142
143/// What happened in a timeline event — drives its icon/colour in the UI.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
145pub enum TimelineEventKind {
146    Approved,
147    ChangesRequested,
148    Reviewed,
149    Commented,
150    Merged,
151    Closed,
152    Reopened,
153    StateChanged,
154    Assigned,
155    Labeled,
156    Committed,
157    Other,
158}
159
160/// A single event on a pull request or work item (a review, a merge, a state change, an
161/// assignment, …), assembled from whatever the provider's timeline / activity / history API
162/// exposes. Ordered oldest → newest.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct TimelineEvent {
165    pub actor: Option<User>,
166    pub kind: TimelineEventKind,
167    /// Human-readable one-liner, e.g. "approved", "changed status to In Progress", "merged".
168    pub summary: String,
169    pub at: Option<DateTime<Utc>>,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct Reviewer {
174    pub user: User,
175    pub vote: ReviewVote,
176    pub is_required: bool,
177}
178
179#[derive(Debug, Clone, Default, Serialize, Deserialize)]
180pub struct CheckSummary {
181    pub successful: u32,
182    pub in_progress: u32,
183    pub failed: u32,
184    pub neutral: u32,
185}
186
187impl CheckSummary {
188    pub fn total(&self) -> u32 {
189        self.successful + self.in_progress + self.failed + self.neutral
190    }
191}
192
193/// A single named CI check / status on a pull request.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct CheckRun {
196    pub name: String,
197    pub status: CheckStatus,
198    pub url: Option<String>,
199}
200
201/// A commit on a pull request.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct Commit {
204    pub sha: String,
205    pub message: String,
206    pub author: String,
207    pub date: Option<DateTime<Utc>>,
208    pub url: Option<String>,
209}
210
211/// Which side of a diff a line belongs to: the old (removed) or new (added) file.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
213pub enum DiffSide {
214    Old,
215    New,
216}
217
218/// A pending review comment targeting a specific line of a file in a pull request.
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct LineComment {
221    pub path: String,
222    pub line: i64,
223    pub side: DiffSide,
224    pub body: String,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct FileChange {
229    pub path: String,
230    pub kind: FileChangeKind,
231    pub additions: i64,
232    pub deletions: i64,
233    /// Unified-diff patch text when the provider supplies it.
234    pub patch: Option<String>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct PullRequest {
239    pub id: String,
240    /// The repository this pull request lives in, **connection-relative** (`acme/pay`) — see
241    /// [`crate::repo`]. `None` for providers that aren't repo-addressed (Jira, Linear).
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub repository: Option<String>,
244    pub number: Option<i64>,
245    pub title: String,
246    pub description: Option<String>,
247    pub author: User,
248    pub status: PullRequestStatus,
249    pub is_draft: bool,
250    pub source_ref: Option<String>,
251    pub target_ref: Option<String>,
252    pub reviewers: Vec<Reviewer>,
253    pub labels: Vec<String>,
254    pub checks: CheckStatus,
255    pub check_summary: Option<CheckSummary>,
256    pub mergeable: MergeableState,
257    pub changed_files: i64,
258    pub additions: i64,
259    pub deletions: i64,
260    pub created_at: Option<DateTime<Utc>>,
261    pub updated_at: Option<DateTime<Utc>>,
262    pub url: Option<String>,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct WorkItem {
267    pub id: String,
268    /// Where this item lives, **connection-relative** — a repository (`acme/pay`) for the
269    /// repo-addressed forges, or a bare project name for Azure DevOps, whose work items are
270    /// project-addressed rather than repo-addressed. `None` for Jira and Linear.
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub repository: Option<String>,
273    pub identifier: Option<String>,
274    pub title: String,
275    pub description: Option<String>,
276    pub state: String,
277    pub state_category: WorkItemStateCategory,
278    pub work_item_type: Option<String>,
279    pub assignee: Option<User>,
280    pub created_at: Option<DateTime<Utc>>,
281    pub updated_at: Option<DateTime<Utc>>,
282    pub url: Option<String>,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct PipelineDefinition {
287    pub id: String,
288    /// The repository this definition belongs to, **connection-relative**. `None` for providers
289    /// that aren't repo-addressed.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub repository: Option<String>,
292    pub name: String,
293    pub path: Option<String>,
294    pub url: Option<String>,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct PipelineStep {
299    pub name: String,
300    pub status: PipelineRunStatus,
301    pub started_at: Option<DateTime<Utc>>,
302    pub finished_at: Option<DateTime<Utc>>,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct PipelineJob {
307    pub id: String,
308    pub name: String,
309    pub status: PipelineRunStatus,
310    pub started_at: Option<DateTime<Utc>>,
311    pub finished_at: Option<DateTime<Utc>>,
312    pub steps: Vec<PipelineStep>,
313    /// Deep link to this job in the provider's web UI.
314    pub url: Option<String>,
315    /// A short problem summary for failed jobs (provider-specific: GitLab's
316    /// failure reason, Azure's error/warning counts, etc.).
317    pub problem: Option<String>,
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct PipelineStage {
322    pub name: String,
323    pub status: PipelineRunStatus,
324    pub jobs: Vec<PipelineJob>,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct PipelineRun {
329    pub id: String,
330    /// The repository this run belongs to, **connection-relative**. `None` for providers that
331    /// aren't repo-addressed.
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub repository: Option<String>,
334    pub definition_id: String,
335    pub number: Option<i64>,
336    pub name: Option<String>,
337    /// A human-readable title for the run — the triggering PR title or commit-message subject
338    /// where the provider exposes it (GitHub `display_title`). `None` when unavailable.
339    pub title: Option<String>,
340    pub status: PipelineRunStatus,
341    pub triggered_by: Option<User>,
342    pub branch: Option<String>,
343    pub commit_sha: Option<String>,
344    pub started_at: Option<DateTime<Utc>>,
345    pub finished_at: Option<DateTime<Utc>>,
346    pub url: Option<String>,
347    pub stages: Vec<PipelineStage>,
348    /// What started the run, in the provider's own words (`push`, `pull_request`,
349    /// `schedule`, Azure `individualCI`, GitLab `merge_request_event`, …). `None` when unknown.
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub event: Option<String>,
352    /// Which attempt of this run this is (GitHub `run_attempt`); `None` where the provider
353    /// doesn't number re-runs.
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub attempt: Option<u32>,
356    /// The pull/merge request this run was built for, by number, when the provider says.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub pull_request: Option<i64>,
359}
360
361/// How serious a [`PipelineAnnotation`] is. Ordered most severe first, so sorting by it puts
362/// failures on top.
363#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
364pub enum AnnotationLevel {
365    Failure,
366    Warning,
367    Notice,
368}
369
370/// A problem a run reported, usually anchored to a file and line: a GitHub check-run
371/// annotation, a GitLab failed test case, an Azure timeline issue.
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373pub struct PipelineAnnotation {
374    pub level: AnnotationLevel,
375    pub message: String,
376    /// Short heading where the provider gives one (a test name, an annotation title).
377    pub title: Option<String>,
378    /// Repository-relative file path, when the problem is anchored to one.
379    pub path: Option<String>,
380    pub line: Option<u32>,
381    /// The job that reported it, matching a [`PipelineJob::id`], when known.
382    pub job_id: Option<String>,
383}
384
385/// A file a run published (build output, report, installer).
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387pub struct PipelineArtifact {
388    pub id: String,
389    pub name: String,
390    pub size_bytes: Option<u64>,
391    pub expires_at: Option<DateTime<Utc>>,
392    /// Where a person opens or downloads it in a browser.
393    pub url: Option<String>,
394}
395
396/// A gate on a pipeline run that is waiting for a manual decision (a deployment
397/// environment reviewer, an approval check, or a manual job).
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
399pub struct PipelineApproval {
400    /// Provider-native identifier used to act on this gate (environment id,
401    /// approval id, or manual job id, depending on the provider).
402    pub id: String,
403    /// Human label for the gate — usually the environment or stage name.
404    pub name: String,
405    /// Whether the authenticated user is allowed to respond to this gate.
406    pub can_respond: bool,
407}
408
409/// Addressing helpers: each item knows the repository it came from, so a call site never has to
410/// reconstruct one. Use these instead of building an [`crate::provider::ItemRef`] by hand.
411impl PullRequest {
412    pub fn item_ref(&self) -> crate::provider::ItemRef {
413        crate::provider::ItemRef::maybe(self.repository.clone(), self.id.clone())
414    }
415}
416
417impl WorkItem {
418    pub fn item_ref(&self) -> crate::provider::ItemRef {
419        crate::provider::ItemRef::maybe(self.repository.clone(), self.id.clone())
420    }
421}
422
423impl PipelineRun {
424    pub fn item_ref(&self) -> crate::provider::ItemRef {
425        crate::provider::ItemRef::maybe(self.repository.clone(), self.id.clone())
426    }
427
428    /// Addresses this run's *definition* (for a re-run/trigger), not the run itself.
429    pub fn definition_ref(&self) -> crate::provider::ItemRef {
430        crate::provider::ItemRef::maybe(self.repository.clone(), self.definition_id.clone())
431    }
432}
433
434impl PipelineDefinition {
435    pub fn item_ref(&self) -> crate::provider::ItemRef {
436        crate::provider::ItemRef::maybe(self.repository.clone(), self.id.clone())
437    }
438}
439
440/// A decision on a pending [`PipelineApproval`].
441#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
442pub enum ApprovalDecision {
443    Approve,
444    Reject,
445}
446
447/// Why a notification fired — each provider's native reason/action maps onto one of these.
448#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
449pub enum NotificationKind {
450    /// You were asked to review a PR/MR.
451    ReviewRequested,
452    /// You were @-mentioned (comment, description, team mention).
453    Mention,
454    /// Something was assigned to you.
455    Assigned,
456    /// CI failed on something of yours.
457    CiFailed,
458    /// A new comment/reply on something you follow.
459    Comment,
460    /// The state of something you follow changed (merged, closed, status).
461    StateChange,
462    /// Anything else the provider notified about.
463    Other,
464}
465
466/// What kind of item a notification points at — drives its icon and which source opens it.
467#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
468pub enum NotificationItemType {
469    PullRequest,
470    WorkItem,
471    Pipeline,
472    Other,
473}
474
475/// One item in the cross-provider notification inbox.
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
477pub struct Notification {
478    /// Provider-native notification id — used to mark it read.
479    pub id: String,
480    pub kind: NotificationKind,
481    pub item_type: NotificationItemType,
482    /// The underlying PR / work-item / pipeline id, for opening it in-app. `None` when the
483    /// notification has no resolvable item (then we fall back to the web URL).
484    pub item_id: Option<String>,
485    /// The repository the item lives in, **connection-relative** — needed to address `item_id`
486    /// on a connection that spans several repositories. `None` when the provider doesn't say.
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub repository: Option<String>,
489    /// Subject line — the PR/issue title.
490    pub title: String,
491    /// Where it lives: `org/repo`, or the project / team name.
492    pub context: String,
493    /// Web URL, for the browser fallback.
494    pub url: Option<String>,
495    pub unread: bool,
496    pub updated_at: Option<DateTime<Utc>>,
497}