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 pub triggered_by_me: bool,
424}
425
426fn bucket_rank(b: Bucket) -> usize {
427 Bucket::ORDER.iter().position(|&x| x == b).unwrap_or(usize::MAX)
428}
429
430fn age_key(t: Option<DateTime<Utc>>) -> (u8, i64) {
432 match t {
433 Some(d) => (0, d.timestamp()),
434 None => (1, 0),
435 }
436}
437
438#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
440pub struct Overflow {
441 pub needs_review: bool,
442 pub your_work: bool,
443 pub your_open_prs: bool,
444 pub recently_merged: bool,
445 pub recent_pipelines: bool,
446}
447
448pub struct Launchpad {
450 pub entries: Vec<Entry>,
451 pub overflow: Overflow,
452}
453
454fn wi_in_progress_rank(e: &Entry) -> u8 {
457 match &e.item {
458 EntryItem::Wi(wi) if wi.state_category == WorkItemStateCategory::Started => 0,
459 _ => 1,
460 }
461}
462
463pub fn build(prs_review: &[PrInput], prs_mine: &[PrInput], wis: &[WiInput], pipes: &[PipeInput]) -> Launchpad {
468 let pr_entry = |row: &PrInput, bucket: Bucket| Entry {
469 bucket,
470 connection_id: row.connection_id.clone(),
471 connection: row.connection.clone(),
472 provider: row.provider,
473 item: EntryItem::Pr(row.pr.clone()),
474 };
475 let pipe_entry = |r: &PipeInput, bucket: Bucket| Entry {
476 bucket,
477 connection_id: r.connection_id.clone(),
478 connection: r.connection.clone(),
479 provider: r.provider,
480 item: EntryItem::Pipe { run: r.run.clone(), definition_name: r.definition_name.clone() },
481 };
482
483 let now = Utc::now();
484 let mut out: Vec<Entry> = Vec::new();
485
486 for r in prs_review {
488 if let Some(bucket) = classify_pr(&r.pr, PrRole::Reviewer) {
489 out.push(pr_entry(r, bucket));
490 }
491 }
492 for r in prs_mine {
495 match r.pr.status {
496 PullRequestStatus::Merged => {
497 if merged_recently(&r.pr, now) {
498 out.push(pr_entry(r, Bucket::RecentlyMerged));
499 }
500 }
501 PullRequestStatus::Closed => {} _ => {
503 if let Some(bucket) = classify_pr(&r.pr, PrRole::Author) {
504 out.push(pr_entry(r, bucket));
505 }
506 out.push(pr_entry(r, Bucket::YourOpenPrs));
507 }
508 }
509 }
510 out.extend(wis.iter().map(|r| Entry {
512 bucket: Bucket::YourWork,
513 connection_id: r.connection_id.clone(),
514 connection: r.connection.clone(),
515 provider: r.provider,
516 item: EntryItem::Wi(r.wi.clone()),
517 }));
518 for r in pipes {
522 match classify_pipe(r.run.status, r.awaiting_approval) {
523 Some(Bucket::ApprovalsWaiting) => out.push(pipe_entry(r, Bucket::ApprovalsWaiting)),
524 Some(bucket) if r.triggered_by_me => out.push(pipe_entry(r, bucket)),
525 _ => {}
526 }
527 if r.triggered_by_me {
528 out.push(pipe_entry(r, Bucket::RecentPipelines));
529 }
530 }
531
532 let newest_first = |b: Bucket| matches!(b, Bucket::RecentlyMerged | Bucket::RecentPipelines);
536 out.sort_by(|a, b| {
537 bucket_rank(a.bucket).cmp(&bucket_rank(b.bucket)).then_with(|| {
538 if a.bucket == Bucket::YourWork {
539 wi_in_progress_rank(a)
540 .cmp(&wi_in_progress_rank(b))
541 .then_with(|| age_key(b.updated_at()).cmp(&age_key(a.updated_at())))
542 } else if newest_first(a.bucket) {
543 age_key(b.updated_at()).cmp(&age_key(a.updated_at()))
544 } else {
545 age_key(a.updated_at()).cmp(&age_key(b.updated_at()))
546 }
547 })
548 });
549
550 let total = |bucket: Bucket| out.iter().filter(|e| e.bucket == bucket).count();
555 let overflow = Overflow {
556 needs_review: total(Bucket::NeedsReview) > NEEDS_REVIEW_MAX,
557 your_work: total(Bucket::YourWork) > YOUR_WORK_MAX,
558 your_open_prs: total(Bucket::YourOpenPrs) > YOUR_OPEN_PRS_MAX,
559 recently_merged: total(Bucket::RecentlyMerged) > RECENT_MERGE_MAX,
560 recent_pipelines: total(Bucket::RecentPipelines) > RECENT_PIPELINE_MAX,
561 };
562 let (mut review, mut work, mut open_prs, mut merged, mut pipelines) = (0, 0, 0, 0, 0);
563 out.retain(|e| match e.bucket {
564 Bucket::NeedsReview => {
565 review += 1;
566 review <= NEEDS_REVIEW_MAX
567 }
568 Bucket::YourWork => {
569 work += 1;
570 work <= YOUR_WORK_MAX
571 }
572 Bucket::YourOpenPrs => {
573 open_prs += 1;
574 open_prs <= YOUR_OPEN_PRS_MAX
575 }
576 Bucket::RecentlyMerged => {
577 merged += 1;
578 merged <= RECENT_MERGE_MAX
579 }
580 Bucket::RecentPipelines => {
581 pipelines += 1;
582 pipelines <= RECENT_PIPELINE_MAX
583 }
584 _ => true,
585 });
586 Launchpad { entries: out, overflow }
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use crate::domain::*;
593
594 fn user(id: &str) -> User {
595 User { id: id.into(), display_name: id.into(), handle: None, avatar_url: None }
596 }
597
598 fn authored(draft: bool, votes: &[ReviewVote], checks: CheckStatus, mergeable: MergeableState) -> PullRequest {
599 PullRequest {
600 repository: None,
601 id: "1".into(),
602 number: Some(1),
603 title: "t".into(),
604 description: None,
605 author: user("me"),
606 status: PullRequestStatus::Open,
607 is_draft: draft,
608 source_ref: None,
609 target_ref: None,
610 reviewers: votes.iter().map(|v| Reviewer { user: user("r"), vote: *v, is_required: false }).collect(),
611 labels: vec![],
612 checks,
613 check_summary: None,
614 mergeable,
615 changed_files: 0,
616 additions: 0,
617 deletions: 0,
618 created_at: None,
619 updated_at: None,
620 url: None,
621 }
622 }
623
624 fn pipe(status: PipelineRunStatus, awaiting: bool) -> PipeInput {
625 PipeInput {
626 connection_id: "c".into(),
627 connection: "GH".into(),
628 provider: ProviderType::GitHub,
629 definition_name: Some("CI Build".into()),
630 awaiting_approval: awaiting,
631 triggered_by_me: true,
632 run: PipelineRun {
633 event: None,
634 attempt: None,
635 pull_request: None,
636 repository: None,
637 id: "r".into(),
638 definition_id: "ci".into(),
639 number: Some(1),
640 name: Some("CI".into()),
641 title: None,
642 status,
643 triggered_by: None,
644 branch: None,
645 commit_sha: None,
646 started_at: None,
647 finished_at: None,
648 url: None,
649 stages: vec![],
650 },
651 }
652 }
653
654 fn pr_row(id: &str, pr: PullRequest) -> PrInput {
655 let mut pr = pr;
656 pr.id = id.into();
657 PrInput { connection_id: "c".into(), connection: "GH".into(), provider: ProviderType::GitHub, pr }
658 }
659
660 fn wi_row(id: &str, cat: WorkItemStateCategory, updated_h: i64) -> WiInput {
661 let wi = WorkItem {
662 repository: None,
663 id: id.into(),
664 identifier: Some(id.into()),
665 title: "wi".into(),
666 description: None,
667 state: "s".into(),
668 state_category: cat,
669 work_item_type: None,
670 assignee: None,
671 created_at: None,
672 updated_at: Some(Utc::now() - chrono::Duration::hours(updated_h)),
673 url: None,
674 };
675 WiInput { connection_id: "c".into(), connection: "GH".into(), provider: ProviderType::GitHub, wi }
676 }
677
678 #[test]
679 fn reviewer_prs_always_need_review() {
680 let pr = authored(false, &[ReviewVote::Approved], CheckStatus::Passed, MergeableState::Mergeable);
681 assert_eq!(classify_pr(&pr, PrRole::Reviewer), Some(Bucket::NeedsReview));
682 }
683
684 #[test]
685 fn authored_prs_route_by_state() {
686 use Bucket::*;
687 let case = |draft, votes: &[ReviewVote], checks, merge| classify_pr(&authored(draft, votes, checks, merge), PrRole::Author);
688
689 assert_eq!(case(true, &[], CheckStatus::None, MergeableState::Mergeable), None);
690 assert_eq!(case(false, &[], CheckStatus::Passed, MergeableState::Mergeable), None);
691 assert_eq!(case(false, &[ReviewVote::Rejected], CheckStatus::Passed, MergeableState::Mergeable), Some(NeedsFixing));
692 assert_eq!(case(false, &[], CheckStatus::Failed, MergeableState::Mergeable), Some(NeedsFixing));
693 assert_eq!(case(false, &[], CheckStatus::Passed, MergeableState::Conflicting), Some(NeedsFixing));
694 assert_eq!(case(false, &[ReviewVote::Approved], CheckStatus::Passed, MergeableState::Mergeable), Some(ReadyToMerge));
695 }
696
697 #[test]
698 fn pipelines_route_to_approval_or_fixing() {
699 assert_eq!(classify_pipe(PipelineRunStatus::Running, true), Some(Bucket::ApprovalsWaiting));
700 assert_eq!(classify_pipe(PipelineRunStatus::Failed, false), Some(Bucket::NeedsFixing));
701 assert_eq!(classify_pipe(PipelineRunStatus::Succeeded, false), None);
702 assert_eq!(classify_pipe(PipelineRunStatus::Failed, true), Some(Bucket::ApprovalsWaiting));
703 }
704
705 #[test]
708 fn pr_blocker_reports_one_reason_in_precedence_order() {
709 use PrBlocker::*;
710 let case = |draft, votes: &[ReviewVote], checks, merge| pr_blocker(&authored(draft, votes, checks, merge));
711
712 assert_eq!(case(false, &[], CheckStatus::Passed, MergeableState::Mergeable), None);
713 assert_eq!(case(false, &[], CheckStatus::Failed, MergeableState::Mergeable), Some(ChecksFailing));
714 assert_eq!(case(false, &[ReviewVote::Rejected], CheckStatus::Passed, MergeableState::Mergeable), Some(ChangesRequested));
715 assert_eq!(case(false, &[], CheckStatus::Passed, MergeableState::Conflicting), Some(Conflicting));
716 assert_eq!(case(false, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Conflicting), Some(Conflicting));
718 assert_eq!(case(false, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Mergeable), Some(ChangesRequested));
719 assert_eq!(case(true, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Conflicting), None);
721 }
722
723 #[test]
726 fn needs_fixing_agrees_with_pr_blocker() {
727 let votes: [&[ReviewVote]; 3] = [&[], &[ReviewVote::Approved], &[ReviewVote::Rejected]];
728 for draft in [true, false] {
729 for v in votes {
730 for checks in [CheckStatus::None, CheckStatus::Passed, CheckStatus::Failed] {
731 for merge in [MergeableState::Mergeable, MergeableState::Conflicting, MergeableState::Unknown] {
732 let pr = authored(draft, v, checks, merge);
733 assert_eq!(
734 classify_pr(&pr, PrRole::Author) == Some(Bucket::NeedsFixing),
735 pr_blocker(&pr).is_some(),
736 "draft={draft} votes={v:?} checks={checks:?} merge={merge:?}"
737 );
738 }
739 }
740 }
741 }
742 }
743
744 #[test]
747 fn pipe_title_prefers_the_run_title() {
748 let mut input = pipe(PipelineRunStatus::Succeeded, false);
749 assert_eq!(pipe_title(&input.run, input.definition_name.as_deref()), "CI Build");
750
751 input.run.title = Some("Bump axum to 0.8".into());
752 assert_eq!(pipe_title(&input.run, input.definition_name.as_deref()), "Bump axum to 0.8");
753
754 input.run.title = Some(" ".into());
756 assert_eq!(pipe_title(&input.run, input.definition_name.as_deref()), "CI Build");
757
758 input.run.title = None;
759 input.definition_name = None;
760 assert_eq!(pipe_title(&input.run, None), "CI");
761 input.run.name = None;
762 assert_eq!(pipe_title(&input.run, None), "ci");
763 }
764
765 #[test]
766 fn pr_state_tests_lifecycle_before_anything_else() {
767 use PrState::*;
768 let stale = |status| {
771 let mut pr = authored(false, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Conflicting);
772 pr.status = status;
773 pr_state(&pr)
774 };
775 assert_eq!(stale(PullRequestStatus::Merged), Merged);
776 assert_eq!(stale(PullRequestStatus::Closed), Closed);
777 assert_eq!(stale(PullRequestStatus::Draft), Draft);
778 assert_eq!(pr_state(&authored(true, &[ReviewVote::Rejected], CheckStatus::Failed, MergeableState::Conflicting)), Draft);
780 }
781
782 #[test]
783 fn pr_state_reports_the_blocker_then_running_checks_then_clear() {
784 use PrState::*;
785 let case = |votes: &[ReviewVote], checks, merge| pr_state(&authored(false, votes, checks, merge));
786
787 assert_eq!(case(&[], CheckStatus::Passed, MergeableState::Conflicting), Blocked(PrBlocker::Conflicting));
788 assert_eq!(case(&[ReviewVote::Rejected], CheckStatus::Passed, MergeableState::Mergeable), Blocked(PrBlocker::ChangesRequested));
789 assert_eq!(case(&[], CheckStatus::Failed, MergeableState::Mergeable), Blocked(PrBlocker::ChecksFailing));
790 assert_eq!(case(&[], CheckStatus::Pending, MergeableState::Mergeable), ChecksRunning);
791 assert_eq!(case(&[ReviewVote::Approved], CheckStatus::Passed, MergeableState::Mergeable), ReadyToMerge);
792 assert_eq!(case(&[], CheckStatus::Passed, MergeableState::Mergeable), NothingBlocking);
794 assert_eq!(case(&[], CheckStatus::None, MergeableState::Unknown), NothingBlocking);
795 assert_eq!(case(&[ReviewVote::Approved], CheckStatus::Failed, MergeableState::Mergeable), Blocked(PrBlocker::ChecksFailing));
797 assert_eq!(case(&[ReviewVote::Approved], CheckStatus::Pending, MergeableState::Mergeable), ChecksRunning);
798 }
799
800 #[test]
803 fn running_checks_come_from_the_summary_when_there_is_one() {
804 let with = |successful, in_progress, failed| {
805 let mut pr = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
806 pr.check_summary = Some(CheckSummary { successful, in_progress, failed, neutral: 0 });
807 pr_state(&pr)
808 };
809 assert_eq!(with(5, 3, 0), PrState::ChecksRunning);
810 assert_eq!(with(8, 0, 0), PrState::NothingBlocking);
811 let mut pr = authored(false, &[], CheckStatus::Pending, MergeableState::Mergeable);
813 pr.check_summary = Some(CheckSummary { successful: 8, in_progress: 0, failed: 0, neutral: 0 });
814 assert_eq!(pr_state(&pr), PrState::NothingBlocking);
815 }
816
817 #[test]
818 fn reviewer_lookups_name_the_person_behind_the_verdict() {
819 let mut pr = authored(false, &[ReviewVote::Approved, ReviewVote::Rejected], CheckStatus::Passed, MergeableState::Mergeable);
820 pr.reviewers[0].user = user("alice");
821 pr.reviewers[1].user = user("sam");
822 assert_eq!(pr_changes_requested_by(&pr).map(|u| u.display_name.as_str()), Some("sam"));
823 assert_eq!(pr_approved_by(&pr).map(|u| u.display_name.as_str()), Some("alice"));
824
825 let none = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
826 assert!(pr_changes_requested_by(&none).is_none() && pr_approved_by(&none).is_none());
827 }
828
829 #[test]
830 fn pr_vote_flags_rolls_up_reviewers() {
831 assert_eq!(pr_vote_flags(&authored(false, &[], CheckStatus::None, MergeableState::Mergeable)), (false, false));
832 assert_eq!(pr_vote_flags(&authored(false, &[ReviewVote::Approved], CheckStatus::None, MergeableState::Mergeable)), (true, false));
833 assert_eq!(pr_vote_flags(&authored(false, &[ReviewVote::Rejected], CheckStatus::None, MergeableState::Mergeable)), (false, true));
834 }
835
836 #[test]
837 fn build_lists_only_your_runs() {
838 let theirs = |status, awaiting| PipeInput { triggered_by_me: false, ..pipe(status, awaiting) };
839 let out = build(
840 &[],
841 &[],
842 &[],
843 &[
844 theirs(PipelineRunStatus::Failed, false),
845 theirs(PipelineRunStatus::Succeeded, false),
846 theirs(PipelineRunStatus::Running, true),
847 ],
848 )
849 .entries;
850 let buckets: Vec<Bucket> = out.iter().map(|e| e.bucket).collect();
851 assert_eq!(buckets, vec![Bucket::ApprovalsWaiting]);
854 }
855
856 #[test]
857 fn build_lists_every_run_of_yours_in_recent_pipelines() {
858 let out = build(&[], &[], &[], &[pipe(PipelineRunStatus::Failed, false), pipe(PipelineRunStatus::Succeeded, false)]).entries;
859 let buckets: Vec<Bucket> = out.iter().map(|e| e.bucket).collect();
860 assert_eq!(buckets.iter().filter(|&&b| b == Bucket::RecentPipelines).count(), 2);
861 assert!(buckets.contains(&Bucket::NeedsFixing));
862 assert_eq!(buckets.iter().filter(|&&b| b == Bucket::ApprovalsWaiting).count(), 0);
863 }
864
865 #[test]
866 fn bucket_order_is_urgency_first() {
867 assert_eq!(Bucket::ORDER[0], Bucket::NeedsReview);
868 assert!(!Bucket::NeedsReview.muted() && Bucket::RecentlyMerged.muted());
869 }
870
871 #[test]
872 fn build_places_your_prs_in_the_full_list_and_recent_merges() {
873 let now = Utc::now();
874 let mut merged = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
875 merged.status = PullRequestStatus::Merged;
876 merged.updated_at = Some(now - chrono::Duration::days(1));
877 let mut old = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
878 old.status = PullRequestStatus::Merged;
879 old.updated_at = Some(now - chrono::Duration::days(60));
880
881 let mine = vec![
882 pr_row("ready", authored(false, &[ReviewVote::Approved], CheckStatus::Passed, MergeableState::Mergeable)),
883 pr_row("draft", authored(true, &[], CheckStatus::None, MergeableState::Mergeable)),
884 pr_row("merged", merged),
885 pr_row("old", old),
886 ];
887 let out = build(&[], &mine, &[], &[]).entries;
888 let buckets = |id: &str| out.iter().filter(|e| e.item_id() == id).map(|e| e.bucket).collect::<Vec<_>>();
889
890 assert!(buckets("ready").contains(&Bucket::ReadyToMerge) && buckets("ready").contains(&Bucket::YourOpenPrs));
891 assert_eq!(buckets("draft"), vec![Bucket::YourOpenPrs]);
892 assert_eq!(buckets("merged"), vec![Bucket::RecentlyMerged]);
893 assert!(buckets("old").is_empty());
894 }
895
896 #[test]
897 fn your_work_caps_at_five_favours_in_progress_and_flags_overflow() {
898 use WorkItemStateCategory as C;
899 let wis = vec![
901 wi_row("todo1", C::Unstarted, 1),
902 wi_row("prog1", C::Started, 2),
903 wi_row("prog2", C::Started, 3),
904 wi_row("todo2", C::Backlog, 4),
905 wi_row("prog3", C::Started, 5),
906 wi_row("todo3", C::Unstarted, 6),
907 wi_row("prog4", C::Started, 7),
908 ];
909 let lp = build(&[], &[], &wis, &[]);
910 let work: Vec<&str> = lp.entries.iter().filter(|e| e.bucket == Bucket::YourWork).map(|e| e.item_id()).collect();
911 assert_eq!(work.len(), 5, "capped at five");
912 assert!(lp.overflow.your_work, "seven assigned flags overflow");
913 assert_eq!(&work[..4], &["prog1", "prog2", "prog3", "prog4"], "in-progress items lead");
915 assert_eq!(work[4], "todo1", "then the most recently updated of the rest");
916 }
917
918 #[test]
919 fn needs_review_caps_at_five_and_flags_overflow() {
920 let review: Vec<PrInput> = (0..6)
921 .map(|i| pr_row(&format!("r{i}"), authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable)))
922 .collect();
923 let lp = build(&review, &[], &[], &[]);
924 assert_eq!(lp.entries.iter().filter(|e| e.bucket == Bucket::NeedsReview).count(), 5, "capped at five");
925 assert!(lp.overflow.needs_review, "six review requests flags overflow");
926 }
927
928 #[test]
929 fn your_open_prs_caps_at_five_and_flags_overflow() {
930 let mine: Vec<PrInput> = (0..6)
931 .map(|i| pr_row(&format!("pr{i}"), authored(true, &[], CheckStatus::None, MergeableState::Mergeable)))
932 .collect();
933 let lp = build(&[], &mine, &[], &[]);
934 assert_eq!(lp.entries.iter().filter(|e| e.bucket == Bucket::YourOpenPrs).count(), 5, "capped at five");
935 assert!(lp.overflow.your_open_prs, "six open PRs flags overflow");
936 }
937
938 #[test]
939 fn expand_buckets_are_not_capped() {
940 let mine: Vec<PrInput> = (0..7)
943 .map(|i| pr_row(&format!("pr{i}"), authored(false, &[], CheckStatus::Failed, MergeableState::Mergeable)))
944 .collect();
945 let lp = build(&[], &mine, &[], &[]);
946 assert_eq!(
947 lp.entries.iter().filter(|e| e.bucket == Bucket::NeedsFixing).count(),
948 7,
949 "needs-fixing is returned whole, not capped",
950 );
951 }
952
953 #[test]
954 fn recent_pipelines_cap_at_five_and_flag_overflow() {
955 let pipes: Vec<PipeInput> = (0..6).map(|_| pipe(PipelineRunStatus::Succeeded, false)).collect();
956 let lp = build(&[], &[], &[], &pipes);
957 assert_eq!(lp.entries.iter().filter(|e| e.bucket == Bucket::RecentPipelines).count(), 5, "capped at five");
958 assert!(lp.overflow.recent_pipelines, "six runs flags overflow");
959 }
960
961 #[test]
962 fn merged_recently_respects_the_window() {
963 let now = Utc::now();
964 let mut pr = authored(false, &[], CheckStatus::Passed, MergeableState::Mergeable);
965 pr.status = PullRequestStatus::Merged;
966 pr.updated_at = Some(now - chrono::Duration::days(2));
967 assert!(merged_recently(&pr, now));
968 pr.updated_at = Some(now - chrono::Duration::days(30));
969 assert!(!merged_recently(&pr, now), "old merges drop off");
970 pr.status = PullRequestStatus::Open;
971 pr.updated_at = Some(now);
972 assert!(!merged_recently(&pr, now), "open PRs aren't 'recently merged'");
973 }
974}