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 two 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}
421
422fn bucket_rank(b: Bucket) -> usize {
423    Bucket::ORDER.iter().position(|&x| x == b).unwrap_or(usize::MAX)
424}
425
426/// Sort key: known-and-older sorts before newer, unknown sorts last.
427fn age_key(t: Option<DateTime<Utc>>) -> (u8, i64) {
428    match t {
429        Some(d) => (0, d.timestamp()),
430        None => (1, 0),
431    }
432}
433
434/// Whether a capped reference bucket had more entries than it shows — drives the "more…" affordance.
435#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
436pub struct Overflow {
437    pub needs_review: bool,
438    pub your_work: bool,
439    pub your_open_prs: bool,
440    pub recently_merged: bool,
441    pub recent_pipelines: bool,
442}
443
444/// The built Launchpad: display-ordered, capped rows plus per-bucket overflow flags.
445pub struct Launchpad {
446    pub entries: Vec<Entry>,
447    pub overflow: Overflow,
448}
449
450/// In-progress work items sort before the rest of "Assigned to you" — `Started` is the
451/// provider-neutral in-progress category, so this is correct for every provider.
452fn wi_in_progress_rank(e: &Entry) -> u8 {
453    match &e.item {
454        EntryItem::Wi(wi) if wi.state_category == WorkItemStateCategory::Started => 0,
455        _ => 1,
456    }
457}
458
459/// Builds the Launchpad rows from the aggregated feeds, already sorted into display order:
460/// bucket by urgency, then oldest-activity first within each bucket (newest-first for the recent
461/// reference lists; in-progress-first for "Assigned to you"). Each reference list is capped, with
462/// [`Overflow`] flagging the ones that had more.
463pub fn build(prs_review: &[PrInput], prs_mine: &[PrInput], wis: &[WiInput], pipes: &[PipeInput]) -> Launchpad {
464    let pr_entry = |row: &PrInput, bucket: Bucket| Entry {
465        bucket,
466        connection_id: row.connection_id.clone(),
467        connection: row.connection.clone(),
468        provider: row.provider,
469        item: EntryItem::Pr(row.pr.clone()),
470    };
471    let pipe_entry = |r: &PipeInput, bucket: Bucket| Entry {
472        bucket,
473        connection_id: r.connection_id.clone(),
474        connection: r.connection.clone(),
475        provider: r.provider,
476        item: EntryItem::Pipe { run: r.run.clone(), definition_name: r.definition_name.clone() },
477    };
478
479    let now = Utc::now();
480    let mut out: Vec<Entry> = Vec::new();
481
482    // PRs where you're a requested reviewer → Needs your review.
483    for r in prs_review {
484        if let Some(bucket) = classify_pr(&r.pr, PrRole::Reviewer) {
485            out.push(pr_entry(r, bucket));
486        }
487    }
488    // Your own PRs: an action bucket on the left when there's something to do, the full open-PR
489    // list on the right, and recently-merged ones as a "shipped" footer.
490    for r in prs_mine {
491        match r.pr.status {
492            PullRequestStatus::Merged => {
493                if merged_recently(&r.pr, now) {
494                    out.push(pr_entry(r, Bucket::RecentlyMerged));
495                }
496            }
497            PullRequestStatus::Closed => {} // abandoned — don't surface
498            _ => {
499                if let Some(bucket) = classify_pr(&r.pr, PrRole::Author) {
500                    out.push(pr_entry(r, bucket));
501                }
502                out.push(pr_entry(r, Bucket::YourOpenPrs));
503            }
504        }
505    }
506    // Work items assigned to you → Assigned to you.
507    out.extend(wis.iter().map(|r| Entry {
508        bucket: Bucket::YourWork,
509        connection_id: r.connection_id.clone(),
510        connection: r.connection.clone(),
511        provider: r.provider,
512        item: EntryItem::Wi(r.wi.clone()),
513    }));
514    // Pipelines: a left action bucket when they need you (approval gate / failed), and the
515    // recent-runs reference list on the right.
516    for r in pipes {
517        if let Some(bucket) = classify_pipe(r.run.status, r.awaiting_approval) {
518            out.push(pipe_entry(r, bucket));
519        }
520        out.push(pipe_entry(r, Bucket::RecentPipelines));
521    }
522
523    // Bucket by urgency; within a bucket oldest-first, except the recent reference lists
524    // (recently merged / recent pipelines), which read newest-first, and "Assigned to you", which
525    // puts in-progress items first then newest activity.
526    let newest_first = |b: Bucket| matches!(b, Bucket::RecentlyMerged | Bucket::RecentPipelines);
527    out.sort_by(|a, b| {
528        bucket_rank(a.bucket).cmp(&bucket_rank(b.bucket)).then_with(|| {
529            if a.bucket == Bucket::YourWork {
530                wi_in_progress_rank(a)
531                    .cmp(&wi_in_progress_rank(b))
532                    .then_with(|| age_key(b.updated_at()).cmp(&age_key(a.updated_at())))
533            } else if newest_first(a.bucket) {
534                age_key(b.updated_at()).cmp(&age_key(a.updated_at()))
535            } else {
536                age_key(a.updated_at()).cmp(&age_key(b.updated_at()))
537            }
538        })
539    });
540
541    // Cap the buckets that deep-link to a full page/view, flagging any that had more so a "more…"
542    // link can show. "Needs your review" caps here (it links to the PR Review-requested view); the
543    // other left-column buckets (ready-to-merge / needs-fixing) are left whole so the dashboard can
544    // expand them in place.
545    let total = |bucket: Bucket| out.iter().filter(|e| e.bucket == bucket).count();
546    let overflow = Overflow {
547        needs_review: total(Bucket::NeedsReview) > NEEDS_REVIEW_MAX,
548        your_work: total(Bucket::YourWork) > YOUR_WORK_MAX,
549        your_open_prs: total(Bucket::YourOpenPrs) > YOUR_OPEN_PRS_MAX,
550        recently_merged: total(Bucket::RecentlyMerged) > RECENT_MERGE_MAX,
551        recent_pipelines: total(Bucket::RecentPipelines) > RECENT_PIPELINE_MAX,
552    };
553    let (mut review, mut work, mut open_prs, mut merged, mut pipelines) = (0, 0, 0, 0, 0);
554    out.retain(|e| match e.bucket {
555        Bucket::NeedsReview => {
556            review += 1;
557            review <= NEEDS_REVIEW_MAX
558        }
559        Bucket::YourWork => {
560            work += 1;
561            work <= YOUR_WORK_MAX
562        }
563        Bucket::YourOpenPrs => {
564            open_prs += 1;
565            open_prs <= YOUR_OPEN_PRS_MAX
566        }
567        Bucket::RecentlyMerged => {
568            merged += 1;
569            merged <= RECENT_MERGE_MAX
570        }
571        Bucket::RecentPipelines => {
572            pipelines += 1;
573            pipelines <= RECENT_PIPELINE_MAX
574        }
575        _ => true,
576    });
577    Launchpad { entries: out, overflow }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use crate::domain::*;
584
585    fn user(id: &str) -> User {
586        User { id: id.into(), display_name: id.into(), handle: None, avatar_url: None }
587    }
588
589    fn authored(draft: bool, votes: &[ReviewVote], checks: CheckStatus, mergeable: MergeableState) -> PullRequest {
590        PullRequest {
591            repository: None,
592            id: "1".into(),
593            number: Some(1),
594            title: "t".into(),
595            description: None,
596            author: user("me"),
597            status: PullRequestStatus::Open,
598            is_draft: draft,
599            source_ref: None,
600            target_ref: None,
601            reviewers: votes.iter().map(|v| Reviewer { user: user("r"), vote: *v, is_required: false }).collect(),
602            labels: vec![],
603            checks,
604            check_summary: None,
605            mergeable,
606            changed_files: 0,
607            additions: 0,
608            deletions: 0,
609            created_at: None,
610            updated_at: None,
611            url: None,
612        }
613    }
614
615    fn pipe(status: PipelineRunStatus, awaiting: bool) -> PipeInput {
616        PipeInput {
617            connection_id: "c".into(),
618            connection: "GH".into(),
619            provider: ProviderType::GitHub,
620            definition_name: Some("CI Build".into()),
621            awaiting_approval: awaiting,
622            run: PipelineRun {
623                event: None,
624                attempt: None,
625                pull_request: None,
626                repository: None,
627                id: "r".into(),
628                definition_id: "ci".into(),
629                number: Some(1),
630                name: Some("CI".into()),
631                title: None,
632                status,
633                triggered_by: None,
634                branch: None,
635                commit_sha: None,
636                started_at: None,
637                finished_at: None,
638                url: None,
639                stages: vec![],
640            },
641        }
642    }
643
644    fn pr_row(id: &str, pr: PullRequest) -> PrInput {
645        let mut pr = pr;
646        pr.id = id.into();
647        PrInput { connection_id: "c".into(), connection: "GH".into(), provider: ProviderType::GitHub, pr }
648    }
649
650    fn wi_row(id: &str, cat: WorkItemStateCategory, updated_h: i64) -> WiInput {
651        let wi = WorkItem {
652            repository: None,
653            id: id.into(),
654            identifier: Some(id.into()),
655            title: "wi".into(),
656            description: None,
657            state: "s".into(),
658            state_category: cat,
659            work_item_type: None,
660            assignee: None,
661            created_at: None,
662            updated_at: Some(Utc::now() - chrono::Duration::hours(updated_h)),
663            url: None,
664        };
665        WiInput { connection_id: "c".into(), connection: "GH".into(), provider: ProviderType::GitHub, wi }
666    }
667
668    #[test]
669    fn reviewer_prs_always_need_review() {
670        let pr = authored(false, &[ReviewVote::Approved], CheckStatus::Passed, MergeableState::Mergeable);
671        assert_eq!(classify_pr(&pr, PrRole::Reviewer), Some(Bucket::NeedsReview));
672    }
673
674    #[test]
675    fn authored_prs_route_by_state() {
676        use Bucket::*;
677        let case = |draft, votes: &[ReviewVote], checks, merge| classify_pr(&authored(draft, votes, checks, merge), PrRole::Author);
678
679        assert_eq!(case(true, &[], CheckStatus::None, MergeableState::Mergeable), None);
680        assert_eq!(case(false, &[], CheckStatus::Passed, MergeableState::Mergeable), None);
681        assert_eq!(case(false, &[ReviewVote::Rejected], CheckStatus::Passed, MergeableState::Mergeable), Some(NeedsFixing));
682        assert_eq!(case(false, &[], CheckStatus::Failed, MergeableState::Mergeable), Some(NeedsFixing));
683        assert_eq!(case(false, &[], CheckStatus::Passed, MergeableState::Conflicting), Some(NeedsFixing));
684        assert_eq!(case(false, &[ReviewVote::Approved], CheckStatus::Passed, MergeableState::Mergeable), Some(ReadyToMerge));
685    }
686
687    #[test]
688    fn pipelines_route_to_approval_or_fixing() {
689        assert_eq!(classify_pipe(PipelineRunStatus::Running, true), Some(Bucket::ApprovalsWaiting));
690        assert_eq!(classify_pipe(PipelineRunStatus::Failed, false), Some(Bucket::NeedsFixing));
691        assert_eq!(classify_pipe(PipelineRunStatus::Succeeded, false), None);
692        assert_eq!(classify_pipe(PipelineRunStatus::Failed, true), Some(Bucket::ApprovalsWaiting));
693    }
694
695    /// Precedence is most-fundamental first, and only the winner shows: a conflict outranks a
696    /// changes-requested review, which outranks red checks.
697    #[test]
698    fn pr_blocker_reports_one_reason_in_precedence_order() {
699        use PrBlocker::*;
700        let case = |draft, votes: &[ReviewVote], checks, merge| pr_blocker(&authored(draft, votes, checks, merge));
701
702        assert_eq!(case(false, &[], CheckStatus::Passed, MergeableState::Mergeable), None);
703        assert_eq!(case(false, &[], CheckStatus::Failed, MergeableState::Mergeable), Some(ChecksFailing));
704        assert_eq!(case(false, &[ReviewVote::Rejected], CheckStatus::Passed, MergeableState::Mergeable), Some(ChangesRequested));
705        assert_eq!(case(false, &[], CheckStatus::Passed, MergeableState::Conflicting), Some(Conflicting));
706        // Every reason at once still names the most fundamental one.
707        assert_eq!(case(false, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Conflicting), Some(Conflicting));
708        assert_eq!(case(false, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Mergeable), Some(ChangesRequested));
709        // A draft isn't asking anything of you yet.
710        assert_eq!(case(true, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Conflicting), None);
711    }
712
713    /// The reason a row *displays* and the reason it was *bucketed* are the same judgement, so
714    /// they can't drift apart: an authored PR is in NeedsFixing exactly when it has a blocker.
715    #[test]
716    fn needs_fixing_agrees_with_pr_blocker() {
717        let votes: [&[ReviewVote]; 3] = [&[], &[ReviewVote::Approved], &[ReviewVote::Rejected]];
718        for draft in [true, false] {
719            for v in votes {
720                for checks in [CheckStatus::None, CheckStatus::Passed, CheckStatus::Failed] {
721                    for merge in [MergeableState::Mergeable, MergeableState::Conflicting, MergeableState::Unknown] {
722                        let pr = authored(draft, v, checks, merge);
723                        assert_eq!(
724                            classify_pr(&pr, PrRole::Author) == Some(Bucket::NeedsFixing),
725                            pr_blocker(&pr).is_some(),
726                            "draft={draft} votes={v:?} checks={checks:?} merge={merge:?}"
727                        );
728                    }
729                }
730            }
731        }
732    }
733
734    /// The run's own title wins (it says what changed); the definition name is the fallback for
735    /// providers that expose no per-run title.
736    #[test]
737    fn pipe_title_prefers_the_run_title() {
738        let mut input = pipe(PipelineRunStatus::Succeeded, false);
739        assert_eq!(pipe_title(&input.run, input.definition_name.as_deref()), "CI Build");
740
741        input.run.title = Some("Bump axum to 0.8".into());
742        assert_eq!(pipe_title(&input.run, input.definition_name.as_deref()), "Bump axum to 0.8");
743
744        // A blank title is as good as absent.
745        input.run.title = Some("   ".into());
746        assert_eq!(pipe_title(&input.run, input.definition_name.as_deref()), "CI Build");
747
748        input.run.title = None;
749        input.definition_name = None;
750        assert_eq!(pipe_title(&input.run, None), "CI");
751        input.run.name = None;
752        assert_eq!(pipe_title(&input.run, None), "ci");
753    }
754
755    #[test]
756    fn pr_state_tests_lifecycle_before_anything_else() {
757        use PrState::*;
758        // A merged PR keeps the conflicting merge state and rejected review it had while open.
759        // Asking what blocks it would give a stale answer, so lifecycle short-circuits first.
760        let stale = |status| {
761            let mut pr = authored(false, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Conflicting);
762            pr.status = status;
763            pr_state(&pr)
764        };
765        assert_eq!(stale(PullRequestStatus::Merged), Merged);
766        assert_eq!(stale(PullRequestStatus::Closed), Closed);
767        assert_eq!(stale(PullRequestStatus::Draft), Draft);
768        // `is_draft` is the same short-circuit for providers that leave `status` as Open.
769        assert_eq!(pr_state(&authored(true, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Conflicting)), Draft);
770    }
771
772    #[test]
773    fn pr_state_reports_the_blocker_then_running_checks_then_clear() {
774        use PrState::*;
775        let case = |votes: &[ReviewVote], checks, merge| pr_state(&authored(false, votes, checks, merge));
776
777        assert_eq!(case(&[], CheckStatus::Passed, MergeableState::Conflicting), Blocked(PrBlocker::Conflicting));
778        assert_eq!(case(&[ReviewVote::Rejected], CheckStatus::Passed, MergeableState::Mergeable), Blocked(PrBlocker::ChangesRequested));
779        assert_eq!(case(&[], CheckStatus::Failed, MergeableState::Mergeable), Blocked(PrBlocker::ChecksFailing));
780        assert_eq!(case(&[], CheckStatus::Pending, MergeableState::Mergeable), ChecksRunning);
781        assert_eq!(case(&[ReviewVote::Approved], CheckStatus::Passed, MergeableState::Mergeable), ReadyToMerge);
782        // Green, as asked: nothing is in the author's way even with nobody having approved.
783        assert_eq!(case(&[], CheckStatus::Passed, MergeableState::Mergeable), NothingBlocking);
784        assert_eq!(case(&[], CheckStatus::None, MergeableState::Unknown), NothingBlocking);
785        // An approval can't outrank a blocker, and unfinished CI can't read as ready.
786        assert_eq!(case(&[ReviewVote::Approved], CheckStatus::Failed, MergeableState::Mergeable), Blocked(PrBlocker::ChecksFailing));
787        assert_eq!(case(&[ReviewVote::Approved], CheckStatus::Pending, MergeableState::Mergeable), ChecksRunning);
788    }
789
790    /// The per-check summary is the authority on whether CI is still going: `CheckStatus` alone
791    /// rounds a half-finished run to Passed or Failed.
792    #[test]
793    fn running_checks_come_from_the_summary_when_there_is_one() {
794        let with = |successful, in_progress, failed| {
795            let mut pr = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
796            pr.check_summary = Some(CheckSummary { successful, in_progress, failed, neutral: 0 });
797            pr_state(&pr)
798        };
799        assert_eq!(with(5, 3, 0), PrState::ChecksRunning);
800        assert_eq!(with(8, 0, 0), PrState::NothingBlocking);
801        // A summary saying "nothing running" beats a stale Pending roll-up.
802        let mut pr = authored(false, &[], CheckStatus::Pending, MergeableState::Mergeable);
803        pr.check_summary = Some(CheckSummary { successful: 8, in_progress: 0, failed: 0, neutral: 0 });
804        assert_eq!(pr_state(&pr), PrState::NothingBlocking);
805    }
806
807    #[test]
808    fn reviewer_lookups_name_the_person_behind_the_verdict() {
809        let mut pr = authored(false, &[ReviewVote::Approved, ReviewVote::Rejected], CheckStatus::Passed, MergeableState::Mergeable);
810        pr.reviewers[0].user = user("alice");
811        pr.reviewers[1].user = user("sam");
812        assert_eq!(pr_changes_requested_by(&pr).map(|u| u.display_name.as_str()), Some("sam"));
813        assert_eq!(pr_approved_by(&pr).map(|u| u.display_name.as_str()), Some("alice"));
814
815        let none = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
816        assert!(pr_changes_requested_by(&none).is_none() && pr_approved_by(&none).is_none());
817    }
818
819    #[test]
820    fn pr_vote_flags_rolls_up_reviewers() {
821        assert_eq!(pr_vote_flags(&authored(false, &[], CheckStatus::None, MergeableState::Mergeable)), (false, false));
822        assert_eq!(pr_vote_flags(&authored(false, &[ReviewVote::Approved], CheckStatus::None, MergeableState::Mergeable)), (true, false));
823        assert_eq!(pr_vote_flags(&authored(false, &[ReviewVote::Rejected], CheckStatus::None, MergeableState::Mergeable)), (false, true));
824    }
825
826    #[test]
827    fn build_lists_every_run_in_recent_pipelines() {
828        let out = build(&[], &[], &[], &[pipe(PipelineRunStatus::Failed, false), pipe(PipelineRunStatus::Succeeded, false)]).entries;
829        let buckets: Vec<Bucket> = out.iter().map(|e| e.bucket).collect();
830        assert_eq!(buckets.iter().filter(|&&b| b == Bucket::RecentPipelines).count(), 2);
831        assert!(buckets.contains(&Bucket::NeedsFixing));
832        assert_eq!(buckets.iter().filter(|&&b| b == Bucket::ApprovalsWaiting).count(), 0);
833    }
834
835    #[test]
836    fn bucket_order_is_urgency_first() {
837        assert_eq!(Bucket::ORDER[0], Bucket::NeedsReview);
838        assert!(!Bucket::NeedsReview.muted() && Bucket::RecentlyMerged.muted());
839    }
840
841    #[test]
842    fn build_places_your_prs_in_the_full_list_and_recent_merges() {
843        let now = Utc::now();
844        let mut merged = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
845        merged.status = PullRequestStatus::Merged;
846        merged.updated_at = Some(now - chrono::Duration::days(1));
847        let mut old = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
848        old.status = PullRequestStatus::Merged;
849        old.updated_at = Some(now - chrono::Duration::days(60));
850
851        let mine = vec![
852            pr_row("ready", authored(false, &[ReviewVote::Approved], CheckStatus::Passed, MergeableState::Mergeable)),
853            pr_row("draft", authored(true, &[], CheckStatus::None, MergeableState::Mergeable)),
854            pr_row("merged", merged),
855            pr_row("old", old),
856        ];
857        let out = build(&[], &mine, &[], &[]).entries;
858        let buckets = |id: &str| out.iter().filter(|e| e.item_id() == id).map(|e| e.bucket).collect::<Vec<_>>();
859
860        assert!(buckets("ready").contains(&Bucket::ReadyToMerge) && buckets("ready").contains(&Bucket::YourOpenPrs));
861        assert_eq!(buckets("draft"), vec![Bucket::YourOpenPrs]);
862        assert_eq!(buckets("merged"), vec![Bucket::RecentlyMerged]);
863        assert!(buckets("old").is_empty());
864    }
865
866    #[test]
867    fn your_work_caps_at_five_favours_in_progress_and_flags_overflow() {
868        use WorkItemStateCategory as C;
869        // Four in-progress + three not = seven assigned items (updated_h = hours ago).
870        let wis = vec![
871            wi_row("todo1", C::Unstarted, 1),
872            wi_row("prog1", C::Started, 2),
873            wi_row("prog2", C::Started, 3),
874            wi_row("todo2", C::Backlog, 4),
875            wi_row("prog3", C::Started, 5),
876            wi_row("todo3", C::Unstarted, 6),
877            wi_row("prog4", C::Started, 7),
878        ];
879        let lp = build(&[], &[], &wis, &[]);
880        let work: Vec<&str> = lp.entries.iter().filter(|e| e.bucket == Bucket::YourWork).map(|e| e.item_id()).collect();
881        assert_eq!(work.len(), 5, "capped at five");
882        assert!(lp.overflow.your_work, "seven assigned flags overflow");
883        // In-progress first (newest activity first within the group), then the rest by recency.
884        assert_eq!(&work[..4], &["prog1", "prog2", "prog3", "prog4"], "in-progress items lead");
885        assert_eq!(work[4], "todo1", "then the most recently updated of the rest");
886    }
887
888    #[test]
889    fn needs_review_caps_at_five_and_flags_overflow() {
890        let review: Vec<PrInput> = (0..6)
891            .map(|i| pr_row(&format!("r{i}"), authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable)))
892            .collect();
893        let lp = build(&review, &[], &[], &[]);
894        assert_eq!(lp.entries.iter().filter(|e| e.bucket == Bucket::NeedsReview).count(), 5, "capped at five");
895        assert!(lp.overflow.needs_review, "six review requests flags overflow");
896    }
897
898    #[test]
899    fn your_open_prs_caps_at_five_and_flags_overflow() {
900        let mine: Vec<PrInput> = (0..6)
901            .map(|i| pr_row(&format!("pr{i}"), authored(true, &[], CheckStatus::None, MergeableState::Mergeable)))
902            .collect();
903        let lp = build(&[], &mine, &[], &[]);
904        assert_eq!(lp.entries.iter().filter(|e| e.bucket == Bucket::YourOpenPrs).count(), 5, "capped at five");
905        assert!(lp.overflow.your_open_prs, "six open PRs flags overflow");
906    }
907
908    #[test]
909    fn expand_buckets_are_not_capped() {
910        // "Ready to merge" / "Needs fixing" are revealed in place by the dashboard, so build keeps
911        // them all rather than capping at 5. Seven of your PRs fail CI → seven needs-fixing rows.
912        let mine: Vec<PrInput> = (0..7)
913            .map(|i| pr_row(&format!("pr{i}"), authored(false, &[], CheckStatus::Failed, MergeableState::Mergeable)))
914            .collect();
915        let lp = build(&[], &mine, &[], &[]);
916        assert_eq!(
917            lp.entries.iter().filter(|e| e.bucket == Bucket::NeedsFixing).count(),
918            7,
919            "needs-fixing is returned whole, not capped",
920        );
921    }
922
923    #[test]
924    fn recent_pipelines_cap_at_five_and_flag_overflow() {
925        let pipes: Vec<PipeInput> = (0..6).map(|_| pipe(PipelineRunStatus::Succeeded, false)).collect();
926        let lp = build(&[], &[], &[], &pipes);
927        assert_eq!(lp.entries.iter().filter(|e| e.bucket == Bucket::RecentPipelines).count(), 5, "capped at five");
928        assert!(lp.overflow.recent_pipelines, "six runs flags overflow");
929    }
930
931    #[test]
932    fn merged_recently_respects_the_window() {
933        let now = Utc::now();
934        let mut pr = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
935        pr.status = PullRequestStatus::Merged;
936        pr.updated_at = Some(now - chrono::Duration::days(2));
937        assert!(merged_recently(&pr, now));
938        pr.updated_at = Some(now - chrono::Duration::days(30));
939        assert!(!merged_recently(&pr, now), "old merges drop off");
940        pr.status = PullRequestStatus::Open;
941        pr.updated_at = Some(now);
942        assert!(!merged_recently(&pr, now), "open PRs aren't 'recently merged'");
943    }
944}