Skip to main content

forgetop_core/
launchpad.rs

1//! Launchpad triage engine: classifies aggregated items into action buckets.
2//!
3//! NAMING (for humans and AI): the user-facing name for this feature is the **"Command Center"**
4//! — that is the label shown in the TUI tab, the dashboard sidebar, docs, etc. The code
5//! deliberately keeps the original `launchpad` / `lp` identifiers (types, fields, API routes,
6//! module names) unchanged. So "Command Center" and "launchpad" refer to the same thing; only the
7//! display strings say "Command Center". Do not rename the code identifiers to match.
8//!
9//! Pure logic — no UI, no fetching — so the triage rules are unit-tested in isolation and,
10//! more importantly, **shared** by the terminal UI and the web dashboard. Both frontends map
11//! their own row types onto the [`PrInput`] / [`WiInput`] / [`PipeInput`] structs here and call
12//! [`build`], so the two never disagree about what "needs your review" or "ready to merge" means.
13
14use chrono::{DateTime, Utc};
15
16use crate::domain::{
17    CheckStatus, MergeableState, PipelineRun, PipelineRunStatus, ProviderType, PullRequest, PullRequestStatus,
18    ReviewVote, User, WorkItem, WorkItemStateCategory,
19};
20
21/// The action bucket an item lands in. [`Bucket::ORDER`] is the display/urgency order — the
22/// things others are blocked on first, then what you can ship, then bounce-backs, then your own
23/// backlog, then muted/informational.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Bucket {
26    NeedsReview,
27    ApprovalsWaiting,
28    ReadyToMerge,
29    NeedsFixing,
30    YourWork,
31    YourOpenPrs,
32    RecentPipelines,
33    RecentlyMerged,
34}
35
36impl Bucket {
37    /// Every bucket, in display (urgency) order.
38    pub const ORDER: [Bucket; 8] = [
39        Bucket::NeedsReview,
40        Bucket::ApprovalsWaiting,
41        Bucket::ReadyToMerge,
42        Bucket::NeedsFixing,
43        Bucket::YourWork,
44        Bucket::YourOpenPrs,
45        Bucket::RecentlyMerged,
46        Bucket::RecentPipelines,
47    ];
48
49    pub fn title(&self) -> &'static str {
50        match self {
51            Bucket::NeedsReview => "Needs your review",
52            Bucket::ApprovalsWaiting => "Approvals waiting",
53            Bucket::ReadyToMerge => "Ready to merge",
54            Bucket::NeedsFixing => "Needs fixing",
55            Bucket::YourWork => "Assigned to you",
56            Bucket::YourOpenPrs => "Your open pull requests",
57            Bucket::RecentPipelines => "Recent pipelines",
58            Bucket::RecentlyMerged => "Your recently merged pull requests",
59        }
60    }
61
62    /// A stable machine-readable key for the bucket (used as the JSON tag for the web UI).
63    pub fn key(&self) -> &'static str {
64        match self {
65            Bucket::NeedsReview => "needs_review",
66            Bucket::ApprovalsWaiting => "approvals_waiting",
67            Bucket::ReadyToMerge => "ready_to_merge",
68            Bucket::NeedsFixing => "needs_fixing",
69            Bucket::YourWork => "your_work",
70            Bucket::YourOpenPrs => "your_open_prs",
71            Bucket::RecentPipelines => "recent_pipelines",
72            Bucket::RecentlyMerged => "recently_merged",
73        }
74    }
75
76    /// Muted buckets are reference lists (dim heading, not counted in the tab badge) — they
77    /// restate items shown elsewhere (your full open-PR list, recent pipelines, recently-merged).
78    pub fn muted(&self) -> bool {
79        matches!(self, Bucket::YourOpenPrs | Bucket::RecentPipelines | Bucket::RecentlyMerged)
80    }
81
82    /// Which Launchpad column this bucket lives in: 0 = left ("Needs you" — things ripe for
83    /// action now), 1 = right ("Your work" — your PRs, items, recently merged).
84    pub fn column(&self) -> usize {
85        match self {
86            Bucket::NeedsReview | Bucket::ApprovalsWaiting | Bucket::ReadyToMerge | Bucket::NeedsFixing => 0,
87            _ => 1,
88        }
89    }
90}
91
92/// Which feed a PR came from — i.e. the current user's relationship to it.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum PrRole {
95    /// You're a requested reviewer.
96    Reviewer,
97    /// You authored it.
98    Author,
99}
100
101/// `(approved, changes_requested)` rolled up from a PR's reviewer votes.
102pub fn pr_vote_flags(pr: &PullRequest) -> (bool, bool) {
103    let approved = pr.reviewers.iter().any(|r| matches!(r.vote, ReviewVote::Approved | ReviewVote::ApprovedWithSuggestions));
104    let changes = pr.reviewers.iter().any(|r| matches!(r.vote, ReviewVote::Rejected));
105    (approved, changes)
106}
107
108/// What is standing between a pull request and its merge — the reason an authored one lands in
109/// [`Bucket::NeedsFixing`], so a row can *say* what a bucket only implies.
110///
111/// [`classify_pr`] buckets an **authored** PR on [`pr_blocker`] being `Some`, so for those the
112/// displayed reason and the bucketing reason are one judgement and cannot drift.
113///
114/// Frontends also render it for rows they did *not* bucket this way — a PR awaiting your review
115/// can carry a conflict or another reviewer's rejection, and saying so is useful even though the
116/// row sits under "Needs your review". So treat it as "what blocks this PR", not as a claim
117/// about which bucket the row is in.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum PrBlocker {
120    /// The branch no longer merges cleanly.
121    Conflicting,
122    /// A reviewer asked for changes.
123    ChangesRequested,
124    /// Required checks came back red.
125    ChecksFailing,
126}
127
128impl PrBlocker {
129    /// A short label for the row's signal cell.
130    pub fn label(&self) -> &'static str {
131        match self {
132            PrBlocker::Conflicting => "conflicts",
133            PrBlocker::ChangesRequested => "changes",
134            PrBlocker::ChecksFailing => "checks",
135        }
136    }
137}
138
139/// What's blocking `pr`, or `None` when nothing is.
140///
141/// Precedence is most-fundamental first: a conflict blocks the merge no matter what review and
142/// CI say, and a review asking for changes outranks red checks, since addressing the review will
143/// re-run them anyway. Only the winner is shown, so the row names one thing to go and do.
144///
145/// Drafts are never blocked — they aren't asking anything of you yet.
146pub fn pr_blocker(pr: &PullRequest) -> Option<PrBlocker> {
147    if pr.is_draft {
148        return None;
149    }
150    let (_, changes) = pr_vote_flags(pr);
151    if matches!(pr.mergeable, MergeableState::Conflicting) {
152        Some(PrBlocker::Conflicting)
153    } else if changes {
154        Some(PrBlocker::ChangesRequested)
155    } else if pr.checks == CheckStatus::Failed {
156        Some(PrBlocker::ChecksFailing)
157    } else {
158        None
159    }
160}
161
162/// Where a pull request stands, as one verdict — the single thing to tell someone who just
163/// opened it.
164///
165/// Composed from [`pr_blocker`] rather than re-deriving it, so the PR list, the Command Center
166/// row and the PR screen cannot disagree about the same pull request; each renders this at a
167/// different length.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum PrState {
170    /// Shipped.
171    Merged,
172    /// Closed without merging.
173    Closed,
174    /// Not open for review yet.
175    Draft,
176    /// Something is in the author's way — see [`PrBlocker`].
177    Blocked(PrBlocker),
178    /// CI hasn't finished: neither blocked nor clear.
179    ChecksRunning,
180    /// Nothing blocking *and* approved and mergeable — the merge button is live.
181    ReadyToMerge,
182    /// Nothing blocking, but not yet approved.
183    NothingBlocking,
184}
185
186impl PrState {
187    /// True for the states that need the author to do something.
188    pub fn is_blocked(&self) -> bool {
189        matches!(self, PrState::Blocked(_))
190    }
191}
192
193/// Where a pull request stands. See [`PrState`].
194///
195/// Lifecycle is tested before anything else: a merged or closed pull request keeps whatever
196/// `mergeable` and reviewer votes it had when it was open, so asking what blocks it gives a
197/// stale answer. A draft isn't blocked either — it isn't asking anything of anyone yet. Only
198/// then does [`pr_blocker`] get a say, and unfinished CI is reported ahead of either "clear"
199/// state so a half-green pull request never reads as done.
200pub fn pr_state(pr: &PullRequest) -> PrState {
201    match pr.status {
202        PullRequestStatus::Merged => return PrState::Merged,
203        PullRequestStatus::Closed => return PrState::Closed,
204        PullRequestStatus::Draft => return PrState::Draft,
205        PullRequestStatus::Open => {}
206    }
207    if pr.is_draft {
208        return PrState::Draft;
209    }
210    if let Some(b) = pr_blocker(pr) {
211        return PrState::Blocked(b);
212    }
213    let running = match &pr.check_summary {
214        Some(s) => s.in_progress > 0,
215        None => pr.checks == CheckStatus::Pending,
216    };
217    if running {
218        return PrState::ChecksRunning;
219    }
220    let (approved, _) = pr_vote_flags(pr);
221    if approved && matches!(pr.mergeable, MergeableState::Mergeable) {
222        PrState::ReadyToMerge
223    } else {
224        PrState::NothingBlocking
225    }
226}
227
228/// The reviewer whose rejection is holding this pull request up, when one is known — so a row can
229/// say *who* asked for changes rather than only that someone did.
230pub fn pr_changes_requested_by(pr: &PullRequest) -> Option<&User> {
231    pr.reviewers.iter().find(|r| r.vote == ReviewVote::Rejected).map(|r| &r.user)
232}
233
234/// The reviewer who approved, when one is known.
235pub fn pr_approved_by(pr: &PullRequest) -> Option<&User> {
236    pr.reviewers
237        .iter()
238        .find(|r| matches!(r.vote, ReviewVote::Approved | ReviewVote::ApprovedWithSuggestions))
239        .map(|r| &r.user)
240}
241
242/// The left-column action bucket a PR lands in, or `None` when there's nothing to act on right
243/// now (a draft, or one just waiting on others' review — those still show in your full open-PR
244/// list on the right, but not as an action item).
245pub fn classify_pr(pr: &PullRequest, role: PrRole) -> Option<Bucket> {
246    match role {
247        // If you're a requested reviewer, someone is blocked on you — top priority.
248        PrRole::Reviewer => Some(Bucket::NeedsReview),
249        PrRole::Author => {
250            // Not redundant with the guard inside `pr_blocker`: this one also keeps an approved,
251            // mergeable *draft* out of `ReadyToMerge` below. Removing it changes behaviour.
252            if pr.is_draft {
253                return None;
254            }
255            if pr_blocker(pr).is_some() {
256                Some(Bucket::NeedsFixing)
257            } else {
258                let (approved, _) = pr_vote_flags(pr);
259                if approved && matches!(pr.mergeable, MergeableState::Mergeable) {
260                    Some(Bucket::ReadyToMerge)
261                } else {
262                    None // open, nothing wrong, just waiting on others
263                }
264            }
265        }
266    }
267}
268
269/// The human-readable title for a pipeline run: what it was *running*, not what ran it.
270///
271/// Prefers the run's own title — the commit subject or triggering PR title, which is what
272/// GitHub's `display_title` and Bitbucket's commit message carry — since that says what
273/// changed. Falls back to [`pipe_workflow`] — the pipeline it ran under — for the providers that
274/// expose no per-run title (GitLab, Azure DevOps).
275///
276/// A blank title counts as absent: Bitbucket builds one from the first line of the commit
277/// message, which is empty when the message starts with a newline.
278pub fn pipe_title<'a>(run: &'a PipelineRun, definition_name: Option<&'a str>) -> &'a str {
279    run.title
280        .as_deref()
281        .filter(|t| !t.trim().is_empty())
282        .unwrap_or_else(|| pipe_workflow(run, definition_name))
283}
284
285/// The pipeline a run belongs to — its definition name, falling back to the run's own name and
286/// finally its definition id. Shared so both frontends label a run the same way.
287pub fn pipe_workflow<'a>(run: &'a PipelineRun, definition_name: Option<&'a str>) -> &'a str {
288    definition_name.or(run.name.as_deref()).unwrap_or(&run.definition_id)
289}
290
291/// How many entries each right-column reference list shows before a "more…" affordance. When a
292/// bucket has more than this, [`Overflow`] flags it so the frontends can link to the full page.
293const NEEDS_REVIEW_MAX: usize = 5;
294const YOUR_WORK_MAX: usize = 5;
295const YOUR_OPEN_PRS_MAX: usize = 5;
296const RECENT_MERGE_MAX: usize = 5;
297const RECENT_PIPELINE_MAX: usize = 5;
298/// Recency window for the "Recently merged" section.
299const RECENT_MERGE_DAYS: i64 = 7;
300
301/// True for your PRs merged within the recency window (shown in "Recently merged").
302fn merged_recently(pr: &PullRequest, now: DateTime<Utc>) -> bool {
303    pr.status == PullRequestStatus::Merged && pr.updated_at.map(|t| (now - t).num_days() <= RECENT_MERGE_DAYS).unwrap_or(false)
304}
305
306/// Classifies a pipeline run, or `None` when it needs no attention.
307pub fn classify_pipe(status: PipelineRunStatus, awaiting_approval: bool) -> Option<Bucket> {
308    if awaiting_approval {
309        Some(Bucket::ApprovalsWaiting)
310    } else if matches!(status, PipelineRunStatus::Failed) {
311        Some(Bucket::NeedsFixing)
312    } else {
313        None
314    }
315}
316
317/// The kind of item behind a Launchpad row (drives what `Enter` opens).
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub enum EntryKind {
320    Pr,
321    Wi,
322    Pipe,
323}
324
325impl EntryKind {
326    /// Short type badge shown on each row.
327    pub fn label(&self) -> &'static str {
328        match self {
329            EntryKind::Pr => "PR",
330            EntryKind::Wi => "Issue",
331            EntryKind::Pipe => "Pipeline",
332        }
333    }
334}
335
336/// The underlying domain object behind a Launchpad row, kept whole so a row can render the same
337/// detail as its section's nav list.
338pub enum EntryItem {
339    Pr(PullRequest),
340    Wi(WorkItem),
341    Pipe { run: PipelineRun, definition_name: Option<String> },
342}
343
344/// One actionable item on the Launchpad, resolved to its bucket + the full domain object.
345pub struct Entry {
346    pub bucket: Bucket,
347    pub connection_id: String,
348    /// Display name of the connection (for the "provider · connection" tag).
349    pub connection: String,
350    pub provider: ProviderType,
351    pub item: EntryItem,
352}
353
354impl Entry {
355    pub fn kind(&self) -> EntryKind {
356        match self.item {
357            EntryItem::Pr(_) => EntryKind::Pr,
358            EntryItem::Wi(_) => EntryKind::Wi,
359            EntryItem::Pipe { .. } => EntryKind::Pipe,
360        }
361    }
362
363    pub fn item_id(&self) -> &str {
364        match &self.item {
365            EntryItem::Pr(pr) => &pr.id,
366            EntryItem::Wi(wi) => &wi.id,
367            EntryItem::Pipe { run, .. } => &run.id,
368        }
369    }
370
371    pub fn title(&self) -> &str {
372        match &self.item {
373            EntryItem::Pr(pr) => &pr.title,
374            EntryItem::Wi(wi) => &wi.title,
375            // What the run was building — see [`pipe_title`].
376            EntryItem::Pipe { run, definition_name } => pipe_title(run, definition_name.as_deref()),
377        }
378    }
379
380    /// Last activity, for the staleness cue + oldest-first ordering.
381    pub fn updated_at(&self) -> Option<DateTime<Utc>> {
382        match &self.item {
383            EntryItem::Pr(pr) => pr.updated_at,
384            EntryItem::Wi(wi) => wi.updated_at,
385            EntryItem::Pipe { run, .. } => run.finished_at.or(run.started_at),
386        }
387    }
388
389    /// Stable identity of the underlying item, used to dismiss it from the Launchpad once you've
390    /// acted on it (e.g. reviewed the PR) even before the next refetch.
391    pub fn key(connection_id: &str, item_id: &str) -> String {
392        format!("{connection_id}:{item_id}")
393    }
394}
395
396/// A pull request plus the connection it came from — one launchpad input row.
397pub struct PrInput {
398    pub connection_id: String,
399    pub connection: String,
400    pub provider: ProviderType,
401    pub pr: PullRequest,
402}
403
404/// A work item plus its connection.
405pub struct WiInput {
406    pub connection_id: String,
407    pub connection: String,
408    pub provider: ProviderType,
409    pub wi: WorkItem,
410}
411
412/// A pipeline run plus its connection and the derived flags the classifier needs.
413pub struct PipeInput {
414    pub connection_id: String,
415    pub connection: String,
416    pub provider: ProviderType,
417    pub run: PipelineRun,
418    pub definition_name: Option<String>,
419    pub awaiting_approval: bool,
420    /// You started this run — see [`run_triggered_by`](crate::filter::run_triggered_by). Only
421    /// your runs are listed or flagged as needing a fix; an approval gate you can respond to
422    /// surfaces whoever started the run, since the run is waiting on you.
423    pub triggered_by_me: bool,
424}
425
426fn bucket_rank(b: Bucket) -> usize {
427    Bucket::ORDER.iter().position(|&x| x == b).unwrap_or(usize::MAX)
428}
429
430/// Sort key: known-and-older sorts before newer, unknown sorts last.
431fn age_key(t: Option<DateTime<Utc>>) -> (u8, i64) {
432    match t {
433        Some(d) => (0, d.timestamp()),
434        None => (1, 0),
435    }
436}
437
438/// Whether a capped reference bucket had more entries than it shows — drives the "more…" affordance.
439#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
440pub struct Overflow {
441    pub needs_review: bool,
442    pub your_work: bool,
443    pub your_open_prs: bool,
444    pub recently_merged: bool,
445    pub recent_pipelines: bool,
446}
447
448/// The built Launchpad: display-ordered, capped rows plus per-bucket overflow flags.
449pub struct Launchpad {
450    pub entries: Vec<Entry>,
451    pub overflow: Overflow,
452}
453
454/// In-progress work items sort before the rest of "Assigned to you" — `Started` is the
455/// provider-neutral in-progress category, so this is correct for every provider.
456fn wi_in_progress_rank(e: &Entry) -> u8 {
457    match &e.item {
458        EntryItem::Wi(wi) if wi.state_category == WorkItemStateCategory::Started => 0,
459        _ => 1,
460    }
461}
462
463/// Builds the Launchpad rows from the aggregated feeds, already sorted into display order:
464/// bucket by urgency, then oldest-activity first within each bucket (newest-first for the recent
465/// reference lists; in-progress-first for "Assigned to you"). Each reference list is capped, with
466/// [`Overflow`] flagging the ones that had more.
467pub fn build(prs_review: &[PrInput], prs_mine: &[PrInput], wis: &[WiInput], pipes: &[PipeInput]) -> Launchpad {
468    let pr_entry = |row: &PrInput, bucket: Bucket| Entry {
469        bucket,
470        connection_id: row.connection_id.clone(),
471        connection: row.connection.clone(),
472        provider: row.provider,
473        item: EntryItem::Pr(row.pr.clone()),
474    };
475    let pipe_entry = |r: &PipeInput, bucket: Bucket| Entry {
476        bucket,
477        connection_id: r.connection_id.clone(),
478        connection: r.connection.clone(),
479        provider: r.provider,
480        item: EntryItem::Pipe { run: r.run.clone(), definition_name: r.definition_name.clone() },
481    };
482
483    let now = Utc::now();
484    let mut out: Vec<Entry> = Vec::new();
485
486    // PRs where you're a requested reviewer → Needs your review.
487    for r in prs_review {
488        if let Some(bucket) = classify_pr(&r.pr, PrRole::Reviewer) {
489            out.push(pr_entry(r, bucket));
490        }
491    }
492    // Your own PRs: an action bucket on the left when there's something to do, the full open-PR
493    // list on the right, and recently-merged ones as a "shipped" footer.
494    for r in prs_mine {
495        match r.pr.status {
496            PullRequestStatus::Merged => {
497                if merged_recently(&r.pr, now) {
498                    out.push(pr_entry(r, Bucket::RecentlyMerged));
499                }
500            }
501            PullRequestStatus::Closed => {} // abandoned — don't surface
502            _ => {
503                if let Some(bucket) = classify_pr(&r.pr, PrRole::Author) {
504                    out.push(pr_entry(r, bucket));
505                }
506                out.push(pr_entry(r, Bucket::YourOpenPrs));
507            }
508        }
509    }
510    // Work items assigned to you → Assigned to you.
511    out.extend(wis.iter().map(|r| Entry {
512        bucket: Bucket::YourWork,
513        connection_id: r.connection_id.clone(),
514        connection: r.connection.clone(),
515        provider: r.provider,
516        item: EntryItem::Wi(r.wi.clone()),
517    }));
518    // Pipelines: a left action bucket when they need you (approval gate / failed), and the
519    // recent-runs reference list on the right. Only runs you started count — other people's
520    // failures aren't yours to fix — except a gate you can approve, which is blocked on you.
521    for r in pipes {
522        match classify_pipe(r.run.status, r.awaiting_approval) {
523            Some(Bucket::ApprovalsWaiting) => out.push(pipe_entry(r, Bucket::ApprovalsWaiting)),
524            Some(bucket) if r.triggered_by_me => out.push(pipe_entry(r, bucket)),
525            _ => {}
526        }
527        if r.triggered_by_me {
528            out.push(pipe_entry(r, Bucket::RecentPipelines));
529        }
530    }
531
532    // Bucket by urgency; within a bucket oldest-first, except the recent reference lists
533    // (recently merged / recent pipelines), which read newest-first, and "Assigned to you", which
534    // puts in-progress items first then newest activity.
535    let newest_first = |b: Bucket| matches!(b, Bucket::RecentlyMerged | Bucket::RecentPipelines);
536    out.sort_by(|a, b| {
537        bucket_rank(a.bucket).cmp(&bucket_rank(b.bucket)).then_with(|| {
538            if a.bucket == Bucket::YourWork {
539                wi_in_progress_rank(a)
540                    .cmp(&wi_in_progress_rank(b))
541                    .then_with(|| age_key(b.updated_at()).cmp(&age_key(a.updated_at())))
542            } else if newest_first(a.bucket) {
543                age_key(b.updated_at()).cmp(&age_key(a.updated_at()))
544            } else {
545                age_key(a.updated_at()).cmp(&age_key(b.updated_at()))
546            }
547        })
548    });
549
550    // Cap the buckets that deep-link to a full page/view, flagging any that had more so a "more…"
551    // link can show. "Needs your review" caps here (it links to the PR Review-requested view); the
552    // other left-column buckets (ready-to-merge / needs-fixing) are left whole so the dashboard can
553    // expand them in place.
554    let total = |bucket: Bucket| out.iter().filter(|e| e.bucket == bucket).count();
555    let overflow = Overflow {
556        needs_review: total(Bucket::NeedsReview) > NEEDS_REVIEW_MAX,
557        your_work: total(Bucket::YourWork) > YOUR_WORK_MAX,
558        your_open_prs: total(Bucket::YourOpenPrs) > YOUR_OPEN_PRS_MAX,
559        recently_merged: total(Bucket::RecentlyMerged) > RECENT_MERGE_MAX,
560        recent_pipelines: total(Bucket::RecentPipelines) > RECENT_PIPELINE_MAX,
561    };
562    let (mut review, mut work, mut open_prs, mut merged, mut pipelines) = (0, 0, 0, 0, 0);
563    out.retain(|e| match e.bucket {
564        Bucket::NeedsReview => {
565            review += 1;
566            review <= NEEDS_REVIEW_MAX
567        }
568        Bucket::YourWork => {
569            work += 1;
570            work <= YOUR_WORK_MAX
571        }
572        Bucket::YourOpenPrs => {
573            open_prs += 1;
574            open_prs <= YOUR_OPEN_PRS_MAX
575        }
576        Bucket::RecentlyMerged => {
577            merged += 1;
578            merged <= RECENT_MERGE_MAX
579        }
580        Bucket::RecentPipelines => {
581            pipelines += 1;
582            pipelines <= RECENT_PIPELINE_MAX
583        }
584        _ => true,
585    });
586    Launchpad { entries: out, overflow }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use crate::domain::*;
593
594    fn user(id: &str) -> User {
595        User { id: id.into(), display_name: id.into(), handle: None, avatar_url: None }
596    }
597
598    fn authored(draft: bool, votes: &[ReviewVote], checks: CheckStatus, mergeable: MergeableState) -> PullRequest {
599        PullRequest {
600            repository: None,
601            id: "1".into(),
602            number: Some(1),
603            title: "t".into(),
604            description: None,
605            author: user("me"),
606            status: PullRequestStatus::Open,
607            is_draft: draft,
608            source_ref: None,
609            target_ref: None,
610            reviewers: votes.iter().map(|v| Reviewer { user: user("r"), vote: *v, is_required: false }).collect(),
611            labels: vec![],
612            checks,
613            check_summary: None,
614            mergeable,
615            changed_files: 0,
616            additions: 0,
617            deletions: 0,
618            created_at: None,
619            updated_at: None,
620            url: None,
621        }
622    }
623
624    fn pipe(status: PipelineRunStatus, awaiting: bool) -> PipeInput {
625        PipeInput {
626            connection_id: "c".into(),
627            connection: "GH".into(),
628            provider: ProviderType::GitHub,
629            definition_name: Some("CI Build".into()),
630            awaiting_approval: awaiting,
631            triggered_by_me: true,
632            run: PipelineRun {
633                event: None,
634                attempt: None,
635                pull_request: None,
636                repository: None,
637                id: "r".into(),
638                definition_id: "ci".into(),
639                number: Some(1),
640                name: Some("CI".into()),
641                title: None,
642                status,
643                triggered_by: None,
644                branch: None,
645                commit_sha: None,
646                started_at: None,
647                finished_at: None,
648                url: None,
649                stages: vec![],
650            },
651        }
652    }
653
654    fn pr_row(id: &str, pr: PullRequest) -> PrInput {
655        let mut pr = pr;
656        pr.id = id.into();
657        PrInput { connection_id: "c".into(), connection: "GH".into(), provider: ProviderType::GitHub, pr }
658    }
659
660    fn wi_row(id: &str, cat: WorkItemStateCategory, updated_h: i64) -> WiInput {
661        let wi = WorkItem {
662            repository: None,
663            id: id.into(),
664            identifier: Some(id.into()),
665            title: "wi".into(),
666            description: None,
667            state: "s".into(),
668            state_category: cat,
669            work_item_type: None,
670            assignee: None,
671            created_at: None,
672            updated_at: Some(Utc::now() - chrono::Duration::hours(updated_h)),
673            url: None,
674        };
675        WiInput { connection_id: "c".into(), connection: "GH".into(), provider: ProviderType::GitHub, wi }
676    }
677
678    #[test]
679    fn reviewer_prs_always_need_review() {
680        let pr = authored(false, &[ReviewVote::Approved], CheckStatus::Passed, MergeableState::Mergeable);
681        assert_eq!(classify_pr(&pr, PrRole::Reviewer), Some(Bucket::NeedsReview));
682    }
683
684    #[test]
685    fn authored_prs_route_by_state() {
686        use Bucket::*;
687        let case = |draft, votes: &[ReviewVote], checks, merge| classify_pr(&authored(draft, votes, checks, merge), PrRole::Author);
688
689        assert_eq!(case(true, &[], CheckStatus::None, MergeableState::Mergeable), None);
690        assert_eq!(case(false, &[], CheckStatus::Passed, MergeableState::Mergeable), None);
691        assert_eq!(case(false, &[ReviewVote::Rejected], CheckStatus::Passed, MergeableState::Mergeable), Some(NeedsFixing));
692        assert_eq!(case(false, &[], CheckStatus::Failed, MergeableState::Mergeable), Some(NeedsFixing));
693        assert_eq!(case(false, &[], CheckStatus::Passed, MergeableState::Conflicting), Some(NeedsFixing));
694        assert_eq!(case(false, &[ReviewVote::Approved], CheckStatus::Passed, MergeableState::Mergeable), Some(ReadyToMerge));
695    }
696
697    #[test]
698    fn pipelines_route_to_approval_or_fixing() {
699        assert_eq!(classify_pipe(PipelineRunStatus::Running, true), Some(Bucket::ApprovalsWaiting));
700        assert_eq!(classify_pipe(PipelineRunStatus::Failed, false), Some(Bucket::NeedsFixing));
701        assert_eq!(classify_pipe(PipelineRunStatus::Succeeded, false), None);
702        assert_eq!(classify_pipe(PipelineRunStatus::Failed, true), Some(Bucket::ApprovalsWaiting));
703    }
704
705    /// Precedence is most-fundamental first, and only the winner shows: a conflict outranks a
706    /// changes-requested review, which outranks red checks.
707    #[test]
708    fn pr_blocker_reports_one_reason_in_precedence_order() {
709        use PrBlocker::*;
710        let case = |draft, votes: &[ReviewVote], checks, merge| pr_blocker(&authored(draft, votes, checks, merge));
711
712        assert_eq!(case(false, &[], CheckStatus::Passed, MergeableState::Mergeable), None);
713        assert_eq!(case(false, &[], CheckStatus::Failed, MergeableState::Mergeable), Some(ChecksFailing));
714        assert_eq!(case(false, &[ReviewVote::Rejected], CheckStatus::Passed, MergeableState::Mergeable), Some(ChangesRequested));
715        assert_eq!(case(false, &[], CheckStatus::Passed, MergeableState::Conflicting), Some(Conflicting));
716        // Every reason at once still names the most fundamental one.
717        assert_eq!(case(false, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Conflicting), Some(Conflicting));
718        assert_eq!(case(false, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Mergeable), Some(ChangesRequested));
719        // A draft isn't asking anything of you yet.
720        assert_eq!(case(true, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Conflicting), None);
721    }
722
723    /// The reason a row *displays* and the reason it was *bucketed* are the same judgement, so
724    /// they can't drift apart: an authored PR is in NeedsFixing exactly when it has a blocker.
725    #[test]
726    fn needs_fixing_agrees_with_pr_blocker() {
727        let votes: [&[ReviewVote]; 3] = [&[], &[ReviewVote::Approved], &[ReviewVote::Rejected]];
728        for draft in [true, false] {
729            for v in votes {
730                for checks in [CheckStatus::None, CheckStatus::Passed, CheckStatus::Failed] {
731                    for merge in [MergeableState::Mergeable, MergeableState::Conflicting, MergeableState::Unknown] {
732                        let pr = authored(draft, v, checks, merge);
733                        assert_eq!(
734                            classify_pr(&pr, PrRole::Author) == Some(Bucket::NeedsFixing),
735                            pr_blocker(&pr).is_some(),
736                            "draft={draft} votes={v:?} checks={checks:?} merge={merge:?}"
737                        );
738                    }
739                }
740            }
741        }
742    }
743
744    /// The run's own title wins (it says what changed); the definition name is the fallback for
745    /// providers that expose no per-run title.
746    #[test]
747    fn pipe_title_prefers_the_run_title() {
748        let mut input = pipe(PipelineRunStatus::Succeeded, false);
749        assert_eq!(pipe_title(&input.run, input.definition_name.as_deref()), "CI Build");
750
751        input.run.title = Some("Bump axum to 0.8".into());
752        assert_eq!(pipe_title(&input.run, input.definition_name.as_deref()), "Bump axum to 0.8");
753
754        // A blank title is as good as absent.
755        input.run.title = Some("   ".into());
756        assert_eq!(pipe_title(&input.run, input.definition_name.as_deref()), "CI Build");
757
758        input.run.title = None;
759        input.definition_name = None;
760        assert_eq!(pipe_title(&input.run, None), "CI");
761        input.run.name = None;
762        assert_eq!(pipe_title(&input.run, None), "ci");
763    }
764
765    #[test]
766    fn pr_state_tests_lifecycle_before_anything_else() {
767        use PrState::*;
768        // A merged PR keeps the conflicting merge state and rejected review it had while open.
769        // Asking what blocks it would give a stale answer, so lifecycle short-circuits first.
770        let stale = |status| {
771            let mut pr = authored(false, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Conflicting);
772            pr.status = status;
773            pr_state(&pr)
774        };
775        assert_eq!(stale(PullRequestStatus::Merged), Merged);
776        assert_eq!(stale(PullRequestStatus::Closed), Closed);
777        assert_eq!(stale(PullRequestStatus::Draft), Draft);
778        // `is_draft` is the same short-circuit for providers that leave `status` as Open.
779        assert_eq!(pr_state(&authored(true, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Conflicting)), Draft);
780    }
781
782    #[test]
783    fn pr_state_reports_the_blocker_then_running_checks_then_clear() {
784        use PrState::*;
785        let case = |votes: &[ReviewVote], checks, merge| pr_state(&authored(false, votes, checks, merge));
786
787        assert_eq!(case(&[], CheckStatus::Passed, MergeableState::Conflicting), Blocked(PrBlocker::Conflicting));
788        assert_eq!(case(&[ReviewVote::Rejected], CheckStatus::Passed, MergeableState::Mergeable), Blocked(PrBlocker::ChangesRequested));
789        assert_eq!(case(&[], CheckStatus::Failed, MergeableState::Mergeable), Blocked(PrBlocker::ChecksFailing));
790        assert_eq!(case(&[], CheckStatus::Pending, MergeableState::Mergeable), ChecksRunning);
791        assert_eq!(case(&[ReviewVote::Approved], CheckStatus::Passed, MergeableState::Mergeable), ReadyToMerge);
792        // Green, as asked: nothing is in the author's way even with nobody having approved.
793        assert_eq!(case(&[], CheckStatus::Passed, MergeableState::Mergeable), NothingBlocking);
794        assert_eq!(case(&[], CheckStatus::None, MergeableState::Unknown), NothingBlocking);
795        // An approval can't outrank a blocker, and unfinished CI can't read as ready.
796        assert_eq!(case(&[ReviewVote::Approved], CheckStatus::Failed, MergeableState::Mergeable), Blocked(PrBlocker::ChecksFailing));
797        assert_eq!(case(&[ReviewVote::Approved], CheckStatus::Pending, MergeableState::Mergeable), ChecksRunning);
798    }
799
800    /// The per-check summary is the authority on whether CI is still going: `CheckStatus` alone
801    /// rounds a half-finished run to Passed or Failed.
802    #[test]
803    fn running_checks_come_from_the_summary_when_there_is_one() {
804        let with = |successful, in_progress, failed| {
805            let mut pr = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
806            pr.check_summary = Some(CheckSummary { successful, in_progress, failed, neutral: 0 });
807            pr_state(&pr)
808        };
809        assert_eq!(with(5, 3, 0), PrState::ChecksRunning);
810        assert_eq!(with(8, 0, 0), PrState::NothingBlocking);
811        // A summary saying "nothing running" beats a stale Pending roll-up.
812        let mut pr = authored(false, &[], CheckStatus::Pending, MergeableState::Mergeable);
813        pr.check_summary = Some(CheckSummary { successful: 8, in_progress: 0, failed: 0, neutral: 0 });
814        assert_eq!(pr_state(&pr), PrState::NothingBlocking);
815    }
816
817    #[test]
818    fn reviewer_lookups_name_the_person_behind_the_verdict() {
819        let mut pr = authored(false, &[ReviewVote::Approved, ReviewVote::Rejected], CheckStatus::Passed, MergeableState::Mergeable);
820        pr.reviewers[0].user = user("alice");
821        pr.reviewers[1].user = user("sam");
822        assert_eq!(pr_changes_requested_by(&pr).map(|u| u.display_name.as_str()), Some("sam"));
823        assert_eq!(pr_approved_by(&pr).map(|u| u.display_name.as_str()), Some("alice"));
824
825        let none = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
826        assert!(pr_changes_requested_by(&none).is_none() && pr_approved_by(&none).is_none());
827    }
828
829    #[test]
830    fn pr_vote_flags_rolls_up_reviewers() {
831        assert_eq!(pr_vote_flags(&authored(false, &[], CheckStatus::None, MergeableState::Mergeable)), (false, false));
832        assert_eq!(pr_vote_flags(&authored(false, &[ReviewVote::Approved], CheckStatus::None, MergeableState::Mergeable)), (true, false));
833        assert_eq!(pr_vote_flags(&authored(false, &[ReviewVote::Rejected], CheckStatus::None, MergeableState::Mergeable)), (false, true));
834    }
835
836    #[test]
837    fn build_lists_only_your_runs() {
838        let theirs = |status, awaiting| PipeInput { triggered_by_me: false, ..pipe(status, awaiting) };
839        let out = build(
840            &[],
841            &[],
842            &[],
843            &[
844                theirs(PipelineRunStatus::Failed, false),
845                theirs(PipelineRunStatus::Succeeded, false),
846                theirs(PipelineRunStatus::Running, true),
847            ],
848        )
849        .entries;
850        let buckets: Vec<Bucket> = out.iter().map(|e| e.bucket).collect();
851        // Someone else's gate you can approve is still waiting on you, but their failures and
852        // their run history aren't yours.
853        assert_eq!(buckets, vec![Bucket::ApprovalsWaiting]);
854    }
855
856    #[test]
857    fn build_lists_every_run_of_yours_in_recent_pipelines() {
858        let out = build(&[], &[], &[], &[pipe(PipelineRunStatus::Failed, false), pipe(PipelineRunStatus::Succeeded, false)]).entries;
859        let buckets: Vec<Bucket> = out.iter().map(|e| e.bucket).collect();
860        assert_eq!(buckets.iter().filter(|&&b| b == Bucket::RecentPipelines).count(), 2);
861        assert!(buckets.contains(&Bucket::NeedsFixing));
862        assert_eq!(buckets.iter().filter(|&&b| b == Bucket::ApprovalsWaiting).count(), 0);
863    }
864
865    #[test]
866    fn bucket_order_is_urgency_first() {
867        assert_eq!(Bucket::ORDER[0], Bucket::NeedsReview);
868        assert!(!Bucket::NeedsReview.muted() && Bucket::RecentlyMerged.muted());
869    }
870
871    #[test]
872    fn build_places_your_prs_in_the_full_list_and_recent_merges() {
873        let now = Utc::now();
874        let mut merged = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
875        merged.status = PullRequestStatus::Merged;
876        merged.updated_at = Some(now - chrono::Duration::days(1));
877        let mut old = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
878        old.status = PullRequestStatus::Merged;
879        old.updated_at = Some(now - chrono::Duration::days(60));
880
881        let mine = vec![
882            pr_row("ready", authored(false, &[ReviewVote::Approved], CheckStatus::Passed, MergeableState::Mergeable)),
883            pr_row("draft", authored(true, &[], CheckStatus::None, MergeableState::Mergeable)),
884            pr_row("merged", merged),
885            pr_row("old", old),
886        ];
887        let out = build(&[], &mine, &[], &[]).entries;
888        let buckets = |id: &str| out.iter().filter(|e| e.item_id() == id).map(|e| e.bucket).collect::<Vec<_>>();
889
890        assert!(buckets("ready").contains(&Bucket::ReadyToMerge) && buckets("ready").contains(&Bucket::YourOpenPrs));
891        assert_eq!(buckets("draft"), vec![Bucket::YourOpenPrs]);
892        assert_eq!(buckets("merged"), vec![Bucket::RecentlyMerged]);
893        assert!(buckets("old").is_empty());
894    }
895
896    #[test]
897    fn your_work_caps_at_five_favours_in_progress_and_flags_overflow() {
898        use WorkItemStateCategory as C;
899        // Four in-progress + three not = seven assigned items (updated_h = hours ago).
900        let wis = vec![
901            wi_row("todo1", C::Unstarted, 1),
902            wi_row("prog1", C::Started, 2),
903            wi_row("prog2", C::Started, 3),
904            wi_row("todo2", C::Backlog, 4),
905            wi_row("prog3", C::Started, 5),
906            wi_row("todo3", C::Unstarted, 6),
907            wi_row("prog4", C::Started, 7),
908        ];
909        let lp = build(&[], &[], &wis, &[]);
910        let work: Vec<&str> = lp.entries.iter().filter(|e| e.bucket == Bucket::YourWork).map(|e| e.item_id()).collect();
911        assert_eq!(work.len(), 5, "capped at five");
912        assert!(lp.overflow.your_work, "seven assigned flags overflow");
913        // In-progress first (newest activity first within the group), then the rest by recency.
914        assert_eq!(&work[..4], &["prog1", "prog2", "prog3", "prog4"], "in-progress items lead");
915        assert_eq!(work[4], "todo1", "then the most recently updated of the rest");
916    }
917
918    #[test]
919    fn needs_review_caps_at_five_and_flags_overflow() {
920        let review: Vec<PrInput> = (0..6)
921            .map(|i| pr_row(&format!("r{i}"), authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable)))
922            .collect();
923        let lp = build(&review, &[], &[], &[]);
924        assert_eq!(lp.entries.iter().filter(|e| e.bucket == Bucket::NeedsReview).count(), 5, "capped at five");
925        assert!(lp.overflow.needs_review, "six review requests flags overflow");
926    }
927
928    #[test]
929    fn your_open_prs_caps_at_five_and_flags_overflow() {
930        let mine: Vec<PrInput> = (0..6)
931            .map(|i| pr_row(&format!("pr{i}"), authored(true, &[], CheckStatus::None, MergeableState::Mergeable)))
932            .collect();
933        let lp = build(&[], &mine, &[], &[]);
934        assert_eq!(lp.entries.iter().filter(|e| e.bucket == Bucket::YourOpenPrs).count(), 5, "capped at five");
935        assert!(lp.overflow.your_open_prs, "six open PRs flags overflow");
936    }
937
938    #[test]
939    fn expand_buckets_are_not_capped() {
940        // "Ready to merge" / "Needs fixing" are revealed in place by the dashboard, so build keeps
941        // them all rather than capping at 5. Seven of your PRs fail CI → seven needs-fixing rows.
942        let mine: Vec<PrInput> = (0..7)
943            .map(|i| pr_row(&format!("pr{i}"), authored(false, &[], CheckStatus::Failed, MergeableState::Mergeable)))
944            .collect();
945        let lp = build(&[], &mine, &[], &[]);
946        assert_eq!(
947            lp.entries.iter().filter(|e| e.bucket == Bucket::NeedsFixing).count(),
948            7,
949            "needs-fixing is returned whole, not capped",
950        );
951    }
952
953    #[test]
954    fn recent_pipelines_cap_at_five_and_flag_overflow() {
955        let pipes: Vec<PipeInput> = (0..6).map(|_| pipe(PipelineRunStatus::Succeeded, false)).collect();
956        let lp = build(&[], &[], &[], &pipes);
957        assert_eq!(lp.entries.iter().filter(|e| e.bucket == Bucket::RecentPipelines).count(), 5, "capped at five");
958        assert!(lp.overflow.recent_pipelines, "six runs flags overflow");
959    }
960
961    #[test]
962    fn merged_recently_respects_the_window() {
963        let now = Utc::now();
964        let mut pr = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
965        pr.status = PullRequestStatus::Merged;
966        pr.updated_at = Some(now - chrono::Duration::days(2));
967        assert!(merged_recently(&pr, now));
968        pr.updated_at = Some(now - chrono::Duration::days(30));
969        assert!(!merged_recently(&pr, now), "old merges drop off");
970        pr.status = PullRequestStatus::Open;
971        pr.updated_at = Some(now);
972        assert!(!merged_recently(&pr, now), "open PRs aren't 'recently merged'");
973    }
974}