1use chrono::{DateTime, Utc};
15
16use crate::domain::{
17 CheckStatus, MergeableState, PipelineRun, PipelineRunStatus, ProviderType, PullRequest, PullRequestStatus,
18 ReviewVote, User, WorkItem, WorkItemStateCategory,
19};
20
21#[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 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 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 pub fn muted(&self) -> bool {
79 matches!(self, Bucket::YourOpenPrs | Bucket::RecentPipelines | Bucket::RecentlyMerged)
80 }
81
82 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum PrRole {
95 Reviewer,
97 Author,
99}
100
101pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum PrBlocker {
120 Conflicting,
122 ChangesRequested,
124 ChecksFailing,
126}
127
128impl PrBlocker {
129 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
139pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum PrState {
170 Merged,
172 Closed,
174 Draft,
176 Blocked(PrBlocker),
178 ChecksRunning,
180 ReadyToMerge,
182 NothingBlocking,
184}
185
186impl PrState {
187 pub fn is_blocked(&self) -> bool {
189 matches!(self, PrState::Blocked(_))
190 }
191}
192
193pub 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
228pub 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
234pub 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
242pub fn classify_pr(pr: &PullRequest, role: PrRole) -> Option<Bucket> {
246 match role {
247 PrRole::Reviewer => Some(Bucket::NeedsReview),
249 PrRole::Author => {
250 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 }
264 }
265 }
266 }
267}
268
269pub 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
285pub 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
291const 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;
298const RECENT_MERGE_DAYS: i64 = 7;
300
301fn 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
306pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub enum EntryKind {
320 Pr,
321 Wi,
322 Pipe,
323}
324
325impl EntryKind {
326 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
336pub enum EntryItem {
339 Pr(PullRequest),
340 Wi(WorkItem),
341 Pipe { run: PipelineRun, definition_name: Option<String> },
342}
343
344pub struct Entry {
346 pub bucket: Bucket,
347 pub connection_id: String,
348 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 EntryItem::Pipe { run, definition_name } => pipe_title(run, definition_name.as_deref()),
377 }
378 }
379
380 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 pub fn key(connection_id: &str, item_id: &str) -> String {
392 format!("{connection_id}:{item_id}")
393 }
394}
395
396pub struct PrInput {
398 pub connection_id: String,
399 pub connection: String,
400 pub provider: ProviderType,
401 pub pr: PullRequest,
402}
403
404pub struct WiInput {
406 pub connection_id: String,
407 pub connection: String,
408 pub provider: ProviderType,
409 pub wi: WorkItem,
410}
411
412pub 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
426fn 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#[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
444pub struct Launchpad {
446 pub entries: Vec<Entry>,
447 pub overflow: Overflow,
448}
449
450fn 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
459pub 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 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 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 => {} _ => {
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 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 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 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 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 #[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 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 assert_eq!(case(true, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Conflicting), None);
711 }
712
713 #[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 #[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 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 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 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 assert_eq!(case(&[], CheckStatus::Passed, MergeableState::Mergeable), NothingBlocking);
784 assert_eq!(case(&[], CheckStatus::None, MergeableState::Unknown), NothingBlocking);
785 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 #[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 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 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 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 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}