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 repository: None,
624 id: "r".into(),
625 definition_id: "ci".into(),
626 number: Some(1),
627 name: Some("CI".into()),
628 title: None,
629 status,
630 triggered_by: None,
631 branch: None,
632 commit_sha: None,
633 started_at: None,
634 finished_at: None,
635 url: None,
636 stages: vec![],
637 },
638 }
639 }
640
641 fn pr_row(id: &str, pr: PullRequest) -> PrInput {
642 let mut pr = pr;
643 pr.id = id.into();
644 PrInput { connection_id: "c".into(), connection: "GH".into(), provider: ProviderType::GitHub, pr }
645 }
646
647 fn wi_row(id: &str, cat: WorkItemStateCategory, updated_h: i64) -> WiInput {
648 let wi = WorkItem {
649 repository: None,
650 id: id.into(),
651 identifier: Some(id.into()),
652 title: "wi".into(),
653 description: None,
654 state: "s".into(),
655 state_category: cat,
656 work_item_type: None,
657 assignee: None,
658 created_at: None,
659 updated_at: Some(Utc::now() - chrono::Duration::hours(updated_h)),
660 url: None,
661 };
662 WiInput { connection_id: "c".into(), connection: "GH".into(), provider: ProviderType::GitHub, wi }
663 }
664
665 #[test]
666 fn reviewer_prs_always_need_review() {
667 let pr = authored(false, &[ReviewVote::Approved], CheckStatus::Passed, MergeableState::Mergeable);
668 assert_eq!(classify_pr(&pr, PrRole::Reviewer), Some(Bucket::NeedsReview));
669 }
670
671 #[test]
672 fn authored_prs_route_by_state() {
673 use Bucket::*;
674 let case = |draft, votes: &[ReviewVote], checks, merge| classify_pr(&authored(draft, votes, checks, merge), PrRole::Author);
675
676 assert_eq!(case(true, &[], CheckStatus::None, MergeableState::Mergeable), None);
677 assert_eq!(case(false, &[], CheckStatus::Passed, MergeableState::Mergeable), None);
678 assert_eq!(case(false, &[ReviewVote::Rejected], CheckStatus::Passed, MergeableState::Mergeable), Some(NeedsFixing));
679 assert_eq!(case(false, &[], CheckStatus::Failed, MergeableState::Mergeable), Some(NeedsFixing));
680 assert_eq!(case(false, &[], CheckStatus::Passed, MergeableState::Conflicting), Some(NeedsFixing));
681 assert_eq!(case(false, &[ReviewVote::Approved], CheckStatus::Passed, MergeableState::Mergeable), Some(ReadyToMerge));
682 }
683
684 #[test]
685 fn pipelines_route_to_approval_or_fixing() {
686 assert_eq!(classify_pipe(PipelineRunStatus::Running, true), Some(Bucket::ApprovalsWaiting));
687 assert_eq!(classify_pipe(PipelineRunStatus::Failed, false), Some(Bucket::NeedsFixing));
688 assert_eq!(classify_pipe(PipelineRunStatus::Succeeded, false), None);
689 assert_eq!(classify_pipe(PipelineRunStatus::Failed, true), Some(Bucket::ApprovalsWaiting));
690 }
691
692 #[test]
695 fn pr_blocker_reports_one_reason_in_precedence_order() {
696 use PrBlocker::*;
697 let case = |draft, votes: &[ReviewVote], checks, merge| pr_blocker(&authored(draft, votes, checks, merge));
698
699 assert_eq!(case(false, &[], CheckStatus::Passed, MergeableState::Mergeable), None);
700 assert_eq!(case(false, &[], CheckStatus::Failed, MergeableState::Mergeable), Some(ChecksFailing));
701 assert_eq!(case(false, &[ReviewVote::Rejected], CheckStatus::Passed, MergeableState::Mergeable), Some(ChangesRequested));
702 assert_eq!(case(false, &[], CheckStatus::Passed, MergeableState::Conflicting), Some(Conflicting));
703 assert_eq!(case(false, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Conflicting), Some(Conflicting));
705 assert_eq!(case(false, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Mergeable), Some(ChangesRequested));
706 assert_eq!(case(true, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Conflicting), None);
708 }
709
710 #[test]
713 fn needs_fixing_agrees_with_pr_blocker() {
714 let votes: [&[ReviewVote]; 3] = [&[], &[ReviewVote::Approved], &[ReviewVote::Rejected]];
715 for draft in [true, false] {
716 for v in votes {
717 for checks in [CheckStatus::None, CheckStatus::Passed, CheckStatus::Failed] {
718 for merge in [MergeableState::Mergeable, MergeableState::Conflicting, MergeableState::Unknown] {
719 let pr = authored(draft, v, checks, merge);
720 assert_eq!(
721 classify_pr(&pr, PrRole::Author) == Some(Bucket::NeedsFixing),
722 pr_blocker(&pr).is_some(),
723 "draft={draft} votes={v:?} checks={checks:?} merge={merge:?}"
724 );
725 }
726 }
727 }
728 }
729 }
730
731 #[test]
734 fn pipe_title_prefers_the_run_title() {
735 let mut input = pipe(PipelineRunStatus::Succeeded, false);
736 assert_eq!(pipe_title(&input.run, input.definition_name.as_deref()), "CI Build");
737
738 input.run.title = Some("Bump axum to 0.8".into());
739 assert_eq!(pipe_title(&input.run, input.definition_name.as_deref()), "Bump axum to 0.8");
740
741 input.run.title = Some(" ".into());
743 assert_eq!(pipe_title(&input.run, input.definition_name.as_deref()), "CI Build");
744
745 input.run.title = None;
746 input.definition_name = None;
747 assert_eq!(pipe_title(&input.run, None), "CI");
748 input.run.name = None;
749 assert_eq!(pipe_title(&input.run, None), "ci");
750 }
751
752 #[test]
753 fn pr_state_tests_lifecycle_before_anything_else() {
754 use PrState::*;
755 let stale = |status| {
758 let mut pr = authored(false, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Conflicting);
759 pr.status = status;
760 pr_state(&pr)
761 };
762 assert_eq!(stale(PullRequestStatus::Merged), Merged);
763 assert_eq!(stale(PullRequestStatus::Closed), Closed);
764 assert_eq!(stale(PullRequestStatus::Draft), Draft);
765 assert_eq!(pr_state(&authored(true, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Conflicting)), Draft);
767 }
768
769 #[test]
770 fn pr_state_reports_the_blocker_then_running_checks_then_clear() {
771 use PrState::*;
772 let case = |votes: &[ReviewVote], checks, merge| pr_state(&authored(false, votes, checks, merge));
773
774 assert_eq!(case(&[], CheckStatus::Passed, MergeableState::Conflicting), Blocked(PrBlocker::Conflicting));
775 assert_eq!(case(&[ReviewVote::Rejected], CheckStatus::Passed, MergeableState::Mergeable), Blocked(PrBlocker::ChangesRequested));
776 assert_eq!(case(&[], CheckStatus::Failed, MergeableState::Mergeable), Blocked(PrBlocker::ChecksFailing));
777 assert_eq!(case(&[], CheckStatus::Pending, MergeableState::Mergeable), ChecksRunning);
778 assert_eq!(case(&[ReviewVote::Approved], CheckStatus::Passed, MergeableState::Mergeable), ReadyToMerge);
779 assert_eq!(case(&[], CheckStatus::Passed, MergeableState::Mergeable), NothingBlocking);
781 assert_eq!(case(&[], CheckStatus::None, MergeableState::Unknown), NothingBlocking);
782 assert_eq!(case(&[ReviewVote::Approved], CheckStatus::Failed, MergeableState::Mergeable), Blocked(PrBlocker::ChecksFailing));
784 assert_eq!(case(&[ReviewVote::Approved], CheckStatus::Pending, MergeableState::Mergeable), ChecksRunning);
785 }
786
787 #[test]
790 fn running_checks_come_from_the_summary_when_there_is_one() {
791 let with = |successful, in_progress, failed| {
792 let mut pr = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
793 pr.check_summary = Some(CheckSummary { successful, in_progress, failed, neutral: 0 });
794 pr_state(&pr)
795 };
796 assert_eq!(with(5, 3, 0), PrState::ChecksRunning);
797 assert_eq!(with(8, 0, 0), PrState::NothingBlocking);
798 let mut pr = authored(false, &[], CheckStatus::Pending, MergeableState::Mergeable);
800 pr.check_summary = Some(CheckSummary { successful: 8, in_progress: 0, failed: 0, neutral: 0 });
801 assert_eq!(pr_state(&pr), PrState::NothingBlocking);
802 }
803
804 #[test]
805 fn reviewer_lookups_name_the_person_behind_the_verdict() {
806 let mut pr = authored(false, &[ReviewVote::Approved, ReviewVote::Rejected], CheckStatus::Passed, MergeableState::Mergeable);
807 pr.reviewers[0].user = user("alice");
808 pr.reviewers[1].user = user("sam");
809 assert_eq!(pr_changes_requested_by(&pr).map(|u| u.display_name.as_str()), Some("sam"));
810 assert_eq!(pr_approved_by(&pr).map(|u| u.display_name.as_str()), Some("alice"));
811
812 let none = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
813 assert!(pr_changes_requested_by(&none).is_none() && pr_approved_by(&none).is_none());
814 }
815
816 #[test]
817 fn pr_vote_flags_rolls_up_reviewers() {
818 assert_eq!(pr_vote_flags(&authored(false, &[], CheckStatus::None, MergeableState::Mergeable)), (false, false));
819 assert_eq!(pr_vote_flags(&authored(false, &[ReviewVote::Approved], CheckStatus::None, MergeableState::Mergeable)), (true, false));
820 assert_eq!(pr_vote_flags(&authored(false, &[ReviewVote::Rejected], CheckStatus::None, MergeableState::Mergeable)), (false, true));
821 }
822
823 #[test]
824 fn build_lists_every_run_in_recent_pipelines() {
825 let out = build(&[], &[], &[], &[pipe(PipelineRunStatus::Failed, false), pipe(PipelineRunStatus::Succeeded, false)]).entries;
826 let buckets: Vec<Bucket> = out.iter().map(|e| e.bucket).collect();
827 assert_eq!(buckets.iter().filter(|&&b| b == Bucket::RecentPipelines).count(), 2);
828 assert!(buckets.contains(&Bucket::NeedsFixing));
829 assert_eq!(buckets.iter().filter(|&&b| b == Bucket::ApprovalsWaiting).count(), 0);
830 }
831
832 #[test]
833 fn bucket_order_is_urgency_first() {
834 assert_eq!(Bucket::ORDER[0], Bucket::NeedsReview);
835 assert!(!Bucket::NeedsReview.muted() && Bucket::RecentlyMerged.muted());
836 }
837
838 #[test]
839 fn build_places_your_prs_in_the_full_list_and_recent_merges() {
840 let now = Utc::now();
841 let mut merged = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
842 merged.status = PullRequestStatus::Merged;
843 merged.updated_at = Some(now - chrono::Duration::days(1));
844 let mut old = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
845 old.status = PullRequestStatus::Merged;
846 old.updated_at = Some(now - chrono::Duration::days(60));
847
848 let mine = vec![
849 pr_row("ready", authored(false, &[ReviewVote::Approved], CheckStatus::Passed, MergeableState::Mergeable)),
850 pr_row("draft", authored(true, &[], CheckStatus::None, MergeableState::Mergeable)),
851 pr_row("merged", merged),
852 pr_row("old", old),
853 ];
854 let out = build(&[], &mine, &[], &[]).entries;
855 let buckets = |id: &str| out.iter().filter(|e| e.item_id() == id).map(|e| e.bucket).collect::<Vec<_>>();
856
857 assert!(buckets("ready").contains(&Bucket::ReadyToMerge) && buckets("ready").contains(&Bucket::YourOpenPrs));
858 assert_eq!(buckets("draft"), vec![Bucket::YourOpenPrs]);
859 assert_eq!(buckets("merged"), vec![Bucket::RecentlyMerged]);
860 assert!(buckets("old").is_empty());
861 }
862
863 #[test]
864 fn your_work_caps_at_five_favours_in_progress_and_flags_overflow() {
865 use WorkItemStateCategory as C;
866 let wis = vec![
868 wi_row("todo1", C::Unstarted, 1),
869 wi_row("prog1", C::Started, 2),
870 wi_row("prog2", C::Started, 3),
871 wi_row("todo2", C::Backlog, 4),
872 wi_row("prog3", C::Started, 5),
873 wi_row("todo3", C::Unstarted, 6),
874 wi_row("prog4", C::Started, 7),
875 ];
876 let lp = build(&[], &[], &wis, &[]);
877 let work: Vec<&str> = lp.entries.iter().filter(|e| e.bucket == Bucket::YourWork).map(|e| e.item_id()).collect();
878 assert_eq!(work.len(), 5, "capped at five");
879 assert!(lp.overflow.your_work, "seven assigned flags overflow");
880 assert_eq!(&work[..4], &["prog1", "prog2", "prog3", "prog4"], "in-progress items lead");
882 assert_eq!(work[4], "todo1", "then the most recently updated of the rest");
883 }
884
885 #[test]
886 fn needs_review_caps_at_five_and_flags_overflow() {
887 let review: Vec<PrInput> = (0..6)
888 .map(|i| pr_row(&format!("r{i}"), authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable)))
889 .collect();
890 let lp = build(&review, &[], &[], &[]);
891 assert_eq!(lp.entries.iter().filter(|e| e.bucket == Bucket::NeedsReview).count(), 5, "capped at five");
892 assert!(lp.overflow.needs_review, "six review requests flags overflow");
893 }
894
895 #[test]
896 fn your_open_prs_caps_at_five_and_flags_overflow() {
897 let mine: Vec<PrInput> = (0..6)
898 .map(|i| pr_row(&format!("pr{i}"), authored(true, &[], CheckStatus::None, MergeableState::Mergeable)))
899 .collect();
900 let lp = build(&[], &mine, &[], &[]);
901 assert_eq!(lp.entries.iter().filter(|e| e.bucket == Bucket::YourOpenPrs).count(), 5, "capped at five");
902 assert!(lp.overflow.your_open_prs, "six open PRs flags overflow");
903 }
904
905 #[test]
906 fn expand_buckets_are_not_capped() {
907 let mine: Vec<PrInput> = (0..7)
910 .map(|i| pr_row(&format!("pr{i}"), authored(false, &[], CheckStatus::Failed, MergeableState::Mergeable)))
911 .collect();
912 let lp = build(&[], &mine, &[], &[]);
913 assert_eq!(
914 lp.entries.iter().filter(|e| e.bucket == Bucket::NeedsFixing).count(),
915 7,
916 "needs-fixing is returned whole, not capped",
917 );
918 }
919
920 #[test]
921 fn recent_pipelines_cap_at_five_and_flag_overflow() {
922 let pipes: Vec<PipeInput> = (0..6).map(|_| pipe(PipelineRunStatus::Succeeded, false)).collect();
923 let lp = build(&[], &[], &[], &pipes);
924 assert_eq!(lp.entries.iter().filter(|e| e.bucket == Bucket::RecentPipelines).count(), 5, "capped at five");
925 assert!(lp.overflow.recent_pipelines, "six runs flags overflow");
926 }
927
928 #[test]
929 fn merged_recently_respects_the_window() {
930 let now = Utc::now();
931 let mut pr = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
932 pr.status = PullRequestStatus::Merged;
933 pr.updated_at = Some(now - chrono::Duration::days(2));
934 assert!(merged_recently(&pr, now));
935 pr.updated_at = Some(now - chrono::Duration::days(30));
936 assert!(!merged_recently(&pr, now), "old merges drop off");
937 pr.status = PullRequestStatus::Open;
938 pr.updated_at = Some(now);
939 assert!(!merged_recently(&pr, now), "open PRs aren't 'recently merged'");
940 }
941}