1use std::collections::{BTreeMap, BTreeSet};
42use std::fmt::Write as _;
43use std::path::{Path, PathBuf};
44use std::sync::Arc;
45use std::time::Duration;
46
47use anyhow::{Context as _, Result, bail};
48use jiff::Timestamp;
49use serde::{Deserialize, Serialize};
50
51use crate::agent::{self, Invocation, SeatState};
52use crate::ask;
53use crate::config::MergeMode;
54use crate::git;
55use crate::proc::Quiet as _;
56use crate::prompt;
57use crate::run::{
58 ContestedHandoff, LandApproval, LandReruns, MergeOutcome, RunState, RunStatus, tail,
59};
60
61pub const POLL: Duration = Duration::from_secs(30);
67
68pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
74
75pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
87
88const LOG_TAIL: usize = 4_000;
91
92const MAX_LOGS: usize = 3;
95
96pub const MARKER: &str = "<!-- magi:land -->";
102
103const NOT_A_REVIEW: [&str; 3] = [
112 "skip review by coderabbit.ai",
113 "summarize by coderabbit.ai",
114 "<!-- tips_start -->",
115];
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum PrLifecycle {
120 Open,
122 Merged,
124 Closed,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Checks {
131 Pending,
133 Green,
136 Red,
138 Unknown,
140}
141
142impl PrLifecycle {
143 pub fn as_str(self) -> &'static str {
145 match self {
146 Self::Open => "open",
147 Self::Merged => "merged",
148 Self::Closed => "closed",
149 }
150 }
151}
152
153impl Checks {
154 pub fn as_str(self) -> &'static str {
156 match self {
157 Self::Pending => "pending",
158 Self::Green => "green",
159 Self::Red => "red",
160 Self::Unknown => "unknown",
161 }
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct ReviewComment {
168 pub author: String,
170 pub path: Option<String>,
172 pub line: Option<u64>,
174 pub body: String,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct PrState {
181 pub url: String,
183 pub number: u64,
185 pub state: PrLifecycle,
187 pub checks: Checks,
189 pub failing: Vec<String>,
191 pub review_comments: Vec<ReviewComment>,
193 pub blocking: Blocking,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum Blocking {
211 No,
213 Yes,
215 Conflict,
217 Unsaid,
221}
222
223impl Blocking {
224 fn of(raw: &str) -> Self {
226 match raw.to_ascii_uppercase().as_str() {
227 "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
230 "DIRTY" => Self::Conflict,
231 "" | "UNKNOWN" => Self::Unsaid,
232 _ => Self::Yes,
234 }
235 }
236
237 #[must_use]
239 pub fn stops_a_merge(self) -> bool {
240 !matches!(self, Self::No)
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum Step {
247 Wait,
249 Rebase,
257 Fix {
259 reason: String,
261 },
262 Merge,
264 Done {
266 merged: bool,
268 },
269 GiveUp {
271 reason: String,
273 },
274}
275
276pub(crate) fn merged_after_all(
298 argv: &[String],
299 stderr: &str,
300 after: Option<PrLifecycle>,
301) -> Option<MergeOutcome> {
302 if after? != PrLifecycle::Merged {
303 return None;
304 }
305 Some(MergeOutcome {
306 mode: MergeMode::Pr,
307 ok: true,
308 detail: format!(
309 "gh {} (the command reported `{}`, but the pull request is merged)",
310 argv.join(" "),
311 stderr.trim()
312 ),
313 empty: false,
314 })
315}
316
317pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
337 decide_with(pr, round, budget, waited, CiExpectation::Expected)
338}
339
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum CiExpectation {
343 Expected,
346 Absent,
351}
352
353pub fn decide_with(
358 pr: &PrState,
359 round: usize,
360 budget: usize,
361 waited: Duration,
362 ci: CiExpectation,
363) -> Step {
364 match pr.state {
365 PrLifecycle::Merged => return Step::Done { merged: true },
366 PrLifecycle::Closed => return Step::Done { merged: false },
367 PrLifecycle::Open => {}
368 }
369
370 if pr.blocking == Blocking::Conflict {
373 return Step::Rebase;
374 }
375
376 if ci == CiExpectation::Absent {
377 return Step::Merge;
378 }
379
380 let spent = round >= budget;
381 match pr.checks {
382 Checks::Pending => Step::Wait,
383 Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
384 Checks::Unknown => Step::GiveUp {
385 reason: format!(
386 "no check status is readable on the pull request after {} minute(s); \
387 refusing to merge on a guess",
388 CHECKS_GRACE.as_secs() / 60
389 ),
390 },
391 Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
398 Checks::Red => {
399 let what = format!(
400 "{} check(s) failing: {}",
401 pr.failing.len(),
402 pr.failing.join(", ")
403 );
404 if spent {
405 Step::GiveUp {
406 reason: format!("{what} — still red after {budget} fix round(s)"),
407 }
408 } else {
409 Step::Fix { reason: what }
410 }
411 }
412 Checks::Green if pr.review_comments.is_empty() => Step::Merge,
413 Checks::Green => {
414 let what = format!(
415 "checks are green but {} review comment(s) are unresolved: {}",
416 pr.review_comments.len(),
417 authors(&pr.review_comments)
418 );
419 if spent {
420 Step::GiveUp {
421 reason: format!("{what} — still unresolved after {budget} fix round(s)"),
422 }
423 } else {
424 Step::Fix { reason: what }
425 }
426 }
427 }
428}
429
430fn authors(comments: &[ReviewComment]) -> String {
432 let mut seen: Vec<&str> = Vec::new();
433 for c in comments {
434 if !seen.contains(&c.author.as_str()) {
435 seen.push(&c.author);
436 }
437 }
438 seen.join(", ")
439}
440
441pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
445 vec![
446 "pr".to_owned(),
447 "merge".to_owned(),
448 number.to_string(),
449 "--squash".to_owned(),
450 "--delete-branch".to_owned(),
451 "--subject".to_owned(),
452 subject.to_owned(),
453 ]
454}
455
456pub fn merge_argv_at(number: u64, subject: &str, head: &str) -> Vec<String> {
462 let mut argv = merge_argv(number, subject);
463 argv.push("--match-head-commit".to_owned());
464 argv.push(head.to_owned());
465 argv
466}
467
468pub fn disable_automerge_argv(number: u64) -> Vec<String> {
473 ["pr", "merge", &number.to_string(), "--disable-auto"]
474 .map(str::to_owned)
475 .to_vec()
476}
477
478fn direct_merge_is_safe(
484 fresh: Option<&Seen>,
485 approved_head: &str,
486 shown: &BTreeSet<String>,
487 round: usize,
488 budget: usize,
489 waited: Duration,
490) -> bool {
491 let Some(fresh) = fresh else {
492 return false;
493 };
494 if fresh.pr.state != PrLifecycle::Open {
495 return false;
496 }
497 let Some(bound) = bound_head(&fresh.head, &fresh.rollup_head, None) else {
498 return false;
499 };
500 if !bound.eq_ignore_ascii_case(approved_head) {
501 return false;
502 }
503 let mut pr = fresh.pr.clone();
504 pr.review_comments.retain(|c| !shown.contains(&c.body));
505 decide(&pr, round, budget, waited) == Step::Merge
506}
507
508fn waiting_on(
515 merge_state: &str,
516 contexts: &[CheckInfo],
517 required_set: Option<&BTreeSet<String>>,
518) -> String {
519 let state = if merge_state.is_empty() {
520 "unknown"
521 } else {
522 merge_state
523 };
524 let tag = |c: &CheckInfo| match c.verdict {
525 Verdict::Fail => "failed",
526 _ => "pending",
527 };
528 let unsettled: Vec<&CheckInfo> = contexts
529 .iter()
530 .filter(|c| c.verdict != Verdict::Pass)
531 .collect();
532 let required: Vec<String> = unsettled
533 .iter()
534 .filter(|c| c.required == Some(true))
535 .map(|c| format!("{} ({})", c.label, tag(c)))
536 .collect();
537 let unknown: Vec<String> = unsettled
538 .iter()
539 .filter(|c| c.required.is_none())
540 .map(|c| format!("{} ({})", c.label, tag(c)))
541 .collect();
542 let never: Vec<&str> = required_set
546 .map(|set| {
547 set.iter()
548 .filter(|name| !contexts.iter().any(|c| c.label.eq_ignore_ascii_case(name)))
549 .map(String::as_str)
550 .collect()
551 })
552 .unwrap_or_default();
553 let mut out = format!("merge state: {state}");
554 if !never.is_empty() {
555 let _ = write!(
556 out,
557 "; required checks never reported: {}",
558 never.join(", ")
559 );
560 }
561 if !required.is_empty() {
562 let _ = write!(
563 out,
564 "; required checks not passing: {}",
565 required.join(", ")
566 );
567 }
568 if !unknown.is_empty() {
569 let _ = write!(
570 out,
571 "; whether these are required could not be read, so they may be: {}",
572 unknown.join(", ")
573 );
574 }
575 if required.is_empty() && unknown.is_empty() && never.is_empty() {
576 if required_set.is_some() {
577 out.push_str(
578 "; no required check is pending, failing or unreported, so GitHub is probably \
579 waiting for a review or another branch rule",
580 );
581 } else {
582 out.push_str(
583 "; the required check list could not be read, so a required check that was \
584 never reported cannot be ruled out",
585 );
586 }
587 }
588 out
589}
590
591pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
598 let title = pr_title.trim();
599 if !title.is_empty() && !title.starts_with("magi: candidate") {
600 return title.to_owned();
601 }
602 let first = instruction
603 .lines()
604 .map(str::trim)
605 .find(|l| !l.is_empty())
606 .unwrap_or("magi: land the winning candidate");
607 first.trim_start_matches(['#', ' ']).to_owned()
608}
609
610pub const APPROVE: &str = "merge";
612
613pub const HOLD: &str = "hold";
615
616pub const REPAIR: &str = "fix findings and re-review";
618
619pub fn repair_available(state: &RunState) -> bool {
622 state.reviews.last().is_some_and(|r| {
623 state.open_findings().iter().any(|f| f.severity.blocks())
624 || r.final_votes()
625 .iter()
626 .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
627 })
628}
629
630#[derive(Debug, Clone, Copy, PartialEq, Eq)]
632pub enum ApprovalReason {
633 EveryMerge,
635 OpenBlockingFindings,
637 FinalReject,
639 ExistingDecision,
641 AfterRepair,
643}
644
645pub fn approval_reasons(state: &RunState) -> Vec<ApprovalReason> {
648 let mut reasons = Vec::new();
649 if state.config.graph.land_approval {
650 reasons.push(ApprovalReason::EveryMerge);
651 }
652 if state.config.graph.hold_contested_merge {
653 if state.open_findings().iter().any(|f| f.severity.blocks()) {
654 reasons.push(ApprovalReason::OpenBlockingFindings);
655 }
656 if state.reviews.last().is_some_and(|r| {
657 r.final_votes()
658 .iter()
659 .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
660 }) {
661 reasons.push(ApprovalReason::FinalReject);
662 }
663 }
664 if state.land_approval.is_some() {
665 reasons.push(ApprovalReason::ExistingDecision);
666 }
667 if !state.merge_repairs.is_empty() {
668 reasons.push(ApprovalReason::AfterRepair);
669 }
670 reasons
671}
672
673fn approval_excerpt(text: &str, max_chars: usize) -> String {
676 if text.chars().count() <= max_chars {
677 text.to_owned()
678 } else {
679 let mut excerpt: String = text.chars().take(max_chars.saturating_sub(1)).collect();
680 excerpt.push('…');
681 excerpt
682 }
683}
684
685fn approval_evidence(state: &RunState) -> String {
689 const TITLE_CHARS: usize = 100;
690 const REASON_CHARS: usize = 300;
691 let reason = |text: &str| approval_excerpt(text, REASON_CHARS);
692 let ja = words(&state.config.graph.language).html_lang == "ja";
693 let mut s = if ja {
694 "承認が必要な理由:\n"
695 } else {
696 "Approval required because:\n"
697 }
698 .to_owned();
699 for reason in approval_reasons(state) {
700 let label = match (reason, ja) {
701 (ApprovalReason::EveryMerge, false) => {
702 "land_approval=true: owner approval for every merge"
703 }
704 (ApprovalReason::EveryMerge, true) => {
705 "land_approval=true: すべてのマージに所有者の承認が必要"
706 }
707 (ApprovalReason::OpenBlockingFindings, false) => {
708 "hold_contested_merge=true: open Major-or-above internal findings"
709 }
710 (ApprovalReason::OpenBlockingFindings, true) => {
711 "hold_contested_merge=true: Major 以上の内部指摘が未解決"
712 }
713 (ApprovalReason::FinalReject, false) => {
714 "hold_contested_merge=true: final reviewer reject after reconsideration (even without Major findings)"
715 }
716 (ApprovalReason::FinalReject, true) => {
717 "hold_contested_merge=true: 再考後の最終票が reject(Major 指摘がなくても承認が必要)"
718 }
719 (ApprovalReason::ExistingDecision, false) => {
720 "existing owner decision: changing controls cannot bypass it"
721 }
722 (ApprovalReason::ExistingDecision, true) => {
723 "既存の所有者判断: 設定変更では迂回できません"
724 }
725 (ApprovalReason::AfterRepair, false) => {
726 "fresh owner approval after merge repair, even with an unchanged head"
727 }
728 (ApprovalReason::AfterRepair, true) => {
729 "マージ修復後の新しい所有者承認(head に変更がなくても必要)"
730 }
731 };
732 let _ = writeln!(s, "- {label}");
733 }
734 s.push_str(if ja {
735 "\n再考後の各レビュアーの最終票:\n"
736 } else {
737 "\nFinal reviewer votes after reconsideration:\n"
738 });
739 if let Some(round) = state.reviews.last() {
740 let votes = round.final_votes();
741 for r in &round.reviews {
742 let vote = votes
743 .iter()
744 .find(|(seat, _, _)| *seat == r.reviewer)
745 .map(|(_, _, v)| v.label())
746 .unwrap_or(if ja { "記録なし" } else { "not recorded" });
747 let _ = writeln!(s, "#{} ({}): {vote}", r.reviewer, r.agent);
748 let rv = round
749 .reconsideration
750 .iter()
751 .find(|rv| rv.reviewer == r.reviewer);
752 if let Some(rv) = rv.filter(|rv| rv.vote.is_some()) {
753 let initial = r.vote.map(|v| v.label()).unwrap_or("?");
754 let _ = writeln!(
755 s,
756 " {}: {initial} → {vote}",
757 if ja { "再考" } else { "reconsideration" }
758 );
759 let _ = writeln!(
760 s,
761 " {}: {}",
762 if ja { "理由" } else { "reason" },
763 reason(if rv.reason.trim().is_empty() {
764 if ja { "記録なし" } else { "not recorded" }
765 } else {
766 &rv.reason
767 })
768 );
769 } else {
770 let _ = writeln!(
771 s,
772 " {}: {}",
773 if ja {
774 "初回票を保持・理由"
775 } else {
776 "initial vote retained; reason"
777 },
778 reason(if r.summary.trim().is_empty() {
779 if ja { "記録なし" } else { "not recorded" }
780 } else {
781 &r.summary
782 })
783 );
784 if let Some(rv) = rv {
785 let _ = writeln!(
786 s,
787 " {}: {}",
788 if ja {
789 "再投票の回答なし"
790 } else {
791 "no reconsideration vote"
792 },
793 reason(rv.failed.as_deref().unwrap_or(if ja {
794 "理由の記録なし"
795 } else {
796 "reason not recorded"
797 }))
798 );
799 }
800 }
801 if let Some(failed) = &r.failed {
802 let _ = writeln!(
803 s,
804 " {}: {}",
805 if ja { "回答なし" } else { "no answer" },
806 reason(failed)
807 );
808 }
809 }
810 } else {
811 s.push_str(if ja {
812 "レビュー記録なし\n"
813 } else {
814 "No review recorded\n"
815 });
816 }
817 s.push_str(if ja { "\n内部レビュー指摘(最終ラウンド): 採用申告や gate 成功だけでは個別指摘の解決確認にはなりません。\n" } else { "\nInternal review findings (last round): adoption claims or a passing gate alone do not confirm individual resolution.\n" });
818 let findings = state.last_round_findings();
819 for f in &findings {
820 let _ = writeln!(
821 s,
822 "[{}] {} {}:{}: {}\n{}",
823 f.id,
824 f.severity,
825 f.file.as_deref().unwrap_or("?"),
826 f.line.map(|l| l.to_string()).unwrap_or_else(|| "?".into()),
827 approval_excerpt(&f.title, TITLE_CHARS),
828 reason(&f.detail)
829 );
830 let mut evidence = false;
831 for fix in state.reviews.iter().filter_map(|r| r.fix.as_ref()).chain(
832 state
833 .operator_fixes
834 .iter()
835 .filter_map(|request| request.fix.as_ref()),
836 ) {
837 if fix.addressed.contains(&f.id) {
838 evidence = true;
839 let _ = writeln!(
840 s,
841 " {} ({}): {}",
842 if ja {
843 "採用申告(解決未確認)"
844 } else {
845 "claimed addressed (resolution unconfirmed)"
846 },
847 fix.agent,
848 reason(&fix.notes)
849 );
850 }
851 for rejection in fix.rejected.iter().filter(|r| r.id == f.id) {
852 evidence = true;
853 let _ = writeln!(
854 s,
855 " {}: {}",
856 if ja { "不採用理由" } else { "declined" },
857 reason(&rejection.why)
858 );
859 }
860 }
861 if !evidence {
862 s.push_str(if ja {
863 " 採用・解決確認の記録なし\n"
864 } else {
865 " No adoption or resolution evidence recorded\n"
866 });
867 }
868 }
869 if findings.is_empty() {
870 s.push_str(if ja {
871 "最終ラウンドに指摘の記録なし\n"
872 } else {
873 "No findings recorded in the last round\n"
874 });
875 }
876 s
877}
878
879pub fn approval_choices(state: &RunState) -> Vec<String> {
881 let mut choices = vec![
882 APPROVE.to_owned(),
883 HOLD.to_owned(),
884 REPAIR.to_owned(),
885 crate::merge_decision::WITHDRAW.to_owned(),
886 crate::merge_decision::DISMISS.to_owned(),
887 ];
888 choices.extend(
889 state
890 .config
891 .agents
892 .iter()
893 .map(|a| format!("{}{id}", crate::merge_decision::REIMPLEMENT, id = a.id)),
894 );
895 choices
896}
897
898pub fn upgrade_repair_choice(q: &mut ask::Question, state: &RunState) -> bool {
901 if q.node != APPROVAL_NODE
902 || !state
903 .land_approval
904 .as_ref()
905 .is_some_and(|a| a.question == q.id)
906 {
907 return false;
908 }
909 let before = (q.choices.clone(), q.actions.clone(), q.merge_target.clone());
910 if q.status.open() {
911 q.actions.remove(HOLD);
914 for choice in approval_choices(state) {
915 if !q.choices.contains(&choice) {
916 q.choices.push(choice);
917 }
918 }
919 if q.merge_target.is_none() {
920 q.merge_target = state.pr.as_ref().map(|pr| crate::merge_decision::Target {
921 pr: pr.url.clone(),
922 head: state.land_approval.as_ref().unwrap().head.clone(),
923 language: state.config.graph.language.clone(),
924 });
925 }
926 }
927 for choice in q.choices.clone() {
928 if (q.status.open()
929 && crate::merge_decision::stops(&choice)
930 && choice != HOLD
931 && choice != crate::merge_decision::DISMISS)
932 || (choice == REPAIR && (q.status.open() || q.resolution().as_deref() == Some(REPAIR)))
933 {
934 q.actions.insert(
935 choice,
936 ask::ChoiceAction::Resume {
937 run: state.id.clone(),
938 },
939 );
940 }
941 }
942 before != (q.choices.clone(), q.actions.clone(), q.merge_target.clone())
943}
944
945pub fn unhedged(message: &str, quote: &str) -> bool {
953 let quote = quote.trim();
954 !quote.is_empty() && message.contains(quote) && !hedged(message) && !retracts(message)
955}
956
957fn hedged(text: &str) -> bool {
961 const WORDS: &[&str] = &[
962 "maybe",
963 "probably",
964 "perhaps",
965 "might",
966 "if",
967 "unless",
968 "not",
969 "no",
970 "nope",
971 "stop",
972 "dont",
973 "abort",
974 "revert",
975 "undo",
976 "never",
977 "wait",
978 "hold",
979 "cancel",
980 "but",
981 "think",
982 "guess",
983 "suppose",
984 "unsure",
985 "yet",
986 "except",
987 "only",
988 "cannot",
989 "should",
990 "once",
991 "provided",
992 "providing",
993 "when",
994 "whenever",
995 "after",
996 "until",
997 "before",
998 "assuming",
999 "given",
1000 "while",
1001 "whether",
1002 "depending",
1003 "pending",
1004 ];
1005 const JA: &[&str] = &[
1006 "かも",
1007 "たぶん",
1008 "多分",
1009 "なら",
1010 "たら",
1011 "ちょっと待",
1012 "しないで",
1013 "しない",
1014 "保留",
1015 "まだ",
1016 "ただし",
1017 "やめ",
1018 "いや",
1019 "止め",
1020 "だめ",
1021 "ダメ",
1022 "じゃない",
1023 "ではない",
1024 "ですか",
1025 "かな",
1026 "でしょう",
1027 "思う",
1028 "でも",
1029 "けど",
1030 "ただ",
1031 "次第",
1032 "場合",
1033 "限り",
1034 "条件",
1035 "とき",
1036 "まで",
1037 "後で",
1038 ];
1039 if text.contains(['?', '?']) || JA.iter().any(|w| text.contains(w)) {
1040 return true;
1041 }
1042 text.to_lowercase()
1043 .replace('\u{2019}', "'")
1044 .split(|c: char| !(c.is_alphanumeric() || c == '\'') || !c.is_ascii())
1045 .filter(|w| !w.is_empty())
1046 .any(|w| WORDS.contains(&w) || w.ends_with("n't"))
1047}
1048
1049fn retracts(message: &str) -> bool {
1052 let lower = message.to_lowercase();
1053 [
1054 "やっぱ",
1055 "待って",
1056 "撤回",
1057 "never mind",
1058 "actually",
1059 "on second thought",
1060 ]
1061 .iter()
1062 .any(|w| lower.contains(w))
1063 || lower
1064 .split(|c: char| !c.is_ascii_alphabetic())
1065 .any(|w| w == "wait")
1066}
1067
1068pub const APPROVAL_NODE: &str = "land-approval";
1074
1075pub const DIFF_MAX_LINES: usize = 400;
1083
1084#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1086pub enum Approval {
1087 Merge,
1089 Repair,
1091 Hold,
1093}
1094
1095pub fn approval(answer: Option<&str>) -> Approval {
1103 match answer {
1104 Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
1105 Some(a) if a.trim() == REPAIR => Approval::Repair,
1106 _ => Approval::Hold,
1107 }
1108}
1109
1110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1112enum ApprovalGate {
1113 Approved,
1115 Repair,
1117 Held,
1120 Pending,
1122}
1123
1124fn esc(s: &str) -> String {
1134 let mut out = String::with_capacity(s.len());
1135 for c in s.chars() {
1136 match c {
1137 '&' => out.push_str("&"),
1138 '<' => out.push_str("<"),
1139 '>' => out.push_str(">"),
1140 '"' => out.push_str("""),
1141 '\'' => out.push_str("'"),
1142 _ => out.push(c),
1143 }
1144 }
1145 out
1146}
1147
1148#[derive(Debug, Clone, PartialEq, Eq)]
1150struct StatRow {
1151 path: String,
1152 added: Option<u64>,
1154 removed: Option<u64>,
1155}
1156
1157impl StatRow {
1158 fn churn(&self) -> u64 {
1161 self.added.unwrap_or(0) + self.removed.unwrap_or(0)
1162 }
1163}
1164
1165fn parse_numstat(numstat: &str) -> Vec<StatRow> {
1171 let mut rows: Vec<StatRow> = numstat
1172 .lines()
1173 .filter_map(|line| {
1174 let mut parts = line.splitn(3, '\t');
1175 let added = parts.next()?.trim();
1176 let removed = parts.next()?.trim();
1177 let path = parts.next()?.trim();
1178 if path.is_empty() {
1179 return None;
1180 }
1181 Some(StatRow {
1182 path: path.to_owned(),
1183 added: added.parse().ok(),
1184 removed: removed.parse().ok(),
1185 })
1186 })
1187 .collect();
1188 rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
1191 rows
1192}
1193
1194fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
1203 if line.starts_with("+++") || line.starts_with("---") {
1204 (" ", "color:#57606a;font-weight:600", line)
1205 } else if let Some(body) = line.strip_prefix('+') {
1206 ("+", "background:#e6ffec;color:#0a3622", body)
1207 } else if let Some(body) = line.strip_prefix('-') {
1208 ("-", "background:#ffebe9;color:#5c1a17", body)
1209 } else if line.starts_with("@@") {
1210 ("~", "background:#eef2ff;color:#3730a3", line)
1211 } else if let Some(body) = line.strip_prefix(' ') {
1212 (" ", "", body)
1213 } else {
1214 (" ", "color:#57606a;font-weight:600", line)
1215 }
1216}
1217
1218struct Words {
1227 html_lang: &'static str,
1228 task: &'static str,
1229 what_changed: &'static str,
1230 review_verdict: &'static str,
1231 reviewer: &'static str,
1232 reviewer_no_answer: &'static str,
1233 checks: &'static str,
1234 nothing_failing: &'static str,
1235 files_changed: &'static str,
1236 commits: &'static str,
1237 no_commits: &'static str,
1238 comments: &'static str,
1239 no_comments: &'static str,
1240 diff: &'static str,
1241 truncated: &'static str,
1242 lands_as: &'static str,
1243}
1244
1245const EN: Words = Words {
1246 html_lang: "en",
1247 task: "Task",
1248 what_changed: "What changed",
1249 review_verdict: "Review verdict",
1250 reviewer: "Reviewer",
1251 reviewer_no_answer: "produced no answer",
1252 checks: "Checks",
1253 nothing_failing: "Nothing failing.",
1254 files_changed: "file(s) changed",
1255 commits: "Commits being squashed",
1256 no_commits: "No commit subjects could be read from the branch.",
1257 comments: "External PR review comments",
1258 no_comments: "No unaddressed external PR comments at this observation. This does not mean internal findings are resolved.",
1259 diff: "Diff",
1260 truncated: "Truncated",
1261 lands_as: "They land as one commit titled",
1262};
1263
1264const JA: Words = Words {
1265 html_lang: "ja",
1266 task: "タスク",
1267 what_changed: "変更内容",
1268 review_verdict: "レビューの結論",
1269 reviewer: "レビュアー",
1270 reviewer_no_answer: "回答なし",
1271 checks: "チェック",
1272 nothing_failing: "失敗しているものはありません。",
1273 files_changed: "ファイル変更",
1274 commits: "squash されるコミット",
1275 no_commits: "ブランチからコミット件名を読めませんでした。",
1276 comments: "外部 PR レビューコメント",
1277 no_comments: "この時点で未対応の外部 PR コメントはありません。内部指摘の解決を意味するものではありません。",
1278 diff: "差分",
1279 truncated: "省略",
1280 lands_as: "これらは次の件名の1コミットとして入ります:",
1281};
1282
1283impl Words {
1284 fn lands_as_tail(&self) -> &'static str {
1288 if self.html_lang == "ja" {
1289 "。この件名も承認の対象です。"
1290 } else {
1291 ", which you are approving too."
1292 }
1293 }
1294
1295 fn approval_summary(&self, number: u64, subject: &str) -> String {
1297 if self.html_lang == "ja" {
1298 format!("プルリクエスト #{number} をマージ: {subject}")
1299 } else {
1300 format!("merge pull request #{number}: {subject}")
1301 }
1302 }
1303
1304 fn approval_detail(
1306 &self,
1307 url: &str,
1308 base: &str,
1309 subject: &str,
1310 contested: Option<&ContestedHandoff>,
1311 ) -> String {
1312 let body = if self.html_lang == "ja" {
1313 format!(
1314 "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
1315 できる状態です。差分の要約・パッチ・squash されるコミットは\
1316 下のパネルにあります。"
1317 )
1318 } else {
1319 format!(
1320 "{url} is green and ready to squash into `{base}` as `{subject}`. \
1321 The panel holds the diffstat, the patch and the commits being squashed."
1322 )
1323 };
1324 match contested {
1325 Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1326 None => body,
1327 }
1328 }
1329
1330 fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1333 const SHOWN: usize = 5;
1334 const TITLE_CHARS: usize = 100;
1335 let ja = self.html_lang == "ja";
1336 let mut out = if ja {
1337 format!(
1338 "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1339 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1340 )
1341 } else {
1342 format!(
1343 "{url} is held for approval although merge approvals are off: the \
1344 review ended with blocking findings still open and a reviewer \
1345 voting reject.\n"
1346 )
1347 };
1348 for f in c.findings.iter().take(SHOWN) {
1349 let at = match (&f.file, f.line) {
1350 (Some(file), Some(line)) => format!("{file}:{line}"),
1351 (Some(file), None) => file.clone(),
1352 _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1353 };
1354 let title: String = f.title.chars().take(TITLE_CHARS).collect();
1355 let _ = writeln!(out, "- {} {} {at}: {title}", f.id, f.severity);
1356 }
1357 if c.findings.len() > SHOWN {
1358 let more = c.findings.len() - SHOWN;
1359 let _ = writeln!(
1360 out,
1361 "{}",
1362 if ja {
1363 format!("- ほか {more} 件")
1364 } else {
1365 format!("- and {more} more")
1366 }
1367 );
1368 }
1369 let seats: Vec<String> = c
1370 .rejecters
1371 .iter()
1372 .map(|(seat, agent)| format!("#{seat} ({agent})"))
1373 .collect();
1374 let _ = write!(
1375 out,
1376 "{} {}",
1377 if ja {
1378 "却下したレビュアー:"
1379 } else {
1380 "Rejected by reviewer:"
1381 },
1382 seats.join(", ")
1383 );
1384 out
1385 }
1386
1387 fn truncated_note(
1389 &self,
1390 omitted: usize,
1391 total: usize,
1392 shown: usize,
1393 where_: &str,
1394 base: &str,
1395 head: &str,
1396 ) -> String {
1397 if self.html_lang == "ja" {
1398 format!(
1399 "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1400 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1401 プルリクエストにあります。"
1402 )
1403 } else {
1404 format!(
1405 "{omitted} of {total} diff lines omitted after the first {shown}. \
1406 The whole patch is in <code>{where_}</code> \
1407 (<code>git diff {base}...{head}</code>) and on the pull request."
1408 )
1409 }
1410 }
1411}
1412
1413fn words(language: &str) -> &'static Words {
1416 if crate::lang::is_japanese(language) {
1417 &JA
1418 } else {
1419 &EN
1420 }
1421}
1422
1423pub fn approval_panel(
1435 state: &RunState,
1436 pr: &PrState,
1437 diffstat: &str,
1438 diff: &str,
1439 commits: &[String],
1440 subject: &str,
1441) -> String {
1442 let rows = parse_numstat(diffstat);
1443 let w = words(&state.config.graph.language);
1444 let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1445
1446 let _ = writeln!(
1447 h,
1448 "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1449 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1450 w.html_lang
1451 );
1452 let _ = writeln!(
1453 h,
1454 "<title>merge #{} — {}</title>\n</head>",
1455 pr.number,
1456 esc(subject)
1457 );
1458 h.push_str(
1459 "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1460 'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1461 word-break:break-word\">\n",
1462 );
1463
1464 let _ = writeln!(
1466 h,
1467 "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1468 <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1469 <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1470 <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1471 <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#0969da\">{}</a></p>",
1472 pr.number,
1473 esc(&state.base_branch),
1474 esc(subject),
1475 esc(&state.id),
1476 esc(&pr.url),
1477 esc(&pr.url),
1478 );
1479
1480 let _ = writeln!(
1481 h,
1482 "<section style=\"padding:12px;background:#fff8c5;border:1px solid #9a6700;border-radius:6px;white-space:pre-wrap\">{}</section>",
1483 esc(&approval_evidence(state))
1484 );
1485
1486 let _ = writeln!(
1489 h,
1490 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1491 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1492 w.task,
1493 esc(&state.instruction)
1494 );
1495
1496 if let Some(summary) = state
1498 .winner()
1499 .map(|c| c.summary.as_str())
1500 .filter(|s| !s.is_empty())
1501 {
1502 let _ = writeln!(
1503 h,
1504 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1505 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1506 w.what_changed,
1507 esc(summary)
1508 );
1509 }
1510
1511 if let Some(round) = state.reviews.last() {
1514 let _ = writeln!(
1515 h,
1516 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1517 w.review_verdict
1518 );
1519 for r in &round.reviews {
1520 let body = match &r.failed {
1527 Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1528 None => esc(&r.summary),
1529 };
1530 let _ = writeln!(
1531 h,
1532 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1533 border-radius:6px\">\
1534 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1535 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1536 w.reviewer,
1537 r.reviewer,
1538 esc(&r.agent),
1539 body,
1540 );
1541 }
1542 }
1543
1544 let _ = writeln!(
1545 h,
1546 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1547 w.checks,
1548 esc(pr.checks.as_str())
1549 );
1550 if pr.failing.is_empty() {
1551 let _ = writeln!(
1552 h,
1553 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1554 w.nothing_failing
1555 );
1556 } else {
1557 h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1558 for f in &pr.failing {
1559 let _ = writeln!(h, "<li>{}</li>", esc(f));
1560 }
1561 h.push_str("</ul>\n");
1562 }
1563
1564 let _ = writeln!(
1567 h,
1568 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1569 rows.len(),
1570 w.files_changed
1571 );
1572 h.push_str(
1573 "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1574 <thead><tr>\
1575 <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1576 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1577 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1578 </th></tr></thead>\n<tbody>\n",
1579 );
1580 let mut total_added = 0u64;
1581 let mut total_removed = 0u64;
1582 for r in &rows {
1583 total_added += r.added.unwrap_or(0);
1584 total_removed += r.removed.unwrap_or(0);
1585 let cell = |n: Option<u64>| match n {
1586 Some(n) => n.to_string(),
1587 None => "bin".to_owned(),
1588 };
1589 let _ = writeln!(
1590 h,
1591 "<tr>\
1592 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1593 font-family:ui-monospace,monospace\">{}</td>\
1594 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1595 color:#0a3622\">{}</td>\
1596 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1597 color:#5c1a17\">{}</td></tr>",
1598 esc(&r.path),
1599 cell(r.added),
1600 cell(r.removed),
1601 );
1602 }
1603 let _ = writeln!(
1604 h,
1605 "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1606 <td style=\"padding:4px 2px\">total</td>\
1607 <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1608 <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1609 </tr></tfoot>\n</table>"
1610 );
1611
1612 let _ = writeln!(
1614 h,
1615 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1616 w.commits
1617 );
1618 if commits.is_empty() {
1619 h.push_str(&format!(
1620 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1621 w.no_commits
1622 ));
1623 } else {
1624 h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1625 for c in commits {
1626 let _ = writeln!(h, "<li>{}</li>", esc(c));
1627 }
1628 h.push_str("</ol>\n");
1629 }
1630 let _ = writeln!(
1631 h,
1632 "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1633 w.lands_as,
1634 esc(subject),
1635 w.lands_as_tail()
1636 );
1637
1638 let _ = writeln!(
1640 h,
1641 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1642 w.comments
1643 );
1644 if pr.review_comments.is_empty() {
1645 h.push_str(&format!(
1646 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1647 w.no_comments
1648 ));
1649 } else {
1650 for c in &pr.review_comments {
1651 let anchor = match (&c.path, c.line) {
1652 (Some(p), Some(l)) => format!("{p}:{l}"),
1653 (Some(p), None) => p.clone(),
1654 _ => "pull request thread".to_owned(),
1655 };
1656 let _ = writeln!(
1657 h,
1658 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1659 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1660 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1661 esc(&c.author),
1662 esc(&anchor),
1663 esc(&tail(&c.body, 800)),
1664 );
1665 }
1666 }
1667
1668 let total = diff.lines().count();
1670 let shown = total.min(DIFF_MAX_LINES);
1671 let _ = writeln!(
1672 h,
1673 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1674 w.diff
1675 );
1676 h.push_str(
1677 "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1678 border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1679 );
1680 for line in diff.lines().take(shown) {
1681 let (gutter, style, body) = diff_row(line);
1682 let _ = writeln!(
1683 h,
1684 "<div style=\"display:flex;{style}\">\
1685 <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1686 border-right:1px solid #d0d7de\">{gutter}</span>\
1687 <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1688 esc(body),
1689 );
1690 }
1691 h.push_str("</div>\n");
1692 if total > shown {
1693 let omitted = total - shown;
1694 let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1695 let where_ = state.winner().map_or_else(
1696 || state.repo.display().to_string(),
1697 |w| w.worktree.display().to_string(),
1698 );
1699 let _ = writeln!(
1700 h,
1701 "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1702 font-size:13px\">{}: {}</p>",
1703 w.truncated,
1704 w.truncated_note(
1705 omitted,
1706 total,
1707 shown,
1708 &esc(&where_),
1709 &esc(&state.base_branch),
1710 &esc(head),
1711 ),
1712 );
1713 }
1714
1715 h.push_str("</body>\n</html>\n");
1716 h
1717}
1718
1719fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1722 if state.config.graph.hold_contested_merge {
1723 state.contested_handoff.clone()
1724 } else {
1725 None
1726 }
1727}
1728
1729pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1737 let mut s = format!(
1738 "This is the merge approval for run {run} (`magi show {run}`). The question's \
1739 own text above names the pull request. Answering `{APPROVE}` squash-merges \
1740 it into the base branch, which cannot be undone; `{HOLD}` leaves the pull \
1741 request open. Silence is a hold: the owner not answering never merges. Only \
1742 the owner choosing `{APPROVE}`, or clearly telling you to merge in their \
1743 own words, merges. Whether their wording is a clear, unconditional instruction \
1744 is your judgement alone: if it is doubtful, conditional, retracted or a \
1745 question, do not settle - ask back with `magi ask --thread`. Doubt and \
1746 silence are a hold.\n\n\
1747 This brief is a snapshot from when you were attached: check `magi show {run}` \
1748 and `gh pr view` (read-only) before telling the owner anything current. \
1749 You run with permission to write the question record, and what keeps you \
1750 from touching anything else is this brief and your instructions - so do \
1751 not change files, branches or the pull request.",
1752 run = q.run
1753 );
1754 if let Some(target) = &q.merge_target {
1755 s.push_str(&format!("\n\nThis decision names PR {} at head `{}`. Merge through chat requires the owner's latest whole message and quote to be exactly `merge {}`. Discussion alone never approves. For hold, repair, withdrawal or replacement, use the exact offered token from the owner's latest whole message. Withdrawal explicitly closes the PR and ends the task. Replacement closes the old PR before dispatching a new attempt with the selected implementer; do not start a separate task yourself.", target.pr, target.head, target.head));
1756 }
1757 if q.choices.iter().any(|c| c == REPAIR) {
1758 s.push_str("\n\n`fix findings and re-review` requests one repair and bounded re-review on the existing PR. It never approves a merge. Settle this choice only when the owner's whole latest message is exactly this label; otherwise discuss it with `--thread`. The driver performs the repair; do not edit or queue another task. Updated review and gate results require a fresh merge decision.");
1759 }
1760 if q.detail.contains("Approval required because:") || q.detail.contains("承認が必要な理由:")
1761 {
1762 s.push_str("\n\nSaved approval evidence (at question creation):\n");
1763 s.push_str(&q.detail);
1764 } else {
1765 s.push_str("\n\nApproval reasons and final review evidence were not recorded in this legacy question. Do not infer its original trigger from current settings; consult the saved panel and review history.");
1766 }
1767 let Some(state) = state else {
1768 s.push_str(
1769 "\n\nThe run's record could not be read, so the pull request, the panel \
1770 summary and any contested findings are not known to you beyond the \
1771 question's own text. Say so to the owner rather than guessing.",
1772 );
1773 return s;
1774 };
1775 if let Some(pr) = &state.pr {
1776 s.push_str(&format!(
1777 "\n\nPull request #{} {} (recorded state: {}, last seen).",
1778 pr.number, pr.url, pr.state
1779 ));
1780 }
1781 s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1782 if let Some(w) = state.winner() {
1783 s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1784 }
1785
1786 s
1787}
1788
1789fn merge_consent_superseded(state: &RunState, question: &ask::Question) -> Result<bool> {
1793 if !question.status.open() && approval(question.resolution().as_deref()) != Approval::Merge {
1794 return Ok(false);
1795 }
1796 let Some(task_id) = state
1797 .origin
1798 .as_ref()
1799 .and_then(|origin| origin.task.as_ref())
1800 else {
1801 return Ok(false);
1802 };
1803 let task = crate::queue::Queue::open()
1804 .get(task_id)
1805 .context("cannot verify operator decisions for the merge approval")?;
1806 Ok(task
1807 .operator_decided_at
1808 .is_some_and(|at| at > question.answered_at.unwrap_or(question.asked_at)))
1809}
1810
1811async fn approval_gate(
1831 state: &mut RunState,
1832 pr: &PrState,
1833 subject: &str,
1834 contested: Option<&ContestedHandoff>,
1835 head: &str,
1836) -> Result<ApprovalGate> {
1837 let store = ask::Questions::open();
1838 let reusable = state.land_approval.as_ref().and_then(|a| {
1843 store
1844 .list()
1845 .into_iter()
1846 .find(|q| q.id == a.question)
1847 .filter(|q| {
1848 a.head.eq_ignore_ascii_case(head)
1849 || q.resolution()
1850 .as_deref()
1851 .is_some_and(crate::merge_decision::stops)
1852 })
1853 });
1854 let reusable = match reusable {
1855 Some(q) if merge_consent_superseded(state, &q)? => None,
1856 other => other,
1857 };
1858 if reusable.is_none() {
1859 for stale in store
1860 .list()
1861 .into_iter()
1862 .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1863 {
1864 let why =
1865 "the pull request head or operator decision changed; asked again for approval";
1866 if let Err(e) = store.update(&stale.id, |q| {
1867 q.abandon(why);
1868 Ok(())
1869 }) {
1870 tracing::warn!("could not retire the superseded approval question: {e:#}");
1871 }
1872 }
1873 }
1874
1875 let q = match reusable {
1876 Some(mut q) => {
1877 if upgrade_repair_choice(&mut q, state) {
1878 q = store
1879 .update(&q.id, |current| {
1880 upgrade_repair_choice(current, state);
1881 Ok(())
1882 })?
1883 .0;
1884 }
1885 q
1886 }
1887 None => {
1888 let worktree = match state.winner() {
1889 Some(w) => w.worktree.clone(),
1890 None => state.repo.clone(),
1891 };
1892 let head = if head.is_empty() {
1895 state
1896 .winner()
1897 .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1898 } else {
1899 head.to_owned()
1900 };
1901 let remote = &state.config.merge.remote;
1904 let tracking = format!("{remote}/{}", state.base_branch);
1905 let base = if git::rev_exists(&worktree, &tracking).await {
1906 tracking
1907 } else {
1908 String::new()
1909 };
1910 let range = format!("{base}...{head}");
1911 let numstat = if base.is_empty() {
1915 String::new()
1916 } else {
1917 git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1918 .await
1919 .map(|o| o.stdout)
1920 .unwrap_or_default()
1921 };
1922 let diff = if base.is_empty() {
1923 String::new()
1924 } else {
1925 git::diff(&worktree, &base, &head).await.unwrap_or_default()
1926 };
1927 let commits: Vec<String> = if base.is_empty() {
1928 Vec::new()
1929 } else {
1930 git::git_raw(
1931 &worktree,
1932 &[
1933 "log",
1934 "--reverse",
1935 "--format=%s",
1936 &format!("{base}..{head}"),
1937 ],
1938 )
1939 .await
1940 .map(|o| o.stdout)
1941 .unwrap_or_default()
1942 .lines()
1943 .filter(|l| !l.trim().is_empty())
1944 .map(str::to_owned)
1945 .collect()
1946 };
1947
1948 let w = words(&state.config.graph.language);
1949 let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1950 let mut fresh = ask::Question::new(
1951 state.id.clone(),
1952 APPROVAL_NODE.to_owned(),
1953 "land".to_owned(),
1954 w.approval_summary(pr.number, subject),
1955 format!(
1956 "{}\n\n{}",
1957 approval_evidence(state),
1958 w.approval_detail(&pr.url, &state.base_branch, subject, contested)
1959 ),
1960 approval_choices(state),
1961 );
1962 fresh.merge_target = Some(crate::merge_decision::Target {
1963 pr: pr.url.clone(),
1964 head: head.clone(),
1965 language: state.config.graph.language.clone(),
1966 });
1967 if fresh.choices.iter().any(|c| c == REPAIR) {
1968 fresh.actions.insert(
1969 REPAIR.to_owned(),
1970 ask::ChoiceAction::Resume {
1971 run: state.id.clone(),
1972 },
1973 );
1974 fresh.detail.push_str("\n\n`fix findings and re-review` runs one repair and bounded review on this PR; it does not approve a merge. A fresh decision follows the updated review and gate.");
1975 }
1976 for choice in fresh.choices.clone() {
1977 if crate::merge_decision::stops(&choice)
1978 && choice != HOLD
1979 && choice != crate::merge_decision::DISMISS
1980 {
1981 fresh.actions.insert(
1982 choice,
1983 ask::ChoiceAction::Resume {
1984 run: state.id.clone(),
1985 },
1986 );
1987 }
1988 }
1989 store
1990 .put_panel(&mut fresh, &html, &[])
1991 .context("write the merge approval panel")?;
1992 store
1993 .put_executable(&mut fresh)
1994 .context("file the merge approval question")?;
1995 state.land_approval = Some(LandApproval {
1996 question: fresh.id.clone(),
1997 head: head.clone(),
1998 });
1999 state.event(
2000 "land",
2001 format!("asking for merge approval ({})", fresh.short()),
2002 );
2003 state.save()?;
2004 if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
2005 tracing::warn!(
2009 "could not notify about merge approval question {}: {e:#} - \
2010 the web UI is the only surface for it now",
2011 fresh.short()
2012 );
2013 }
2014 fresh
2015 }
2016 };
2017
2018 Ok(match q.status {
2019 ask::QuestionStatus::Open => ApprovalGate::Pending,
2020 ask::QuestionStatus::Abandoned => ApprovalGate::Held,
2024 ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
2028 Approval::Merge => ApprovalGate::Approved,
2029 Approval::Repair if !state.reviews.is_empty() => {
2030 if !state.merge_repairs.iter().any(|r| r.question == q.id) {
2031 state.merge_repairs.push(crate::run::MergeRepair {
2032 question: q.id.clone(),
2033 head: head.to_owned(),
2034 pr: pr.url.clone(),
2035 reviews: state.reviews.clone(),
2036 gate: state.gate.clone(),
2037 gate_head: state.gate_head.clone(),
2038 gate_fixes: state.gate_fixes.clone(),
2039 pre_gate: state.pre_gate.clone(),
2040 phase: crate::run::MergeRepairPhase::Requested,
2041 failure: None,
2042 });
2043 state.land_approval = None;
2044 state.event(
2045 "repair",
2046 "owner requested fix findings and re-review; merge is not approved",
2047 );
2048 state.save()?;
2049 }
2050 ApprovalGate::Repair
2051 }
2052 Approval::Repair | Approval::Hold => ApprovalGate::Held,
2053 },
2054 })
2055}
2056
2057fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
2060 let mut failing = Vec::new();
2061 let mut pending = false;
2062 let mut unknown = false;
2063 for check in rollup {
2064 match check.verdict() {
2065 Verdict::Pass => {}
2066 Verdict::Pending => pending = true,
2067 Verdict::Fail => failing.push(check.label()),
2068 Verdict::Unknown => unknown = true,
2069 }
2070 }
2071 let checks = if rollup.is_empty() {
2072 Checks::Unknown
2073 } else if pending {
2074 Checks::Pending
2075 } else if !failing.is_empty() {
2076 Checks::Red
2077 } else if unknown {
2078 Checks::Unknown
2079 } else {
2080 Checks::Green
2081 };
2082 (checks, failing)
2083}
2084
2085pub fn parse_pr(json: &str) -> Result<PrState> {
2088 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2089 let state = match raw.state.to_ascii_uppercase().as_str() {
2090 "OPEN" => PrLifecycle::Open,
2091 "MERGED" => PrLifecycle::Merged,
2092 "CLOSED" => PrLifecycle::Closed,
2093 other => bail!("unknown pull request state `{other}`"),
2094 };
2095
2096 let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
2097
2098 let mut review_comments = Vec::new();
2099 for r in raw.reviews {
2100 push_if_outstanding(
2101 &mut review_comments,
2102 ReviewComment {
2103 author: r.author.login,
2104 path: None,
2105 line: None,
2106 body: r.body,
2107 },
2108 );
2109 }
2110 for c in raw.comments {
2111 push_if_outstanding(
2112 &mut review_comments,
2113 ReviewComment {
2114 author: c.author.login,
2115 path: None,
2116 line: None,
2117 body: c.body,
2118 },
2119 );
2120 }
2121
2122 Ok(PrState {
2123 url: raw.url,
2124 number: raw.number,
2125 state,
2126 checks,
2127 failing,
2128 review_comments,
2129 blocking: Blocking::of(&raw.merge_state_status),
2130 })
2131}
2132
2133#[derive(Debug, Clone, PartialEq, Eq)]
2135pub(crate) struct CheckView {
2136 pub name: String,
2137 pub verdict: Verdict,
2138 pub run: Option<String>,
2140 pub url: Option<String>,
2141}
2142
2143#[derive(Debug, Clone, PartialEq, Eq)]
2147pub(crate) struct RollupView {
2148 pub url: String,
2149 pub number: u64,
2150 pub state: PrLifecycle,
2151 pub head: String,
2152 pub checks: Vec<CheckView>,
2153}
2154
2155pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
2158 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2159 let state = match raw.state.to_ascii_uppercase().as_str() {
2160 "OPEN" => PrLifecycle::Open,
2161 "MERGED" => PrLifecycle::Merged,
2162 "CLOSED" => PrLifecycle::Closed,
2163 other => bail!("unknown pull request state `{other}`"),
2164 };
2165 let checks = raw
2166 .status_check_rollup
2167 .iter()
2168 .map(|c| CheckView {
2169 name: c.label(),
2170 verdict: c.verdict(),
2171 run: c.url().and_then(run_of),
2172 url: c.url().map(str::to_owned),
2173 })
2174 .collect();
2175 Ok(RollupView {
2176 url: raw.url,
2177 number: raw.number,
2178 state,
2179 head: raw.head_ref_oid,
2180 checks,
2181 })
2182}
2183
2184pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
2194 let view = gh(
2195 repo,
2196 &[
2197 "pr".to_owned(),
2198 "view".to_owned(),
2199 pr_url.to_owned(),
2200 "--json".to_owned(),
2201 "state".to_owned(),
2202 ],
2203 )
2204 .await?;
2205 if !view.0 {
2206 bail!("gh pr view {pr_url}: {}", view.1);
2207 }
2208 Ok(parse_pr(&view.1)?.state)
2212}
2213
2214#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2218pub struct ExternalMerge {
2219 pub url: String,
2221 pub number: u64,
2223}
2224
2225#[derive(Debug, Deserialize)]
2226#[serde(rename_all = "camelCase")]
2227struct GhMergedPr {
2228 url: String,
2229 number: u64,
2230 merged_at: String,
2231 base_ref_name: String,
2232}
2233
2234fn pick_merged_pr(
2248 json: &str,
2249 base_branch: &str,
2250 created_at: Timestamp,
2251) -> Result<Option<ExternalMerge>> {
2252 let raw: Vec<GhMergedPr> =
2253 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2254 let mut matches: Vec<ExternalMerge> = Vec::new();
2255 for pr in raw {
2256 if pr.base_ref_name != base_branch {
2257 continue;
2258 }
2259 let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
2260 continue;
2261 };
2262 if merged_at < created_at {
2263 continue;
2264 }
2265 matches.push(ExternalMerge {
2266 url: pr.url,
2267 number: pr.number,
2268 });
2269 }
2270 if matches.len() == 1 {
2271 Ok(matches.pop())
2272 } else {
2273 Ok(None)
2274 }
2275}
2276
2277#[derive(Debug, Clone, PartialEq, Eq)]
2279pub enum OpenPr {
2280 None,
2282 One {
2284 url: String,
2286 title: String,
2288 },
2289 Many(Vec<String>),
2291}
2292
2293#[derive(Debug, Deserialize)]
2294#[serde(rename_all = "camelCase")]
2295struct GhOpenPr {
2296 url: String,
2300 #[serde(default)]
2301 title: String,
2302 base_ref_name: String,
2303 #[serde(default)]
2306 is_cross_repository: bool,
2307}
2308
2309pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
2315 let raw: Vec<GhOpenPr> =
2316 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2317 let mut hits: Vec<GhOpenPr> = raw
2318 .into_iter()
2319 .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
2320 .collect();
2321 Ok(match hits.len() {
2322 0 => OpenPr::None,
2323 1 => {
2324 let p = hits.remove(0);
2325 OpenPr::One {
2326 url: p.url,
2327 title: p.title,
2328 }
2329 }
2330 _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
2331 })
2332}
2333
2334pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
2338 let (ok, out) = gh(
2339 repo,
2340 &[
2341 "pr".to_owned(),
2342 "list".to_owned(),
2343 "--head".to_owned(),
2344 branch.to_owned(),
2345 "--base".to_owned(),
2346 base.to_owned(),
2347 "--state".to_owned(),
2348 "open".to_owned(),
2349 "--json".to_owned(),
2350 "number,url,title,baseRefName,isCrossRepository".to_owned(),
2351 ],
2352 )
2353 .await?;
2354 if !ok {
2355 bail!("gh pr list failed: {out}");
2356 }
2357 pick_open_pr(&out, base)
2358}
2359
2360#[derive(Debug, Deserialize)]
2361#[serde(rename_all = "camelCase")]
2362struct GhPrHead {
2363 head_ref_name: String,
2364 base_ref_name: String,
2365 state: String,
2366 is_cross_repository: bool,
2369 head_ref_oid: String,
2372}
2373
2374#[derive(Debug, Clone, PartialEq, Eq)]
2376pub struct Refusal {
2377 pub retry: bool,
2380 pub why: String,
2382}
2383
2384impl Refusal {
2385 fn final_(why: String) -> Self {
2386 Self { retry: false, why }
2387 }
2388}
2389
2390pub fn closable(
2399 json: &str,
2400 branch: &str,
2401 base: &str,
2402 verified: &[String],
2403) -> std::result::Result<(), Refusal> {
2404 let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
2405 retry: true,
2406 why: format!("could not read the pull request ({e})"),
2407 })?;
2408 if pr.head_ref_name != branch {
2409 return Err(Refusal::final_(format!(
2410 "its head is `{}`, not this run's `{branch}`",
2411 pr.head_ref_name
2412 )));
2413 }
2414 if pr.is_cross_repository {
2415 return Err(Refusal::final_("its head lives in a fork".to_owned()));
2416 }
2417 if pr.base_ref_name != base {
2418 return Err(Refusal::final_(format!(
2419 "it targets `{}`, not `{base}`",
2420 pr.base_ref_name
2421 )));
2422 }
2423 if !pr.state.eq_ignore_ascii_case("open") {
2424 return Err(Refusal::final_(format!(
2425 "it is already {}",
2426 pr.state.to_ascii_lowercase()
2427 )));
2428 }
2429 if !verified.contains(&pr.head_ref_oid) {
2433 return Err(Refusal {
2434 retry: true,
2435 why: format!(
2436 "its head {} is not a commit this run checked against the base",
2437 crate::already::short_sha(&pr.head_ref_oid)
2438 ),
2439 });
2440 }
2441 Ok(())
2442}
2443
2444async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2449 let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2450 return false;
2451 };
2452 is_forge_url(url.trim())
2453}
2454
2455fn is_forge_url(url: &str) -> bool {
2456 url.contains("://") && !url.starts_with("file://")
2457 || url
2458 .split_once(':')
2459 .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2460}
2461
2462fn forge_unavailable(message: &str) -> bool {
2465 message.contains("known GitHub host") || message.contains("spawn gh")
2466}
2467
2468pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2471 let how = match evidence.proof {
2472 crate::already::Proof::PatchId => format!(
2473 "carried by commit {} on `{base}` with the same patch",
2474 evidence.names()
2475 ),
2476 crate::already::Proof::Ancestry => {
2477 format!("already in the history of `{base}` as {}", evidence.names())
2478 }
2479 crate::already::Proof::Tree => format!(
2480 "already part of `{base}` (merging this branch changes nothing at {})",
2481 crate::already::short_sha(&evidence.tip)
2482 ),
2483 };
2484 format!(
2485 "Closing: everything this branch adds is {how}, so there is nothing left to \
2486 land. This pull request was closed automatically after that was verified; \
2487 reopen it if you disagree."
2488 )
2489}
2490
2491pub async fn close_superseded_pr(
2503 state: &mut RunState,
2504 branch: &str,
2505 evidence: &crate::already::Evidence,
2506 verified: &[String],
2507) -> Result<std::result::Result<String, String>> {
2508 let repo = state.repo.clone();
2509 let base = state.base_branch.clone();
2510 let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2511 Some(p) => p.url.clone(),
2512 None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2517 return Ok(Err(
2518 "the remote is not a forge, so there is no pull request".to_owned(),
2519 ));
2520 }
2521 None => match find_open_pr(&repo, branch, &base).await {
2522 Err(e) if forge_unavailable(&format!("{e:#}")) => {
2523 return Ok(Err(format!("no forge to ask: {e:#}")));
2524 }
2525 Err(e) => return Err(e),
2526 Ok(OpenPr::One { url, .. }) => url,
2527 Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2528 Ok(OpenPr::Many(urls)) => {
2529 return Ok(Err(format!(
2530 "{} open pull requests name it; not choosing between them",
2531 urls.len()
2532 )));
2533 }
2534 },
2535 };
2536 let (ok, view) = gh(
2537 &repo,
2538 &[
2539 "pr".to_owned(),
2540 "view".to_owned(),
2541 url.clone(),
2542 "--json".to_owned(),
2543 "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2544 ],
2545 )
2546 .await?;
2547 if !ok {
2548 bail!("gh pr view {url} failed: {view}");
2549 }
2550 if let Err(refusal) = closable(&view, branch, &base, verified) {
2551 if refusal.retry {
2554 bail!("left {url} open: {}", refusal.why);
2555 }
2556 return Ok(Err(format!("left {url} open: {}", refusal.why)));
2557 }
2558 let (ok, out) = gh(
2559 &repo,
2560 &[
2561 "pr".to_owned(),
2562 "close".to_owned(),
2563 url.clone(),
2564 "--comment".to_owned(),
2565 superseded_comment(&base, evidence),
2566 ],
2567 )
2568 .await?;
2569 if !ok {
2570 bail!("gh pr close {url} failed: {out}");
2571 }
2572 if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2573 p.state = "closed".to_owned();
2574 }
2575 Ok(Ok(url))
2576}
2577
2578pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2582 let (title, _) = crate::github_text::prepare(state, title, "");
2583 edit_pr_title(repo, url, &title).await
2584}
2585
2586pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2590 edit_pr_title(repo, url, title).await
2591}
2592
2593pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2596 let (ok, _) = gh(
2597 repo,
2598 &[
2599 "pr".to_owned(),
2600 "edit".to_owned(),
2601 url.to_owned(),
2602 "--title".to_owned(),
2603 title.to_owned(),
2604 "--body".to_owned(),
2605 body.to_owned(),
2606 ],
2607 )
2608 .await
2609 .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2610 if !ok {
2611 bail!("gh pr edit failed while posting the chosen rewrite");
2613 }
2614 Ok(())
2615}
2616
2617async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2618 let (ok, out) = gh(
2619 repo,
2620 &[
2621 "pr".to_owned(),
2622 "edit".to_owned(),
2623 url.to_owned(),
2624 "--title".to_owned(),
2625 title.to_owned(),
2626 ],
2627 )
2628 .await?;
2629 if !ok {
2630 bail!("gh pr edit failed: {out}");
2631 }
2632 Ok(())
2633}
2634
2635pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2648 let Some(winner) = state.winner() else {
2649 return Ok(None);
2650 };
2651 let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2652 let out = gh(
2653 &state.repo,
2654 &[
2655 "pr".to_owned(),
2656 "list".to_owned(),
2657 "--head".to_owned(),
2658 branch.clone(),
2659 "--state".to_owned(),
2660 "merged".to_owned(),
2661 "--json".to_owned(),
2662 "url,number,mergedAt,baseRefName".to_owned(),
2663 ],
2664 )
2665 .await?;
2666 if !out.0 {
2667 bail!("gh pr list --head {branch}: {}", out.1);
2668 }
2669 pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2670}
2671
2672pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2684 let out = tokio::process::Command::new("git")
2685 .args(["merge-base", "--is-ancestor", branch, base_branch])
2686 .current_dir(repo)
2687 .quiet()
2688 .stdin(std::process::Stdio::null())
2689 .output()
2690 .await
2691 .context("spawn git merge-base --is-ancestor")?;
2692 Ok(out.status.success())
2693}
2694
2695fn forge_slug(url: &str) -> Option<(String, &str)> {
2703 let rest = url.rsplit("://").next()?;
2704 let (host, path) = rest.split_once('/')?;
2705 if host.is_empty() {
2706 return None;
2707 }
2708 Some((host.to_ascii_lowercase(), path))
2709}
2710
2711pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2719 let (host, path) = forge_slug(url)?;
2720 let mut segments = path.split('/');
2721 let owner = segments.next()?;
2722 let repo = segments.next()?;
2723 let kind = segments.next()?;
2724 if owner.is_empty() || repo.is_empty() || kind != "pull" {
2725 return None;
2726 }
2727 Some(format!("{host}/{owner}/{repo}"))
2728}
2729
2730fn slug_of_repo_url(url: &str) -> Option<String> {
2734 let (host, path) = forge_slug(url)?;
2735 let mut segments = path.split('/');
2736 let owner = segments.next()?;
2737 let repo = segments.next()?;
2738 if owner.is_empty() || repo.is_empty() {
2739 return None;
2740 }
2741 Some(format!("{host}/{owner}/{repo}"))
2742}
2743
2744pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2765 if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2766 return Ok(());
2767 }
2768 bail!(
2769 "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2770 request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2771 actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2772 verify by hand before treating this as a false positive)"
2773 );
2774}
2775
2776async fn repo_slug(repo: &Path) -> Result<String> {
2787 let out = gh(
2788 repo,
2789 &[
2790 "repo".to_owned(),
2791 "view".to_owned(),
2792 "--json".to_owned(),
2793 "url".to_owned(),
2794 ],
2795 )
2796 .await?;
2797 if !out.0 {
2798 bail!("gh repo view --json url: {}", out.1);
2799 }
2800 #[derive(Debug, Deserialize)]
2801 struct GhRepo {
2802 url: String,
2803 }
2804 let parsed: GhRepo = serde_json::from_str(&out.1)
2805 .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2806 slug_of_repo_url(&parsed.url)
2807 .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2808}
2809
2810pub async fn correct_manual_merge(
2858 state: &mut RunState,
2859 url: &str,
2860) -> Result<(RunStatus, RunStatus)> {
2861 let Some(pr_slug) = slug_of_pr_url(url) else {
2862 bail!(
2863 "could not parse an owner/repo out of {url}; refusing to guess which repository \
2864 this pull request belongs to"
2865 );
2866 };
2867 let run_slug = repo_slug(&state.repo).await?;
2868 ensure_same_repo(&run_slug, &pr_slug)?;
2869 correct_merge(state, url).await
2870}
2871
2872pub(crate) async fn correct_confirmed_external_merge(
2883 state: &mut RunState,
2884 url: &str,
2885) -> Result<(RunStatus, RunStatus)> {
2886 correct_merge(state, url).await
2887}
2888
2889fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2891 let Some(pr) = a.pr.as_ref() else {
2892 return false;
2893 };
2894 if !url.is_empty()
2895 && pr
2896 .url
2897 .trim_end_matches('/')
2898 .eq_ignore_ascii_case(url.trim_end_matches('/'))
2899 {
2900 return true;
2901 }
2902 number > 0
2903 && pr.number == number
2904 && match (a.repo.canonicalize(), repo.canonicalize()) {
2905 (Ok(x), Ok(y)) => x == y,
2906 _ => a.repo == repo,
2907 }
2908}
2909
2910fn rewrite_open_prs(
2924 home: &Path,
2925 decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2926) -> usize {
2927 let now = Timestamp::now();
2928 let mut changed = 0;
2929 for id in crate::run::list_ids_in(&home.join("runs")) {
2930 let Ok(_owner) = crate::pr_head::owner_lock(home, &id) else {
2931 continue;
2932 };
2933 let path = home.join("runs").join(&id).join("run.json");
2934 let Ok(body) = std::fs::read_to_string(&path) else {
2935 continue;
2936 };
2937 let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2938 continue;
2939 };
2940 if !state.status.done()
2941 || state.pr.as_ref().is_none_or(|p| p.state != "open")
2942 || state.liveness_under(home, crate::daemon::is_working_on(home, &id, now))
2943 != crate::run::Liveness::Dead
2944 {
2945 continue;
2946 }
2947 let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2948 continue;
2949 };
2950 if let Some(pr) = state.pr.as_mut() {
2951 pr.state = to.as_str().to_owned();
2952 }
2953 let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2954 state.event(
2955 "land",
2956 format!("recorded {url} as {}: another run settled it", to.as_str()),
2957 );
2958 match state.save_under(home) {
2959 Ok(()) => changed += 1,
2960 Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2961 }
2962 }
2963 changed
2964}
2965
2966pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2971 if to == PrLifecycle::Open {
2972 return;
2973 }
2974 let Some(home) = crate::run::try_home() else {
2975 return;
2976 };
2977 write_pr_state_through_in(&home, run, to);
2978}
2979
2980pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2981 let Some(pr) = run.pr.as_ref() else {
2982 return 0;
2983 };
2984 let (url, number) = (pr.url.clone(), pr.number);
2985 rewrite_open_prs(home, &mut |other| {
2986 (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2987 })
2988}
2989
2990pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2993 let now = Timestamp::now();
2994 let mut out = Vec::new();
2995 for id in crate::run::list_ids_in(&home.join("runs")) {
2996 let path = home.join("runs").join(&id).join("run.json");
2997 let Ok(body) = std::fs::read_to_string(&path) else {
2998 continue;
2999 };
3000 let Ok(state) = serde_json::from_str::<RunState>(&body) else {
3001 continue;
3002 };
3003 if let Some(pr) = state.pr.as_ref()
3004 && state.status.done()
3005 && pr.state == "open"
3006 && !pr.url.is_empty()
3007 && !crate::daemon::is_working_on(home, &id, now)
3008 {
3009 out.push((id, state.repo.clone(), pr.url.clone()));
3010 }
3011 }
3012 out
3013}
3014
3015pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
3018 rewrite_open_prs(home, &mut |s| {
3019 s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
3020 })
3021}
3022
3023pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
3030 let mut known = BTreeMap::new();
3031 let mut failed = 0;
3032 let mut seen = BTreeSet::new();
3033 for (_, repo, url) in stale_open_prs(home) {
3034 if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
3035 continue;
3036 }
3037 match lifecycle(&repo, &url).await {
3038 Ok(state) => {
3039 known.insert(url, state);
3040 }
3041 Err(e) => {
3042 tracing::warn!("repair pr state of {url}: {e:#}");
3043 failed += 1;
3044 }
3045 }
3046 }
3047 (apply_pr_states(home, &known), failed)
3048}
3049
3050async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
3051 match lifecycle(&state.repo, url).await? {
3052 PrLifecycle::Merged => {}
3053 other => bail!(
3054 "{url} is {}, not merged; refusing to record {} as merged on a guess",
3055 other.as_str(),
3056 state.id
3057 ),
3058 }
3059 let before = state.status;
3060 if let Err(e) = land(state, url).await {
3061 state.status = RunStatus::Blocked;
3068 state.event("fold", format!("manual-merge correction failed: {e:#}"));
3069 state.save()?;
3070 return Err(e).context(format!("confirming the merge of {url}"));
3071 }
3072 state.event(
3073 "fold",
3074 "operator recorded this pull request as a manual merge; this run never \
3075 re-entered `land`, so `bump::after_merge` did not run for it - a release \
3076 bump this change might warrant has to be filed by hand",
3077 );
3078 if state.status == RunStatus::Merged {
3081 crate::followup::after_merge(state, url).await;
3082 }
3083 state.save()?;
3084 Ok((before, state.status))
3085}
3086
3087pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
3094 let raw: Vec<GhInline> =
3095 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
3096 let mut out = Vec::new();
3097 for c in raw {
3098 push_if_outstanding(
3099 &mut out,
3100 ReviewComment {
3101 author: c.user.login,
3102 path: c.path,
3103 line: c.line,
3104 body: c.body,
3105 },
3106 );
3107 }
3108 Ok(out)
3109}
3110
3111fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
3117 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
3118 return;
3119 }
3120 if comment.path.is_none() && is_noise(&comment.body) {
3121 return;
3122 }
3123 out.push(comment);
3124}
3125
3126pub fn is_noise(body: &str) -> bool {
3144 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
3145 return true;
3146 }
3147 let mut content = false;
3148 for line in strip_blocks(body).lines() {
3149 let line = unquote(line);
3150 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
3151 continue;
3152 }
3153 content = true;
3154 break;
3155 }
3156 !content
3157}
3158
3159fn strip_blocks(body: &str) -> String {
3161 let mut out = String::with_capacity(body.len());
3162 let mut rest = body;
3163 loop {
3164 let open = ["<!--", "<details>"]
3165 .iter()
3166 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
3167 .min_by_key(|(i, _)| *i);
3168 let Some((at, tag)) = open else {
3169 out.push_str(rest);
3170 return out;
3171 };
3172 out.push_str(&rest[..at]);
3173 let after = &rest[at + tag.len()..];
3174 let close = if tag == "<!--" { "-->" } else { "</details>" };
3175 match after.find(close) {
3176 Some(end) => rest = &after[end + close.len()..],
3177 None => return out,
3179 }
3180 }
3181}
3182
3183fn unquote(line: &str) -> &str {
3185 let mut s = line.trim();
3186 while let Some(rest) = s.strip_prefix('>') {
3187 s = rest.trim_start();
3188 }
3189 s.trim()
3190}
3191
3192fn is_checklist(line: &str) -> bool {
3194 let rest = line
3195 .strip_prefix("- ")
3196 .or_else(|| line.strip_prefix("* "))
3197 .unwrap_or("");
3198 let rest = rest.trim_start();
3199 matches!(
3200 rest.get(..3),
3201 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
3202 )
3203}
3204
3205fn is_decoration(line: &str) -> bool {
3207 line.starts_with('#')
3208 || line.starts_with("[!")
3209 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
3210}
3211
3212fn is_banner(line: &str) -> bool {
3219 let plain = drop_spans(line, "**", "**");
3220 let plain = if plain.contains("](") {
3221 drop_spans(&plain, "[", ")")
3222 } else {
3223 plain
3224 };
3225 !plain.chars().any(char::is_alphanumeric)
3226}
3227
3228fn drop_spans(s: &str, open: &str, close: &str) -> String {
3232 let mut out = String::with_capacity(s.len());
3233 let mut rest = s;
3234 while let Some(at) = rest.find(open) {
3235 out.push_str(&rest[..at]);
3236 let after = &rest[at + open.len()..];
3237 match after.find(close) {
3238 Some(end) => rest = &after[end + close.len()..],
3239 None => return out,
3240 }
3241 }
3242 out.push_str(rest);
3243 out
3244}
3245
3246fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
3264 static LOCKS: std::sync::LazyLock<
3265 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
3266 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
3267 LOCKS
3268 .lock()
3269 .unwrap_or_else(std::sync::PoisonError::into_inner)
3270 .entry(repo.to_path_buf())
3271 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
3272 .clone()
3273}
3274
3275fn repo_label(repo: &Path, pr_url: &str) -> String {
3278 let parts: Vec<&str> = pr_url.split('/').collect();
3279 if let Some(at) = parts.iter().rposition(|p| *p == "pull")
3280 && at >= 2
3281 && !parts[at - 1].is_empty()
3282 && !parts[at - 2].is_empty()
3283 {
3284 return format!("{}/{}", parts[at - 2], parts[at - 1]);
3285 }
3286 repo.file_name()
3287 .map(|n| n.to_string_lossy().into_owned())
3288 .unwrap_or_default()
3289}
3290
3291fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
3295 (pr.checks == Checks::Red).then(|| {
3296 let details = crate::notices::format_list(&pr.failing)
3297 .map(|names| format!(": {names}"))
3298 .unwrap_or_default();
3299 format!(
3300 "Merged {repo_name} PR #{} with red checks{details} ({})",
3301 pr.number, pr.url
3302 )
3303 })
3304}
3305
3306async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
3310 let repo_name = repo_label(&state.repo, &pr.url);
3311 let Some(summary) = red_merge_summary(&repo_name, pr) else {
3312 return;
3313 };
3314 if let Some(rec) = state.pr.as_mut() {
3315 rec.red_at_merge = pr.failing.clone();
3316 }
3317 state.event("land", summary.clone());
3318 crate::notices::raise_with(
3321 crate::notices::merged_red(&state.id, &summary),
3322 &state.config.notify,
3323 );
3324}
3325
3326pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
3333 land_with(state, pr_url, &GhForge).await
3334}
3335
3336trait Forge {
3340 async fn close(&self, repo: &Path, pr_url: &str, comment: &str) -> Result<()> {
3341 let (ok, out) = gh(
3342 repo,
3343 &[
3344 "pr".into(),
3345 "close".into(),
3346 pr_url.into(),
3347 "--comment".into(),
3348 comment.into(),
3349 ],
3350 )
3351 .await?;
3352 if !ok {
3353 bail!("PR closure failed: {out}");
3354 }
3355 Ok(())
3356 }
3357 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
3358 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
3359 async fn poll(&self);
3360 async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
3362 fn now(&self) -> i64;
3364 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3367 #[allow(clippy::too_many_arguments)]
3368 async fn fix(
3369 &self,
3370 state: &mut RunState,
3371 pr: &PrState,
3372 round: usize,
3373 budget: usize,
3374 reason: &str,
3375 logs: &str,
3376 ) -> Result<Fixed>;
3377}
3378
3379struct GhForge;
3380
3381impl Forge for GhForge {
3382 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3383 observe(repo, pr_url).await
3384 }
3385 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3386 gh(repo, argv).await
3387 }
3388 async fn poll(&self) {
3389 tokio::time::sleep(POLL).await;
3390 }
3391 async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3392 let args = [
3393 "run".to_owned(),
3394 "rerun".to_owned(),
3395 run.to_owned(),
3396 "--failed".to_owned(),
3397 ];
3398 let (ok, out) = gh(repo, &args).await?;
3399 if !ok {
3400 bail!("gh run rerun {run}: {out}");
3401 }
3402 Ok(())
3403 }
3404 fn now(&self) -> i64 {
3405 jiff::Timestamp::now().as_second()
3406 }
3407 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3408 required_contexts_of(repo, base).await
3409 }
3410 async fn fix(
3411 &self,
3412 state: &mut RunState,
3413 pr: &PrState,
3414 round: usize,
3415 budget: usize,
3416 reason: &str,
3417 logs: &str,
3418 ) -> Result<Fixed> {
3419 fix_round(state, pr, round, budget, reason, logs).await
3420 }
3421}
3422
3423fn encode_path_segment(s: &str) -> String {
3425 let mut out = String::new();
3426 for b in s.bytes() {
3427 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3428 out.push(b as char);
3429 } else {
3430 let _ = write!(out, "%{b:02X}");
3431 }
3432 }
3433 out
3434}
3435
3436async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3441 let enc = encode_path_segment(base);
3442 let mut all = BTreeSet::new();
3443 let classic = gh(
3446 repo,
3447 &[
3448 "api".to_owned(),
3449 format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3450 ],
3451 )
3452 .await
3453 .ok()?;
3454 if classic.0 {
3455 all.extend(parse_classic_required(&classic.1)?);
3456 } else if !classic.1.contains("404") {
3457 return None;
3458 }
3459 let rules = gh(
3460 repo,
3461 &[
3462 "api".to_owned(),
3463 format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3464 ],
3465 )
3466 .await
3467 .ok()?;
3468 if !rules.0 {
3469 return None;
3470 }
3471 all.extend(parse_ruleset_required(&rules.1)?);
3472 Some(all)
3473}
3474
3475fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3478 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3479 let mut out = BTreeSet::new();
3480 for c in v.get("contexts")?.as_array()? {
3481 out.insert(c.as_str()?.to_owned());
3482 }
3483 for c in v
3484 .get("checks")
3485 .and_then(|c| c.as_array())
3486 .into_iter()
3487 .flatten()
3488 {
3489 if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3490 out.insert(name.to_owned());
3491 }
3492 }
3493 Some(out)
3494}
3495
3496fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3498 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3499 let mut out = BTreeSet::new();
3500 for rule in v.as_array()? {
3501 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3502 continue;
3503 }
3504 let checks = rule
3505 .pointer("/parameters/required_status_checks")?
3506 .as_array()?;
3507 for c in checks {
3508 out.insert(c.get("context")?.as_str()?.to_owned());
3509 }
3510 }
3511 Some(out)
3512}
3513
3514fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3522 awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3523}
3524
3525fn bound_head<'a>(
3535 seen_head: &'a str,
3536 rollup_head: &str,
3537 awaiting: Option<&str>,
3538) -> Option<&'a str> {
3539 if seen_head.is_empty()
3540 || awaiting_new_head(awaiting, seen_head)
3541 || !rollup_head.eq_ignore_ascii_case(seen_head)
3542 {
3543 return None;
3544 }
3545 Some(seen_head)
3546}
3547
3548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3550enum Refused {
3551 Pending,
3553 Recheck,
3557 Final,
3560}
3561
3562fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3565 let Some(after) = after else {
3566 return Refused::Pending;
3568 };
3569 if after.pr.state != PrLifecycle::Open {
3570 return Refused::Final;
3571 }
3572 if !after.head.eq_ignore_ascii_case(observed_head) {
3575 return Refused::Pending;
3576 }
3577 if bound_head(&after.head, &after.rollup_head, None).is_none() {
3579 return Refused::Pending;
3580 }
3581 let state = after.merge_state.to_ascii_uppercase();
3582 if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3583 || state.is_empty()
3584 || state == "UNKNOWN"
3585 {
3586 return Refused::Pending;
3587 }
3588 if rechecked {
3589 Refused::Final
3590 } else {
3591 Refused::Recheck
3592 }
3593}
3594
3595async fn disarm<F: Forge>(
3600 forge: &F,
3601 state: &mut RunState,
3602 repo: &Path,
3603 number: u64,
3604) -> std::result::Result<(), String> {
3605 let argv = disable_automerge_argv(number);
3606 let out = {
3607 let merge_lock = repo_merge_lock(repo);
3608 let _merge_slot = merge_lock.lock().await;
3609 forge.merge(repo, &argv).await
3610 };
3611 match out {
3612 Ok((true, _)) => {
3613 state.land_armed_head = None;
3614 state.event("land", "auto-merge disabled");
3615 state.save().map_err(|e| format!("{e:#}"))?;
3616 Ok(())
3617 }
3618 Ok((false, msg)) => Err(msg),
3619 Err(e) => Err(format!("{e:#}")),
3620 }
3621}
3622
3623async fn stop_disarmed<F: Forge>(
3627 forge: &F,
3628 state: &mut RunState,
3629 repo: &Path,
3630 pr: &PrState,
3631 why: &str,
3632) -> Result<()> {
3633 if state.land_armed_head.is_none() {
3634 return stop(state, repo, pr, why).await;
3635 }
3636 match disarm(forge, state, repo, pr.number).await {
3637 Ok(()) => stop(state, repo, pr, why).await,
3638 Err(e) => {
3639 let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3640 stop(state, repo, pr, &why).await
3641 }
3642 }
3643}
3644
3645fn owner_requested_stop(state: &RunState) -> Result<bool> {
3647 let Some(binding) = &state.land_approval else {
3648 return Ok(false);
3649 };
3650 let q = ask::Questions::open().get(&binding.question)?;
3651 Ok(q.run == state.id
3652 && q.node == APPROVAL_NODE
3653 && q.resolution()
3654 .as_deref()
3655 .is_some_and(crate::merge_decision::stops))
3656}
3657
3658async fn owner_stop<F: Forge>(state: &mut RunState, pr: &mut PrState, forge: &F) -> Result<bool> {
3661 use crate::merge_decision as md;
3662 let Some(binding) = &state.land_approval else {
3663 return Ok(false);
3664 };
3665 let q = ask::Questions::open().get(&binding.question)?;
3666 let Some(choice) = q.resolution().filter(|c| md::stops(c)) else {
3667 return Ok(false);
3668 };
3669 if q.run != state.id || q.node != APPROVAL_NODE {
3670 bail!("stop decision names another run");
3671 }
3672 let target = q.merge_target.clone().unwrap_or_else(|| md::Target {
3673 pr: pr.url.clone(),
3674 head: binding.head.clone(),
3675 language: state.config.graph.language.clone(),
3676 });
3677 if target.pr != pr.url {
3678 bail!("stop decision names another PR");
3679 }
3680 if state.merge_decision.is_none() {
3681 state.merge_decision = Some(md::Decision {
3682 question: q.id.clone(),
3683 target,
3684 choice: choice.clone(),
3685 closed: false,
3686 replacement_run: None,
3687 replacement_pr: None,
3688 });
3689 state.save()?;
3690 }
3691 let repo = state.repo.clone();
3692 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3694 stop(state, &repo, pr, &format!("owner stop requested, but auto-merge could not be disabled: {e}; no repair or replacement started")).await?;
3695 return Ok(true);
3696 }
3697 if choice.eq_ignore_ascii_case(HOLD) {
3698 stop(
3699 state,
3700 &repo,
3701 pr,
3702 "owner held the decision: PR left open; no further repair or merge work",
3703 )
3704 .await?;
3705 return Ok(true);
3706 }
3707 if md::selected_agent(&choice).is_some()
3708 || state
3709 .origin
3710 .as_ref()
3711 .and_then(|o| o.task.as_ref())
3712 .is_some()
3713 {
3714 let owners: Vec<_> = crate::queue::Queue::open()
3715 .list()
3716 .into_iter()
3717 .filter(|task| task.runs.last() == Some(&state.id))
3718 .collect();
3719 let owner = (owners.len() == 1).then(|| &owners[0]);
3720 if !owner.is_some_and(|task| {
3721 task.runs.last() == Some(&state.id)
3722 && (!task
3723 .operator_decided_at
3724 .is_some_and(|at| at > q.answered_at.unwrap_or(q.asked_at))
3725 || task.resumption.as_ref().is_some_and(|release| {
3726 release.question_id.as_deref() == Some(q.id.as_str())
3727 && task.operator_decided_at == Some(release.at)
3728 }))
3729 }) {
3730 stop(state, &repo, pr, "replacement requires the current original queue task owner and an unsuperseded answer; PR left open").await?;
3731 return Ok(true);
3732 }
3733 }
3734 if pr.state == PrLifecycle::Merged {
3735 stop(
3736 state,
3737 &repo,
3738 pr,
3739 "PR already merged; withdrawal/replacement cannot be applied",
3740 )
3741 .await?;
3742 return Ok(true);
3743 }
3744 let comment = format!(
3745 "{MARKER}\nOwner decision on question {}: {}. Previous attempt: `{}`. Original instruction and review history remain in the task record.",
3746 q.id, choice, state.id
3747 );
3748 if pr.state == PrLifecycle::Open {
3749 if let Err(e) = forge.close(&repo, &pr.url, &comment).await {
3750 stop(
3751 state,
3752 &repo,
3753 pr,
3754 &format!(
3755 "owner requested closure but it failed: {e}; task held, no replacement started"
3756 ),
3757 )
3758 .await?;
3759 return Ok(true);
3760 }
3761 }
3762 let confirmed = forge.view(&repo, &pr.url).await?;
3763 if confirmed.pr.state != PrLifecycle::Closed {
3764 stop(
3765 state,
3766 &repo,
3767 pr,
3768 "PR closure was not confirmed; task held, no replacement started",
3769 )
3770 .await?;
3771 return Ok(true);
3772 }
3773 *pr = confirmed.pr;
3774 state.merge_decision.as_mut().unwrap().closed = true;
3775 if let Some(record) = &mut state.pr {
3776 record.state = "closed".into();
3777 }
3778 state.status = RunStatus::Superseded;
3779 state.merge = Some(MergeOutcome {
3780 mode: MergeMode::Pr,
3781 ok: false,
3782 detail: format!("owner decision: {choice}; PR closure confirmed"),
3783 empty: false,
3784 });
3785 state.event(
3786 "land",
3787 format!(
3788 "{} applied: PR closure confirmed; old execution retired",
3789 choice
3790 ),
3791 );
3792 state.save()?;
3793 Ok(true)
3794}
3795
3796async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
3797 crate::pr_head::ensure_owner(state)?;
3798 let reopening_hold = crate::merge_decision::resumable_hold(state);
3799 let repo = state.repo.clone();
3800 let budget = state.config.graph.land_rounds;
3801 let mut round = 0usize;
3802 let mut rebases = 0usize;
3805 let mut waited = Duration::ZERO;
3806 let mut shown: BTreeSet<String> = BTreeSet::new();
3811 let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
3813 let mut rechecked = false;
3815
3816 state.status = RunStatus::Landing;
3824 state.event("land", format!("watching {pr_url}"));
3825 state.save()?;
3826
3827 let mut resumed_armed = state.land_armed_head.is_some();
3831
3832 loop {
3833 let seen = forge.view(&repo, pr_url).await?;
3834 let mut pr = seen.pr.clone();
3835 pr.review_comments.retain(|c| !shown.contains(&c.body));
3836 state.pr = Some(crate::run::PrRecord {
3837 url: pr.url.clone(),
3838 number: pr.number,
3839 state: pr.state.as_str().to_owned(),
3840 checks: pr.checks.as_str().to_owned(),
3841 round,
3842 rounds: budget,
3843 red_at_merge: Vec::new(),
3844 });
3845 state.save()?;
3846
3847 crate::pr_head::ensure_owner(state)?;
3848 if reopening_hold {
3849 anyhow::ensure!(
3850 pr.state == PrLifecycle::Open,
3851 "held PR is no longer open; cannot request a fresh decision"
3852 );
3853 disarm(forge, state, &repo, pr.number)
3856 .await
3857 .map_err(anyhow::Error::msg)?;
3858 let previous = state.merge_decision.take();
3859 let binding = state.land_approval.take();
3860 state.config.graph.land_approval = true;
3861 let subject = merge_subject(
3862 crate::graph::landing_title(state, &seen.title),
3863 &crate::graph::landing_subject_source(state),
3864 );
3865 let contested = contested_to_ask(state);
3866 if let Err(error) =
3867 approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
3868 {
3869 state.merge_decision = previous;
3870 state.land_approval = binding;
3871 return Err(error);
3872 }
3873 state.end_pass_parked(crate::run::ParkCause::Question);
3874 state.parked = true;
3875 state.event("land", format!("explicit resume reopened held decision {}; parked awaiting a fresh owner answer", previous.as_ref().map(|d| d.question.as_str()).unwrap_or_default()));
3876 state.save()?;
3877 return Ok(pr);
3878 }
3879 if owner_stop(state, &mut pr, forge).await? {
3880 return Ok(pr);
3881 }
3882
3883 if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
3884 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3889 let why = format!(
3890 "a previous pass may have armed auto-merge and it could not be disabled \
3891 on resume: {e}"
3892 );
3893 stop(state, &repo, &pr, &why).await?;
3894 return Ok(pr);
3895 }
3896 }
3897
3898 if pr.state == PrLifecycle::Open
3903 && !seen.head.is_empty()
3904 && state
3905 .land_armed_head
3906 .as_deref()
3907 .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
3908 {
3909 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3910 let why = format!(
3911 "the head moved while auto-merge was armed and it could not be disabled: {e}"
3912 );
3913 stop(state, &repo, &pr, &why).await?;
3914 return Ok(pr);
3915 }
3916 }
3917
3918 if pr.state == PrLifecycle::Open {
3919 if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
3920 if waited >= WAIT_CEILING {
3921 let want = awaiting_head.as_deref().unwrap_or_default();
3922 let why = format!(
3923 "the pull request's checks were still not about one readable head after \
3924 {} minutes (expected {}, pull request points at {}, checks are for {}); \
3925 someone may have pushed over it",
3926 WAIT_CEILING.as_secs() / 60,
3927 if want.is_empty() { "any" } else { want },
3928 if seen.head.is_empty() {
3929 "nothing readable"
3930 } else {
3931 &seen.head
3932 },
3933 if seen.rollup_head.is_empty() {
3934 "nothing readable"
3935 } else {
3936 &seen.rollup_head
3937 },
3938 );
3939 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3940 return Ok(pr);
3941 }
3942 waited += POLL;
3943 forge.poll().await;
3944 continue;
3945 }
3946 if awaiting_head.take().is_some() {
3949 waited = Duration::ZERO;
3952 }
3953 }
3954
3955 if pr.state == PrLifecycle::Open && state.config.graph.rerun_flaky_checks {
3960 let failing = rerunnable(&seen.contexts);
3961 let now = forge.now();
3962 match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
3963 RerunStep::Rerun(runs) => {
3964 let rec = state.land_reruns.get_or_insert_with(Default::default);
3965 if !rec.head.eq_ignore_ascii_case(&seen.head) {
3966 *rec = LandReruns {
3967 head: seen.head.clone(),
3968 runs: BTreeMap::new(),
3969 };
3970 }
3971 for run in &runs {
3972 rec.runs.insert(run.clone(), now);
3973 }
3974 state.save()?;
3978 let mut any = false;
3979 for run in &runs {
3980 let names = failing
3981 .iter()
3982 .filter(|(_, r)| r.as_deref() == Some(run))
3983 .map(|(l, _)| l.as_str())
3984 .collect::<Vec<_>>()
3985 .join(", ");
3986 match forge.rerun(&repo, run).await {
3987 Ok(()) => {
3988 any = true;
3989 state.event(
3990 "land",
3991 format!("reran failed check(s) {names} (run {run})"),
3992 );
3993 }
3994 Err(e) => state.event(
3996 "land",
3997 format!("could not rerun check(s) {names} (run {run}): {e:#}"),
3998 ),
3999 }
4000 }
4001 state.save()?;
4002 if any {
4003 waited = Duration::ZERO;
4004 forge.poll().await;
4005 continue;
4006 }
4007 }
4008 RerunStep::Grace => {
4009 if waited >= WAIT_CEILING {
4010 let why = format!(
4011 "checks were still failing after a rerun and {} minutes",
4012 WAIT_CEILING.as_secs() / 60
4013 );
4014 stop(state, &repo, &pr, &why).await?;
4015 return Ok(pr);
4016 }
4017 waited += POLL;
4018 forge.poll().await;
4019 continue;
4020 }
4021 RerunStep::Fix => {}
4022 }
4023 }
4024
4025 let step = decide(&pr, round, budget, waited);
4026 let armed_here = state
4031 .land_armed_head
4032 .as_deref()
4033 .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
4034 if armed_here && matches!(step, Step::Merge | Step::Wait) {
4035 if waited >= WAIT_CEILING {
4036 let required = if seen.base.is_empty() {
4037 None
4038 } else {
4039 forge.required_contexts(&repo, &seen.base).await
4040 };
4041 let why = format!(
4042 "auto-merge was armed on {} but the pull request did not merge within {} \
4043 minutes ({})",
4044 seen.head,
4045 WAIT_CEILING.as_secs() / 60,
4046 waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
4047 );
4048 stop_disarmed(forge, state, &repo, &pr, &why).await?;
4049 return Ok(pr);
4050 }
4051 waited += POLL;
4052 forge.poll().await;
4053 continue;
4054 }
4055 if armed_here && !matches!(step, Step::Done { .. }) {
4056 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4059 let why = format!("auto-merge could not be disabled: {e}");
4060 stop(state, &repo, &pr, &why).await?;
4061 return Ok(pr);
4062 }
4063 }
4064 match step {
4065 Step::Wait => {
4066 if waited >= WAIT_CEILING {
4067 let why = format!(
4068 "checks were still running after {} minutes",
4069 WAIT_CEILING.as_secs() / 60
4070 );
4071 stop(state, &repo, &pr, &why).await?;
4072 return Ok(pr);
4073 }
4074 waited += POLL;
4075 forge.poll().await;
4076 }
4077 Step::Done { merged } => {
4078 if merged {
4079 state.pr_merged_head = (!seen.head.is_empty()).then(|| seen.head.clone());
4080 }
4081 if let Some(armed) = state.land_armed_head.take() {
4086 if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
4087 let msg = format!(
4088 "{} merged on {} but the owner approved {armed}; review what landed",
4089 pr.url, seen.head
4090 );
4091 tracing::warn!("{msg}");
4092 state.event("land", msg);
4093 crate::notices::raise_with(
4097 crate::notices::Notice::warn(
4098 &format!("merged-unapproved-head:{}", state.id),
4099 "A pull request merged on a commit the owner did not approve; \
4100 review what landed",
4101 )
4102 .link(crate::notices::Link::Run {
4103 id: state.id.clone(),
4104 }),
4105 &state.config.notify,
4106 );
4107 }
4108 }
4109 state.status = if merged {
4110 RunStatus::Merged
4111 } else {
4112 RunStatus::Ready
4113 };
4114 let detail = if merged {
4115 format!("{} was merged", pr.url)
4116 } else {
4117 format!("{} was closed without merging", pr.url)
4118 };
4119 state.merge = Some(MergeOutcome {
4120 mode: MergeMode::Pr,
4121 ok: merged,
4122 detail: detail.clone(),
4123 empty: false,
4124 });
4125 state.event("land", detail);
4126 state.save()?;
4127 write_pr_state_through(state, pr.state);
4128 return Ok(pr);
4129 }
4130 Step::Merge => {
4131 state.check_delivery_evidence(&seen.head)?;
4132 let subject = merge_subject(
4133 crate::graph::landing_title(state, &seen.title),
4134 &crate::graph::landing_subject_source(state),
4135 );
4136 let contested = contested_to_ask(state);
4142 if !approval_reasons(state).is_empty() {
4143 match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
4144 .await?
4145 {
4146 ApprovalGate::Approved => {}
4147 ApprovalGate::Repair => {
4148 if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4151 state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4152 state.save()?;
4153 }
4154 return Ok(pr);
4155 }
4156 ApprovalGate::Held => {
4157 if owner_stop(state, &mut pr, forge).await? {
4160 return Ok(pr);
4161 }
4162 stop(
4163 state,
4164 &repo,
4165 &pr,
4166 "the owner did not approve the merge (held or unanswered)",
4167 )
4168 .await?;
4169 return Ok(pr);
4170 }
4171 ApprovalGate::Pending => {
4179 state.end_pass_parked(crate::run::ParkCause::Question);
4180 state.parked = true;
4181 state.event(
4182 "land",
4183 "parked awaiting merge approval - resumes once answered",
4184 );
4185 state.save()?;
4186 return Ok(pr);
4187 }
4188 }
4189 }
4190 let observed_head = seen.head.clone();
4201 {
4204 let fresh = forge.view(&repo, pr_url).await.ok();
4205 if !direct_merge_is_safe(
4206 fresh.as_ref(),
4207 &observed_head,
4208 &shown,
4209 round,
4210 budget,
4211 waited,
4212 ) {
4213 if waited >= WAIT_CEILING {
4214 let why = "the pull request did not settle on the approved head \
4215 before it could be merged";
4216 stop(state, &repo, &pr, why).await?;
4217 return Ok(pr);
4218 }
4219 state.event(
4220 "land",
4221 "the pull request changed before merging; looking again",
4222 );
4223 waited += POLL;
4224 forge.poll().await;
4225 continue;
4226 }
4227 }
4228 let argv = merge_argv_at(pr.number, &subject, &observed_head);
4229 let out = {
4230 let merge_lock = repo_merge_lock(&repo);
4231 let _merge_slot = merge_lock.lock().await;
4232 crate::pr_head::ensure_owner(state)?;
4233 if let Some(task_id) = state
4234 .origin
4235 .as_ref()
4236 .and_then(|origin| origin.task.as_ref())
4237 {
4238 let task = crate::queue::Queue::open()
4239 .get(task_id)
4240 .context("cannot verify the task owner immediately before merge")?;
4241 if task.runs.last() != Some(&state.id)
4242 || task.operator_held()
4243 || task.status == crate::queue::TaskStatus::Done
4244 {
4245 bail!(
4246 "task ownership or an operator hold superseded this merge approval"
4247 );
4248 }
4249 }
4250 if let Some(binding) = &state.land_approval {
4251 let current = ask::Questions::open().get(&binding.question)?;
4252 if merge_consent_superseded(state, ¤t)?
4253 || current.run != state.id
4254 || !binding.head.eq_ignore_ascii_case(&observed_head)
4255 || approval(current.resolution().as_deref()) != Approval::Merge
4256 || current.merge_target.as_ref().is_some_and(|t| {
4257 t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4258 })
4259 {
4260 bail!(
4261 "merge decision is stale or no longer authorizes the reviewed head"
4262 );
4263 }
4264 }
4265 forge.merge(&repo, &argv).await?
4266 };
4267 if out.0 {
4268 let confirmed = forge.view(&repo, pr_url).await.is_ok_and(|c| {
4274 if c.pr.state == PrLifecycle::Merged {
4275 state.pr_merged_head = (!c.head.is_empty()).then_some(c.head);
4276 true
4277 } else {
4278 false
4279 }
4280 });
4281 if !confirmed {
4282 if waited >= WAIT_CEILING {
4283 let why = "the merge request succeeded but the pull request \
4284 could not be confirmed merged after waiting";
4285 stop(state, &repo, &pr, why).await?;
4286 return Ok(pr);
4287 }
4288 state.event(
4289 "land",
4290 "merge accepted but the pull request is not confirmed merged yet; waiting",
4291 );
4292 state.save()?;
4293 waited += POLL;
4294 forge.poll().await;
4295 continue;
4296 }
4297 pr.state = PrLifecycle::Merged;
4298 state.status = RunStatus::Merged;
4299 state.merge = Some(MergeOutcome {
4300 mode: MergeMode::Pr,
4301 ok: true,
4302 detail: format!("gh {}", argv.join(" ")),
4303 empty: false,
4304 });
4305 if let Some(pr_record) = state.pr.as_mut() {
4310 pr_record.state = pr.state.as_str().to_owned();
4311 }
4312 state.event("land", format!("merged {} as `{subject}`", pr.url));
4313 announce_red_merge(state, &pr).await;
4314 state.save()?;
4315 write_pr_state_through(state, pr.state);
4316 return Ok(pr);
4317 }
4318 let after_seen = forge.view(&repo, pr_url).await.ok();
4319 let after = after_seen.as_ref().map(|s| s.pr.state);
4320 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4321 state.pr_merged_head = after_seen
4322 .as_ref()
4323 .map(|s| s.head.clone())
4324 .filter(|h| !h.is_empty());
4325 pr.state = PrLifecycle::Merged;
4326 state.status = RunStatus::Merged;
4327 state.merge = Some(outcome);
4328 if let Some(pr_record) = state.pr.as_mut() {
4329 pr_record.state = pr.state.as_str().to_owned();
4330 }
4331 state.event("land", format!("merged {} as `{subject}`", pr.url));
4332 announce_red_merge(state, &pr).await;
4333 state.save()?;
4334 write_pr_state_through(state, pr.state);
4335 return Ok(pr);
4336 }
4337 let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
4338 match verdict {
4339 Refused::Final => {
4340 let merge_state = after_seen
4341 .as_ref()
4342 .map(|s| s.merge_state.as_str())
4343 .filter(|m| !m.is_empty())
4344 .unwrap_or("unknown");
4345 let why = format!(
4349 "the merge was refused: {} (merge state: {merge_state})",
4350 out.1
4351 );
4352 stop(state, &repo, &pr, &why).await?;
4353 return Ok(pr);
4354 }
4355 verdict => {
4356 if waited >= WAIT_CEILING {
4361 let why = format!(
4362 "the merge was still refused after {} minutes: {}",
4363 WAIT_CEILING.as_secs() / 60,
4364 out.1
4365 );
4366 stop(state, &repo, &pr, &why).await?;
4367 return Ok(pr);
4368 }
4369 if verdict == Refused::Recheck {
4370 rechecked = true;
4371 }
4372 state.event(
4373 "land",
4374 "merge refused while the branch policy is not satisfied yet; waiting",
4375 );
4376 state.save()?;
4377 waited += POLL;
4378 forge.poll().await;
4379 }
4380 }
4381 }
4382 Step::Rebase => {
4383 if rebases >= budget {
4389 let why = format!(
4390 "the base moved under this branch {budget} time(s) and it still does \
4391 not merge; rebasing again would only race it"
4392 );
4393 stop(state, &repo, &pr, &why).await?;
4394 return Ok(pr);
4395 }
4396 rebases += 1;
4397 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4398 stop(
4399 state,
4400 &repo,
4401 &pr,
4402 "the pull request conflicts and this run has no winning branch to rebase",
4403 )
4404 .await?;
4405 return Ok(pr);
4406 };
4407 let base = state.base_branch.clone();
4408 state.event(
4409 "land",
4410 format!("{} no longer merges; rebasing onto {base}", pr.url),
4411 );
4412 state.save()?;
4413
4414 let remote = state.config.merge.remote.clone();
4418 git::fetch(&repo, &remote, &base).await.ok();
4419 if owner_stop(state, &mut pr, forge).await? {
4420 return Ok(pr);
4421 }
4422 let scratch = state.dir().join("rebase");
4423 let onto = format!("{remote}/{base}");
4424 let rebased =
4425 match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
4426 Ok(crate::rebase::Rebased::Applied) => Ok(None),
4427 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4428 Err(e) => Err(e),
4429 };
4430 if owner_stop(state, &mut pr, forge).await? {
4431 return Ok(pr);
4432 }
4433 match rebased {
4434 Ok(None) => {
4435 let pushed = {
4436 let merge_lock = repo_merge_lock(&repo);
4437 let _merge_slot = merge_lock.lock().await;
4438 if let Some(head) = &state.pr_head {
4439 git::push_as(
4440 &repo,
4441 &state.config.merge.remote,
4442 &branch,
4443 head,
4444 state
4445 .pr_pushed_head
4446 .as_deref()
4447 .context("missing public head lease")?,
4448 )
4449 .await?
4450 } else {
4451 git::push_pinned(
4452 &repo,
4453 &state.config.merge.remote,
4454 &branch,
4455 &seen.head,
4456 )
4457 .await?
4458 }
4459 };
4460 if !pushed.ok() {
4461 let why = format!(
4462 "rebased {branch} but could not push it: {}",
4463 pushed.stderr.trim()
4464 );
4465 stop(state, &repo, &pr, &why).await?;
4466 return Ok(pr);
4467 }
4468 let head =
4471 match git::rev_parse(&repo, &format!("refs/heads/{branch}")).await {
4472 Ok(head) => head,
4473 Err(e) => {
4474 let why = format!(
4475 "rebased and pushed {branch} but could not read the pushed \
4476 commit: {e:#}"
4477 );
4478 stop(state, &repo, &pr, &why).await?;
4479 return Ok(pr);
4480 }
4481 };
4482 crate::graph::refresh_reviewed_commits(state, &branch).await;
4483 state.pr_pushed_head = Some(head.clone());
4484 awaiting_head = Some(head);
4485 rechecked = false;
4486 state.event("land", format!("rebased {branch} onto {base}"));
4487 state.save()?;
4488 waited = Duration::ZERO;
4491 tokio::time::sleep(POLL).await;
4492 }
4493 Ok(Some(conflict)) => {
4496 let why = format!(
4497 "{} conflicts with {base} and the rebase did not apply: {}",
4498 pr.url,
4499 conflict.chars().take(600).collect::<String>()
4500 );
4501 stop(state, &repo, &pr, &why).await?;
4502 return Ok(pr);
4503 }
4504 Err(e) => {
4505 let why = format!("could not rebase {branch} onto {base}: {e:#}");
4506 stop(state, &repo, &pr, &why).await?;
4507 return Ok(pr);
4508 }
4509 }
4510 }
4511 Step::GiveUp { reason } => {
4512 stop(state, &repo, &pr, &reason).await?;
4513 return Ok(pr);
4514 }
4515 Step::Fix { reason } => {
4516 round += 1;
4517 waited = Duration::ZERO;
4518 for c in &pr.review_comments {
4519 shown.insert(c.body.clone());
4520 }
4521 state.event("land", format!("round {round}: {reason}"));
4522 state.save()?;
4523
4524 let logs = failing_logs(&repo, &seen.failing_urls).await;
4525 let was_red = pr.checks == Checks::Red;
4526 if owner_stop(state, &mut pr, forge).await? {
4527 return Ok(pr);
4528 }
4529 let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4530 if owner_stop(state, &mut pr, forge).await? {
4531 return Ok(pr);
4532 }
4533 match fixed {
4534 Fixed::Committed { head } => {
4535 state.pr_pushed_head = Some(head.clone());
4538 awaiting_head = Some(head);
4539 rechecked = false;
4540 waited = Duration::ZERO;
4541 forge.poll().await;
4542 }
4543 Fixed::Declined if was_red => {
4544 let why = format!(
4545 "the fixer produced no commit while {} check(s) were failing \
4546 ({}); stopping instead of looping on an unchanged tree",
4547 pr.failing.len(),
4548 pr.failing.join(", ")
4549 );
4550 stop(state, &repo, &pr, &why).await?;
4551 return Ok(pr);
4552 }
4553 Fixed::Declined => state.event(
4558 "land",
4559 format!("round {round}: fixer declined the comments, nothing committed"),
4560 ),
4561 Fixed::Failed(why) => {
4562 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
4563 return Ok(pr);
4564 }
4565 }
4566 state.save()?;
4567 }
4568 }
4569 }
4570}
4571
4572#[derive(Clone)]
4576struct Seen {
4577 pr: PrState,
4578 title: String,
4579 failing_urls: Vec<(String, String)>,
4580 head: String,
4582 rollup_head: String,
4585 merge_state: String,
4588 contexts: Vec<CheckInfo>,
4590 base: String,
4592}
4593
4594#[derive(Clone)]
4596struct CheckInfo {
4597 label: String,
4598 verdict: Verdict,
4599 required: Option<bool>,
4600 run: Option<String>,
4602}
4603
4604#[derive(Debug, Clone, PartialEq, Eq)]
4606enum RerunStep {
4607 Rerun(Vec<String>),
4609 Grace,
4611 Fix,
4613}
4614
4615fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
4619 contexts
4620 .iter()
4621 .filter(|c| c.verdict == Verdict::Fail && c.required != Some(false))
4622 .filter(|c| {
4624 c.run.as_ref().is_none_or(|r| {
4625 !contexts.iter().any(|o| {
4626 matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
4627 && o.run.as_ref() == Some(r)
4628 })
4629 })
4630 })
4631 .map(|c| (c.label.clone(), c.run.clone()))
4632 .collect()
4633}
4634
4635fn rerun_step(
4637 failing: &[(String, Option<String>)],
4638 record: Option<&LandReruns>,
4639 head: &str,
4640 now: i64,
4641) -> RerunStep {
4642 if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
4645 return RerunStep::Fix;
4646 }
4647 let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
4649 let mut fresh: Vec<String> = Vec::new();
4650 let mut recent = false;
4651 for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
4652 match record.and_then(|r| r.runs.get(run)) {
4653 None => {
4654 if !fresh.iter().any(|f| f == run) {
4655 fresh.push(run.to_owned());
4656 }
4657 }
4658 Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
4659 Some(_) => {}
4660 }
4661 }
4662 if !fresh.is_empty() {
4663 RerunStep::Rerun(fresh)
4664 } else if recent {
4665 RerunStep::Grace
4666 } else {
4667 RerunStep::Fix
4668 }
4669}
4670
4671async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
4675 let view = gh(
4676 repo,
4677 &[
4678 "pr".to_owned(),
4679 "view".to_owned(),
4680 pr_url.to_owned(),
4681 "--json".to_owned(),
4682 "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
4683 .to_owned(),
4684 ],
4685 )
4686 .await?;
4687 if !view.0 {
4688 bail!("gh pr view {pr_url}: {}", view.1);
4689 }
4690 let number = parse_pr(&view.1)?.number;
4691 let node = last_commit_node(repo, number).await;
4692 let mut seen = seen_from(&view.1, node.as_deref())?;
4693
4694 let inline = gh(
4695 repo,
4696 &[
4697 "api".to_owned(),
4698 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
4699 ],
4700 )
4701 .await?;
4702 if inline.0 {
4703 match parse_inline_comments(&inline.1) {
4704 Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
4705 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
4708 }
4709 } else {
4710 tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
4711 }
4712 Ok(seen)
4713}
4714
4715fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
4726 let mut pr = parse_pr(view_json)?;
4727 let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
4728
4729 let mut rollup_head = String::new();
4730 let mut failing_urls = Vec::new();
4731 let mut contexts = Vec::new();
4732 let mut checks = Checks::Unknown;
4733 let mut failing = Vec::new();
4734 if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
4735 (checks, failing) = rollup_verdict(&rollup);
4736 failing_urls = rollup
4737 .iter()
4738 .filter(|c| c.verdict() == Verdict::Fail)
4739 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
4740 .collect();
4741 contexts = rollup
4742 .iter()
4743 .map(|c| CheckInfo {
4744 label: c.label(),
4745 verdict: c.verdict(),
4746 required: c.is_required,
4747 run: c.url().and_then(run_of),
4748 })
4749 .collect();
4750 rollup_head = oid;
4751 }
4752 pr.checks = checks;
4753 pr.failing = failing;
4754
4755 Ok(Seen {
4756 pr,
4757 title: raw.title,
4758 failing_urls,
4759 head: raw.head_ref_oid,
4760 rollup_head,
4761 merge_state: raw.merge_state_status,
4762 contexts,
4763 base: raw.base_ref_name,
4764 })
4765}
4766
4767fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
4770 let v: serde_json::Value = serde_json::from_str(json).ok()?;
4771 if v.get("errors").is_some_and(|e| !e.is_null()) {
4772 return None;
4773 }
4774 let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
4775 let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
4776 let contexts = commit.pointer("/statusCheckRollup/contexts");
4777 let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
4778 return Some((oid.to_owned(), Vec::new()));
4780 };
4781 if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
4782 return None;
4783 }
4784 let nodes = contexts.get("nodes")?.as_array()?;
4785 let rollup = nodes
4786 .iter()
4787 .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
4788 .collect::<Result<Vec<_>, _>>()
4789 .ok()?;
4790 Some((oid.to_owned(), rollup))
4791}
4792
4793async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
4796 let out = gh(
4797 repo,
4798 &[
4799 "api".to_owned(),
4800 "graphql".to_owned(),
4801 "-F".to_owned(),
4802 "owner={owner}".to_owned(),
4803 "-F".to_owned(),
4804 "repo={repo}".to_owned(),
4805 "-F".to_owned(),
4806 format!("number={number}"),
4807 "-f".to_owned(),
4808 "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
4809 name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
4810 statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
4811 ... on CheckRun{name status conclusion detailsUrl \
4812 isRequired(pullRequestNumber:$number)} \
4813 ... on StatusContext{context state targetUrl \
4814 isRequired(pullRequestNumber:$number)}}}}}}}}}}"
4815 .to_owned(),
4816 ],
4817 )
4818 .await
4819 .ok()?;
4820 out.0.then_some(out.1)
4821}
4822
4823#[doc(hidden)]
4825#[derive(Debug, PartialEq)]
4826pub enum Fixed {
4827 Committed {
4829 head: String,
4831 },
4832 Declined,
4834 Failed(String),
4836}
4837
4838#[doc(hidden)]
4844pub async fn fix_round(
4845 state: &mut RunState,
4846 pr: &PrState,
4847 round: usize,
4848 budget: usize,
4849 reason: &str,
4850 logs: &str,
4851) -> Result<Fixed> {
4852 let winner = state
4853 .winner()
4854 .cloned()
4855 .context("landing needs a winning candidate; none is recorded on this run")?;
4856 let roles = state
4857 .config
4858 .resolve_roles()
4859 .context("resolve the roster for the fix round")?;
4860 let attempts = crate::fixer::attempts(state, &roles, &winner);
4864 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4865
4866 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
4867 let mut artifacts = agent::artifacts_dir(&state.dir());
4868 if let Some(request) = state
4869 .merge_repairs
4870 .last()
4871 .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
4872 {
4873 artifacts = artifacts.join(format!("merge-repair-{}", request.question));
4874 }
4875 let prompt = if state.config.cache_dir().is_some() {
4876 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
4877 } else {
4878 prompt
4879 };
4880 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
4884 let mut last = None;
4888 for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
4889 if owner_requested_stop(state)? {
4890 return Ok(Fixed::Declined);
4891 }
4892 let mut seat = seat_of(state, &seat_key, &spec.id);
4893 let stem = if i == 0 {
4894 format!("land-{round}")
4895 } else {
4896 format!("land-{round}-{}", spec.id)
4897 };
4898 let out = agent::invoke(
4899 &spec,
4900 &mut seat,
4901 &Invocation {
4902 scope: crate::agent::InvocationScope::Repository,
4903 coordination: state.config.coordination.as_ref(),
4904 fetched_base: state.fetched_base.as_ref(),
4905 cwd: &winner.worktree,
4906 prompt: &prompt,
4907 timeout: Duration::from_secs(state.config.graph.timeout_fix),
4908 allow_write: true,
4909 unsandboxed: false,
4910 sessions: state.config.graph.sessions,
4911 artifacts: &artifacts,
4912 stem: &stem,
4913 run: &state.id,
4914 node: "land",
4915 cache_dir: state.config.cache_dir().as_deref(),
4916 attachments: &[],
4917 writable: &[],
4918 },
4919 )
4920 .await;
4921 state.seats.insert(seat.key.clone(), seat);
4922 if let Some(next) = ids.get(i + 1)
4923 && agent::chain_advances(&out)
4924 {
4925 let (class, why) = crate::fixer::failure_of(&out);
4926 crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
4927 continue;
4928 }
4929 last = Some(out);
4930 break;
4931 }
4932 let out = last.expect("the fixer chain always has an entry");
4933
4934 match out {
4935 Ok(o) if o.quota_exhausted() => {
4936 return Ok(Fixed::Failed(
4937 "rate limited (quota); the fixer could not run".to_owned(),
4938 ));
4939 }
4940 Ok(o) if !o.usable() => {
4941 return Ok(Fixed::Failed(format!(
4942 "the fixer produced nothing usable ({})",
4943 crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
4944 )));
4945 }
4946 Ok(_) => {}
4947 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4948 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
4949 }
4950
4951 if owner_requested_stop(state)? {
4952 return Ok(Fixed::Declined);
4953 }
4954
4955 let wait = crate::repo_lock::WaitPolicy::new(
4958 Duration::from_secs(state.config.graph.timeout_resource_wait),
4959 || false,
4960 );
4961 let coordination = crate::repo_lock::SeatLease::operation_with(
4962 &winner.worktree,
4963 state.config.coordination.as_ref(),
4964 &wait,
4965 )
4966 .await?;
4967 match git::rescue_commit(
4968 &winner.worktree,
4969 &format!("magi: land round {round} fixes (uncommitted work)"),
4970 )
4971 .await
4972 {
4973 Ok(r) => state.note_withheld("land", &r.withheld),
4974 Err(e)
4975 if state
4976 .merge_repairs
4977 .last()
4978 .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
4979 {
4980 return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
4981 }
4982 Err(_) => {}
4983 }
4984 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
4985 drop(coordination);
4986 if after == before {
4987 return Ok(Fixed::Declined);
4988 }
4989
4990 if owner_requested_stop(state)? {
4991 return Ok(Fixed::Declined);
4992 }
4993 crate::pr_head::ensure_owner(state)?;
4994 let remote = state.config.merge.remote.clone();
4995 let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
4996 let push = if state.pr_head.is_some() {
4997 let expected = state
4998 .pr_pushed_head
4999 .as_deref()
5000 .context("missing public head lease")?;
5001 git::push_as(
5002 &winner.worktree,
5003 &remote,
5004 &winner.branch,
5005 &public_head,
5006 expected,
5007 )
5008 .await?
5009 } else {
5010 git::push(&winner.worktree, &remote, &winner.branch).await?
5011 };
5012 if !push.ok() {
5013 return Ok(Fixed::Failed(format!(
5014 "pushing {} to {remote} failed: {}",
5015 public_head, push.stderr
5016 )));
5017 }
5018 state.event(
5019 "land",
5020 format!("round {round}: pushed a fix to {public_head}"),
5021 );
5022 state.pr_pushed_head = Some(after.clone());
5023 state.save()?;
5024 Ok(Fixed::Committed { head: after })
5025}
5026
5027pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
5029 crate::fixer::seat_for(state, key, agent)
5030}
5031
5032pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
5035 let seen = observe(&state.repo, url).await?;
5036 let winner = state.winner().context("no winning branch to repair")?;
5037 if seen.pr.state != PrLifecycle::Open || seen.head != head {
5038 anyhow::bail!(
5039 "PR is closed or its head changed; review the current head before requesting repair again"
5040 );
5041 }
5042 if git::rev_parse(&winner.worktree, "HEAD").await? != head
5043 || !git::is_clean(&winner.worktree).await?
5044 {
5045 anyhow::bail!(
5046 "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
5047 );
5048 }
5049 let view = gh(
5052 &state.repo,
5053 &[
5054 "pr".into(),
5055 "view".into(),
5056 url.into(),
5057 "--json".into(),
5058 "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
5059 ],
5060 )
5061 .await?;
5062 if !view.0 {
5063 bail!("cannot check auto-merge: {}", view.1);
5064 }
5065 let live: serde_json::Value = serde_json::from_str(&view.1)?;
5066 let public = state.pr_branch().unwrap_or(&winner.branch);
5067 if live["state"] != "OPEN"
5068 || live["headRefOid"] != head
5069 || live["headRefName"] != public
5070 || live["baseRefName"] != state.base_branch
5071 || live["isCrossRepository"] != false
5072 {
5073 bail!("PR changed during repair preflight; review its current head explicitly");
5074 }
5075 let auto_merge = live
5076 .get("autoMergeRequest")
5077 .context("cannot read the PR auto-merge reservation; repair held")?;
5078 if !auto_merge.is_null() {
5079 let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
5080 if !out.0 {
5081 bail!("cannot disable auto-merge; repair held: {}", out.1);
5082 }
5083 }
5084 Ok(seen.pr)
5085}
5086
5087fn fix_prompt(
5089 state: &RunState,
5090 pr: &PrState,
5091 round: usize,
5092 budget: usize,
5093 reason: &str,
5094 logs: &str,
5095) -> String {
5096 let mut s = format!(
5097 "Your patch is open as a pull request and it is not landing. Land round \
5098 {round} of {budget}.\n\n\
5099 Pull request: {}\n\n\
5100 What is holding it: {reason}\n\n\
5101 # The task\n\n{}\n",
5102 pr.url, state.instruction
5103 );
5104
5105 if pr.failing.is_empty() {
5106 s.push_str("\n# Failing checks\n\n(none)\n");
5107 } else {
5108 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
5109 if logs.trim().is_empty() {
5110 s.push_str(
5111 "\nNo log could be read; investigate the failure with necessary focused checks.\n",
5112 );
5113 } else {
5114 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
5115 }
5116 }
5117
5118 if pr.review_comments.is_empty() {
5119 s.push_str("\n# Review comments\n\n(none)\n");
5120 } else {
5121 s.push_str("\n# Review comments\n");
5122 for c in &pr.review_comments {
5123 let where_ = match (&c.path, c.line) {
5124 (Some(p), Some(l)) => format!(" ({p}:{l})"),
5125 (Some(p), None) => format!(" ({p})"),
5126 _ => String::new(),
5127 };
5128 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
5129 }
5130 }
5131
5132 s.push_str(
5133 "\n# Rules\n\n\
5134 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
5135 failing test; do not silence a lint with an allow attribute; do not \
5136 stretch a timeout to hide a race. If the check is right, the code is \
5137 wrong.\n\
5138 2. Change nothing the checks and the comments did not raise. A \
5139 drive-by refactor turns a one-line fix into a pull request that \
5140 needs reviewing again.\n\
5141 3. If a comment is wrong, say so with a checkable argument and change \
5142 nothing for it. A declined comment with a reason is a correct \
5143 outcome; a change made to appease a reviewer is not.\n\
5144 4. Commit in this worktree. magi pushes to the pull request's branch \
5145 for you; do not push, merge, or close anything yourself.\n\
5146 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
5147 # Output\n\n\
5148 Say what you changed and why, and what you declined and why.",
5149 );
5150
5151 let language = &state.config.graph.language;
5152 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
5153 let _ = write!(s, "\n\nWrite all prose in {language}.");
5154 }
5155 s.push_str(&crate::prompt::github_rule(
5157 language,
5158 &state.config.graph.github_language,
5159 ));
5160 if let Some(overlay) = state.config.prompts.overlay("fix") {
5161 let _ = write!(s, "\n\n{overlay}");
5162 }
5163 s.push('\n');
5164 s.push_str(crate::prompt::child_run_ownership());
5165 s
5166}
5167
5168async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
5171 let mut out = String::new();
5172 for (name, url) in failing.iter().take(MAX_LOGS) {
5173 let args = match (job_of(url), run_of(url)) {
5174 (Some(job), _) => vec![
5175 "run".to_owned(),
5176 "view".to_owned(),
5177 "--log-failed".to_owned(),
5178 "--job".to_owned(),
5179 job,
5180 ],
5181 (None, Some(run)) => vec![
5182 "run".to_owned(),
5183 "view".to_owned(),
5184 run,
5185 "--log-failed".to_owned(),
5186 ],
5187 (None, None) => continue,
5189 };
5190 let (ok, body) = match gh(repo, &args).await {
5191 Ok(v) => v,
5192 Err(e) => (false, format!("{e:#}")),
5193 };
5194 if !ok && body.trim().is_empty() {
5195 continue;
5196 }
5197 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
5198 }
5199 out
5200}
5201
5202fn job_of(details_url: &str) -> Option<String> {
5205 let after = details_url.split("/job/").nth(1)?;
5206 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5207 (!id.is_empty()).then_some(id)
5208}
5209
5210pub(crate) fn run_of(details_url: &str) -> Option<String> {
5212 let after = details_url.split("/actions/runs/").nth(1)?;
5213 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5214 (!id.is_empty()).then_some(id)
5215}
5216
5217fn stop_comment(run_id: &str, why: &str) -> String {
5221 format!(
5222 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
5223 The branch is untouched and the run is `{run_id}`. Nothing was merged."
5224 )
5225}
5226
5227async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
5232 let body = stop_comment(&state.id, why);
5233 let (_, mut body) = crate::github_text::prepare(state, "", &body);
5234 if !body.contains(MARKER) {
5235 body = format!("{MARKER}\n{body}");
5236 }
5237 let posted = gh(
5238 repo,
5239 &[
5240 "pr".to_owned(),
5241 "comment".to_owned(),
5242 pr.number.to_string(),
5243 "--body".to_owned(),
5244 body,
5245 ],
5246 )
5247 .await;
5248 match posted {
5249 Ok((true, _)) => {}
5250 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
5251 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
5252 }
5253 state.status = RunStatus::Blocked;
5254 state.merge = Some(MergeOutcome {
5255 mode: MergeMode::Pr,
5256 ok: false,
5257 detail: why.to_owned(),
5258 empty: false,
5259 });
5260 state.event("land", format!("stopped: {why}"));
5261 state.save()?;
5262 Ok(())
5263}
5264
5265pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
5281 let out = tokio::process::Command::new("gh")
5282 .args(args)
5283 .current_dir(cwd)
5284 .env_remove("GH_REPO")
5285 .quiet()
5286 .stdin(std::process::Stdio::null())
5287 .output()
5288 .await
5289 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
5290 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
5291 let err = String::from_utf8_lossy(&out.stderr);
5292 if body.trim().is_empty() {
5293 body = err.into_owned();
5294 } else if !err.trim().is_empty() {
5295 body.push_str(&err);
5296 }
5297 Ok((out.status.success(), body.trim().to_owned()))
5298}
5299
5300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5302pub(crate) enum Verdict {
5303 Pass,
5304 Fail,
5305 Pending,
5306 Unknown,
5307}
5308
5309impl Verdict {
5310 pub(crate) fn label(self) -> &'static str {
5312 match self {
5313 Self::Pass => "passed",
5314 Self::Fail => "failed",
5315 Self::Pending => "pending",
5316 Self::Unknown => "unknown",
5317 }
5318 }
5319}
5320
5321#[derive(Debug, Deserialize)]
5322#[serde(rename_all = "camelCase")]
5323struct GhPr {
5324 #[serde(default)]
5325 url: String,
5326 #[serde(default)]
5327 number: u64,
5328 #[serde(default)]
5329 state: String,
5330 #[serde(default)]
5331 title: String,
5332 #[serde(default)]
5333 status_check_rollup: Vec<GhCheck>,
5334 #[serde(default)]
5341 merge_state_status: String,
5342 #[serde(default)]
5346 head_ref_oid: String,
5347 #[serde(default)]
5348 base_ref_name: String,
5349 #[serde(default)]
5350 reviews: Vec<GhReview>,
5351 #[serde(default)]
5352 comments: Vec<GhComment>,
5353}
5354
5355#[derive(Debug, Deserialize)]
5360#[serde(rename_all = "camelCase")]
5361struct GhCheck {
5362 #[serde(default)]
5363 name: Option<String>,
5364 #[serde(default)]
5365 context: Option<String>,
5366 #[serde(default)]
5367 status: Option<String>,
5368 #[serde(default)]
5369 conclusion: Option<String>,
5370 #[serde(default)]
5371 state: Option<String>,
5372 #[serde(default)]
5373 details_url: Option<String>,
5374 #[serde(default)]
5375 target_url: Option<String>,
5376 #[serde(default)]
5379 is_required: Option<bool>,
5380}
5381
5382impl GhCheck {
5383 fn label(&self) -> String {
5385 self.name
5386 .clone()
5387 .or_else(|| self.context.clone())
5388 .unwrap_or_else(|| "(unnamed check)".to_owned())
5389 }
5390
5391 fn url(&self) -> Option<&str> {
5393 self.details_url
5394 .as_deref()
5395 .or(self.target_url.as_deref())
5396 .filter(|u| !u.is_empty())
5397 }
5398
5399 fn verdict(&self) -> Verdict {
5407 if let Some(status) = self.status.as_deref() {
5408 if !status.eq_ignore_ascii_case("COMPLETED") {
5409 return Verdict::Pending;
5410 }
5411 }
5412 let outcome = self
5413 .conclusion
5414 .as_deref()
5415 .or(self.state.as_deref())
5416 .unwrap_or("");
5417 match outcome.to_ascii_uppercase().as_str() {
5418 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
5419 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
5420 | "ACTION_REQUIRED" => Verdict::Fail,
5421 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
5422 Verdict::Pending
5423 }
5424 _ => Verdict::Unknown,
5425 }
5426 }
5427}
5428
5429#[derive(Debug, Deserialize)]
5430struct GhAuthor {
5431 #[serde(default)]
5432 login: String,
5433}
5434
5435#[derive(Debug, Deserialize)]
5436struct GhReview {
5437 #[serde(default)]
5438 author: GhAuthor,
5439 #[serde(default)]
5440 body: String,
5441}
5442
5443#[derive(Debug, Deserialize)]
5444struct GhComment {
5445 #[serde(default)]
5446 author: GhAuthor,
5447 #[serde(default)]
5448 body: String,
5449}
5450
5451#[derive(Debug, Deserialize)]
5452struct GhUser {
5453 #[serde(default)]
5454 login: String,
5455}
5456
5457#[derive(Debug, Deserialize)]
5458struct GhInline {
5459 #[serde(default)]
5460 user: GhUser,
5461 #[serde(default)]
5462 path: Option<String>,
5463 #[serde(default)]
5464 line: Option<u64>,
5465 #[serde(default)]
5466 body: String,
5467}
5468
5469impl Default for GhAuthor {
5470 fn default() -> Self {
5471 Self {
5472 login: "(unknown)".to_owned(),
5473 }
5474 }
5475}
5476
5477impl Default for GhUser {
5478 fn default() -> Self {
5479 Self {
5480 login: "(unknown)".to_owned(),
5481 }
5482 }
5483}
5484
5485#[cfg(test)]
5486pub(crate) mod tests {
5487 use super::*;
5488 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
5489
5490 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
5491 format!(
5492 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
5493 )
5494 }
5495
5496 #[test]
5497 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
5498 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
5499 assert_eq!(
5500 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
5501 Ok(())
5502 );
5503 for (json, why) in [
5504 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
5505 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
5506 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
5507 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
5508 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
5509 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
5510 ] {
5511 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
5512 .unwrap_err()
5513 .why;
5514 assert!(err.contains(why), "{json}: {err}");
5515 }
5516 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
5518 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
5519 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
5520 assert!(
5521 !closable(
5522 &head_json("x", "main", "OPEN", false),
5523 "magi/27b2/A",
5524 "main",
5525 &[]
5526 )
5527 .unwrap_err()
5528 .retry
5529 );
5530 assert!(is_forge_url("https://github.com/o/r.git"));
5531 assert!(is_forge_url("git@github.com:o/r.git"));
5532 assert!(!is_forge_url("/tmp/origin.git"));
5533 assert!(!is_forge_url("C:\\work\\origin.git"));
5534 assert!(!is_forge_url("file:///tmp/origin.git"));
5535 assert!(forge_unavailable(
5536 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
5537 ));
5538 assert!(!forge_unavailable(
5539 "gh pr list failed: error connecting to api.github.com"
5540 ));
5541 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
5542 assert!(
5544 closable(
5545 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
5546 "b",
5547 "main",
5548 &["aaa".to_owned()]
5549 )
5550 .is_err()
5551 );
5552 }
5553
5554 #[test]
5555 fn the_close_comment_names_the_commit_on_the_base() {
5556 let e = crate::already::Evidence {
5557 proof: crate::already::Proof::PatchId,
5558 tip: "1234567890".to_owned(),
5559 commits: vec!["0e368de0000".to_owned()],
5560 };
5561 let c = superseded_comment("main", &e);
5562 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
5563 }
5564
5565 const GREEN_OPEN: &str = r####"{
5567 "url": "https://github.com/yukimemi/magi/pull/10",
5568 "number": 10,
5569 "state": "OPEN",
5570 "mergeStateStatus": "CLEAN",
5571 "statusCheckRollup": [
5572 {
5573 "__typename": "CheckRun",
5574 "conclusion": "SKIPPED",
5575 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
5576 "name": "review",
5577 "status": "COMPLETED",
5578 "workflowName": "claude-review"
5579 },
5580 {
5581 "__typename": "CheckRun",
5582 "conclusion": "SUCCESS",
5583 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
5584 "name": "check (ubuntu-latest)",
5585 "status": "COMPLETED",
5586 "workflowName": "CI"
5587 },
5588 {
5589 "__typename": "CheckRun",
5590 "conclusion": "SUCCESS",
5591 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
5592 "name": "rustfmt",
5593 "status": "COMPLETED",
5594 "workflowName": "CI"
5595 },
5596 {
5597 "__typename": "StatusContext",
5598 "context": "CodeRabbit",
5599 "state": "SUCCESS",
5600 "targetUrl": ""
5601 }
5602 ],
5603 "reviews": [],
5604 "comments": [
5605 {
5606 "author": {
5607 "login": "coderabbitai"
5608 },
5609 "authorAssociation": "NONE",
5610 "body": "<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- This is an auto-generated comment: skip review by coderabbit.ai -->\n\n> [!IMPORTANT]\n> - [ ] <!-- {\"checkboxId\":\"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe\"} --> 🔍 Trigger review\n> \n> This repository does not receive automatic reviews because it has fewer than 10 stars.\n> \n> <details>\n> <summary>⚙️ Run configuration</summary>\n> \n> **Configuration used**: defaults\n> \n> **Review profile**: CHILL\n> \n> **Plan**: Pro Plus\n> \n> **Run ID**: `78e70bf3-c5a0-4269-a96c-2afb2dba7eff`\n> \n> </details>\n\n<!-- end of auto-generated comment: skip review by coderabbit.ai -->\n\n<!-- tips_start -->\n\n---\n\nThanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=yukimemi/magi&utm_content=10)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.\n\n<details>\n<summary>❤️ Share</summary>\n\n- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%2"
5611 }
5612 ]
5613}"####;
5614
5615 const RED_OPEN: &str = r####"{
5617 "url": "https://github.com/yukimemi/magi/pull/9",
5618 "number": 9,
5619 "state": "OPEN",
5620 "mergeStateStatus": "UNSTABLE",
5621 "statusCheckRollup": [
5622 {
5623 "__typename": "CheckRun",
5624 "conclusion": "SUCCESS",
5625 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5626 "name": "check (ubuntu-latest)",
5627 "status": "COMPLETED",
5628 "workflowName": "CI"
5629 },
5630 {
5631 "__typename": "CheckRun",
5632 "conclusion": "SUCCESS",
5633 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5634 "name": "rustfmt",
5635 "status": "COMPLETED",
5636 "workflowName": "CI"
5637 },
5638 {
5639 "__typename": "CheckRun",
5640 "conclusion": "FAILURE",
5641 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5642 "name": "editorconfig",
5643 "status": "COMPLETED",
5644 "workflowName": "CI"
5645 },
5646 {
5647 "__typename": "StatusContext",
5648 "context": "CodeRabbit",
5649 "state": "SUCCESS",
5650 "targetUrl": ""
5651 }
5652 ],
5653 "reviews": [],
5654 "comments": [
5655 {
5656 "author": {
5657 "login": "coderabbitai"
5658 },
5659 "authorAssociation": "NONE",
5660 "body": "<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- This is an auto-generated comment: skip review by coderabbit.ai -->\n\n> [!IMPORTANT]\n> - [ ] <!-- {\"checkboxId\":\"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe\"} --> 🔍 Trigger review\n> \n> This repository does not receive automatic reviews because it has fewer than 10 stars.\n> \n> <details>\n> <summary>⚙️ Run configuration</summary>\n> \n> **Configuration used**: defaults\n> \n> **Review profile**: CHILL\n> \n> **Plan**: Team\n> \n> **Run ID**: `91e0dc24-6040-4c3d-92c6-f7d2b542523d`\n> \n> </details>\n\n<!-- end of auto-generated comment: skip review by coderabbit.ai -->\n\n<!-- tips_start -->\n\n---\n\nThanks for using [CodeRabbit](https://coderab"
5661 }
5662 ]
5663}"####;
5664
5665 const PENDING_OPEN: &str = r####"{
5667 "url": "https://github.com/yukimemi/magi/pull/9",
5668 "number": 9,
5669 "state": "OPEN",
5670 "statusCheckRollup": [
5671 {
5672 "__typename": "CheckRun",
5673 "conclusion": "SUCCESS",
5674 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5675 "name": "check (ubuntu-latest)",
5676 "status": "COMPLETED",
5677 "workflowName": "CI"
5678 },
5679 {
5680 "__typename": "CheckRun",
5681 "conclusion": "SUCCESS",
5682 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5683 "name": "rustfmt",
5684 "status": "COMPLETED",
5685 "workflowName": "CI"
5686 },
5687 {
5688 "__typename": "CheckRun",
5689 "conclusion": null,
5690 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5691 "name": "editorconfig",
5692 "status": "IN_PROGRESS",
5693 "workflowName": "CI"
5694 },
5695 {
5696 "__typename": "StatusContext",
5697 "context": "CodeRabbit",
5698 "state": "SUCCESS",
5699 "targetUrl": ""
5700 }
5701 ],
5702 "reviews": [],
5703 "comments": []
5704}"####;
5705
5706 const MERGED: &str = r####"{
5708 "url": "https://github.com/yukimemi/magi/pull/16",
5709 "number": 16,
5710 "state": "MERGED",
5711 "statusCheckRollup": [
5712 {
5713 "__typename": "CheckRun",
5714 "conclusion": "SUCCESS",
5715 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
5716 "name": "check (ubuntu-latest)",
5717 "status": "COMPLETED",
5718 "workflowName": "CI"
5719 },
5720 {
5721 "__typename": "CheckRun",
5722 "conclusion": "SUCCESS",
5723 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
5724 "name": "review",
5725 "status": "COMPLETED",
5726 "workflowName": "claude-review"
5727 }
5728 ],
5729 "reviews": [],
5730 "comments": []
5731}"####;
5732
5733 const REVIEWED_OPEN: &str = r####"{
5735 "url": "https://github.com/yukimemi/magi/pull/12",
5736 "number": 12,
5737 "state": "OPEN",
5738 "statusCheckRollup": [
5739 {
5740 "__typename": "CheckRun",
5741 "conclusion": "SUCCESS",
5742 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
5743 "name": "check (ubuntu-latest)",
5744 "status": "COMPLETED",
5745 "workflowName": "CI"
5746 },
5747 {
5748 "__typename": "CheckRun",
5749 "conclusion": "SUCCESS",
5750 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
5751 "name": "review",
5752 "status": "COMPLETED",
5753 "workflowName": "claude-review"
5754 }
5755 ],
5756 "reviews": [
5757 {
5758 "author": {
5759 "login": "claude"
5760 },
5761 "state": "COMMENTED",
5762 "body": ""
5763 }
5764 ],
5765 "comments": [
5766 {
5767 "author": {
5768 "login": "coderabbitai"
5769 },
5770 "authorAssociation": "NONE",
5771 "body": "<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- This is an auto-generated comment: skip review by coderabbit.ai -->\n\n> [!IMPORTANT]\n> - [ ] <!-- {\"checkboxId\":\"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe\"} --> 🔍 Trigger review\n> \n> This repository does not receive automatic reviews because it has fewer than 10 stars.\n> \n> <details>\n> <summary>⚙️ Run configuration</summary>\n> \n> **Configuration used**: defaults\n> \n> **Review profile**: CHILL\n> \n> **Plan**: Team\n> \n> **Run ID**: `72058bf3-b7df-41d9-8e4d-a06a31be4a26`\n> \n> </details>\n\n<!-- end of auto-generated comment: skip review by coderabbit.ai -->\n\n<!-- tips_start -->\n\n---\n\nThanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=yukimemi/magi&utm_content=12)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.\n\n<details>\n<summa"
5772 },
5773 {
5774 "author": {
5775 "login": "claude"
5776 },
5777 "authorAssociation": "NONE",
5778 "body": "**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)\n\n---\n### Review: `magi review <branch>` — cheap-half-only graph\n\nRead through `src/graph.rs`, `src/main.rs`, `src/prompt.rs`, and the new/edited tests, and traced the claimed degeneration (`prep` short-circuits on a non-empty candidate list, `implement` skips because `commits != 0`, `judge`/`vote` skip on `viable().len() == 1`, `tally` skips because it's pre-set, `fold_losers` has no losers) against the actual code — it holds up. CI (`cargo make check`) is green on this PR.\n\n**Correctness**\n\n- One real bug, flagged inline on `src/graph.rs:1255`: the fixer-agent fallback (`self.roles.implementers[winner.index].clone()`) is unreachable in the normal graph (a real candidate's `winner.agent` always resolves via `config.agent(...)`), but a review-only run's `winner.agent` is always the `\"(existing branch)\"` sentinel, so this fallback now runs on *every* review-only fix that has no dedicated `[roles] fixer`. `graph.candidates` has no lower-bound validation, so a `magi.toml` tuned for review-only use (`candidates = 0`, plausible given this PR's own cost rationale) would panic with an out-of-bounds index the first time a"
5779 }
5780 ]
5781}"####;
5782
5783 const INLINE: &str = r####"[
5785 {
5786 "user": {
5787 "login": "claude[bot]"
5788 },
5789 "path": "src/graph.rs",
5790 "line": 231,
5791 "body": "Minor edge case: unlike `implement()` (which sets `c.empty = commits == 0 || patch.trim().is_empty()`, `src/graph.rs:472`), the seeded review-only candidate always sets `empty: false` once `commits > 0` is confirmed, without checking whether the diff itself is actually empty (e.g. a commit immediately followed by a revert nets zero file changes). Such a branch would pass `Runner::review`'s validation and proceed into a review round with an empty patch, where `implement()`'s equivalent path would"
5792 }
5793]"####;
5794
5795 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
5797<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
5798
5799> [!IMPORTANT]
5800> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
5801>
5802> This repository does not receive automatic reviews because it has fewer than 10 stars.
5803>
5804> <details>
5805> <summary>⚙️ Run configuration</summary>
5806>
5807> **Configuration used**: defaults
5808>
5809> **Review profile**: CHILL
5810>
5811> **Plan**: Team
5812>
5813> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
5814>
5815> </details>
5816
5817<!-- end of auto-generated comment: skip review by coderabbit.ai -->
5818
5819<!-- tips_start -->
5820
5821---
5822
5823Thanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=yukimemi/magi&utm_content=16)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
5824
5825<details>
5826<summary>❤️ Share</summary>
5827
5828- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20off"####;
5829
5830 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
5832
5833---
5834### Reviewing PR #16
5835
5836- [x] Read AGENTS.md conventions
5837- [x] Review `src/daemon.rs` changes
5838- [x] Review `src/main.rs` changes (new `doctor` reporting)
5839- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
5840- [x] Check test coverage for new behavior
5841- [x] Run verification commands (blocked — see note)
5842- [x] Post findings"####;
5843
5844 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
5846
5847---
5848### Review: `magi review <branch>` — cheap-half-only graph
5849
5850Read through `src/graph.rs`, `src/main.rs`, `src/prompt.rs`, and the new/edited tests, and traced the claimed degeneration (`prep` short-circuits on a non-empty candidate list, `implement` skips because `commits != 0`, `judge`/`vote` skip on `viable().len() == 1`, `tally` skips because it's pre-set, `fold_losers` has no losers) against the actual code — it holds up. CI (`cargo make check`) is green on this PR.
5851
5852**Correctness**
5853
5854- One real bug, flagged inline on `src/graph.rs:1255`: the fixer-agent fallback (`self.roles.implementers[winner.index].clone()`) is unreachable in the normal graph (a real candidate's `winner.agent` always resolves via `config.agent(...)`), but a review-only run's `winner.agent` is always the `"(existing branch)"` sentinel, so this fallback now runs on *every* review-only fix that has no dedicated `[roles] fixer`. `graph.candidates` has no lower-bound validation, so a `magi.toml` tuned for review-only use (`candidates = 0`, plausible given this PR's own cost rationale) would panic with an out-of-bounds index the first time a"####;
5855
5856 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
5857 PrState {
5858 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
5859 number: 16,
5860 state: PrLifecycle::Open,
5861 checks,
5862 blocking: if matches!(checks, Checks::Red) {
5866 Blocking::Yes
5867 } else {
5868 Blocking::No
5869 },
5870 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
5871 review_comments: (0..comments)
5872 .map(|i| ReviewComment {
5873 author: "coderabbitai".to_owned(),
5874 path: Some("src/graph.rs".to_owned()),
5875 line: Some(231),
5876 body: format!("finding {i}"),
5877 })
5878 .collect(),
5879 }
5880 }
5881
5882 #[test]
5883 fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
5884 use CiExpectation::{Absent, Expected};
5885 for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
5886 let p = pr(checks, &["x"], 0);
5887 for waited in [Duration::ZERO, CHECKS_GRACE] {
5888 assert_eq!(
5889 decide_with(&p, 0, 4, waited, Expected),
5890 decide(&p, 0, 4, waited)
5891 );
5892 }
5893 }
5894 for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
5896 let p = pr(checks, &["x"], 0);
5897 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
5898 assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
5899 }
5900 let mut p = pr(Checks::Unknown, &[], 0);
5902 p.blocking = Blocking::Conflict;
5903 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
5904 p.state = PrLifecycle::Merged;
5906 assert_eq!(
5907 decide_with(&p, 0, 4, Duration::ZERO, Absent),
5908 Step::Done { merged: true }
5909 );
5910 }
5911
5912 #[test]
5913 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
5914 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
5915 assert_eq!(state.number, 10);
5916 assert_eq!(state.state, PrLifecycle::Open);
5917 assert_eq!(state.checks, Checks::Green);
5918 assert!(state.failing.is_empty());
5919 assert!(
5920 state.review_comments.is_empty(),
5921 "the only comment is CodeRabbit's trigger notice: {:?}",
5922 state.review_comments
5923 );
5924 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
5925 }
5926
5927 #[test]
5928 fn a_failing_check_parses_as_red_and_is_named() {
5929 let state = parse_pr(RED_OPEN).expect("red fixture parses");
5930 assert_eq!(state.checks, Checks::Red);
5931 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
5932 let mut blocking = state.clone();
5939 blocking.blocking = Blocking::Yes;
5940 match decide(&blocking, 0, 4, Duration::ZERO) {
5941 Step::Fix { reason } => {
5942 assert!(reason.contains("editorconfig"), "reason: {reason}");
5943 assert!(reason.contains("failing"), "reason: {reason}");
5944 }
5945 other => panic!("expected a fix round, got {other:?}"),
5946 }
5947 }
5948
5949 #[test]
5950 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
5951 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
5952 assert_eq!(state.checks, Checks::Pending);
5953 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
5954 }
5955
5956 #[test]
5957 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
5958 let state = parse_pr(MERGED).expect("merged fixture parses");
5959 assert_eq!(state.state, PrLifecycle::Merged);
5960 assert_eq!(
5961 decide(&state, 0, 4, Duration::ZERO),
5962 Step::Done { merged: true }
5963 );
5964 }
5965
5966 #[test]
5967 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
5968 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
5969 assert_eq!(state.checks, Checks::Green);
5970 let authors: Vec<&str> = state
5971 .review_comments
5972 .iter()
5973 .map(|c| c.author.as_str())
5974 .collect();
5975 assert_eq!(
5976 authors,
5977 vec!["claude"],
5978 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
5979 );
5980 match decide(&state, 0, 4, Duration::ZERO) {
5981 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
5982 other => panic!("expected a fix round, got {other:?}"),
5983 }
5984 }
5985
5986 #[test]
5987 fn inline_review_comments_keep_their_file_and_line() {
5988 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
5989 assert_eq!(comments.len(), 1);
5990 assert_eq!(comments[0].author, "claude[bot]");
5991 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
5992 assert_eq!(comments[0].line, Some(231));
5993 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
5994 }
5995
5996 #[test]
5997 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
5998 assert!(
5999 is_noise(CODERABBIT_TRIGGER),
6000 "CodeRabbit's trigger notice declares itself not a review"
6001 );
6002 assert!(
6003 is_noise(CLAUDE_CHECKLIST),
6004 "a progress checklist asks for nothing"
6005 );
6006 assert!(
6007 !is_noise(CLAUDE_FINDING),
6008 "a review that names a bug is input, not noise"
6009 );
6010
6011 let mut clean = pr(Checks::Green, &[], 0);
6012 clean.review_comments.push(ReviewComment {
6013 author: "coderabbitai".to_owned(),
6014 path: None,
6015 line: None,
6016 body: CODERABBIT_TRIGGER.to_owned(),
6017 });
6018 clean.review_comments.retain(|c| !is_noise(&c.body));
6019 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
6020
6021 let mut found = pr(Checks::Green, &[], 0);
6022 found.review_comments.push(ReviewComment {
6023 author: "claude".to_owned(),
6024 path: None,
6025 line: None,
6026 body: CLAUDE_FINDING.to_owned(),
6027 });
6028 found.review_comments.retain(|c| !is_noise(&c.body));
6029 assert!(matches!(
6030 decide(&found, 0, 4, Duration::ZERO),
6031 Step::Fix { .. }
6032 ));
6033 }
6034
6035 #[test]
6036 fn the_policy_table_holds_for_every_combination_that_matters() {
6037 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
6038 (
6039 "pending checks are waited for, even on the last round",
6040 pr(Checks::Pending, &[], 0),
6041 4,
6042 4,
6043 Duration::ZERO,
6044 Step::Wait,
6045 ),
6046 (
6047 "red checks are fixed",
6048 pr(Checks::Red, &["editorconfig"], 0),
6049 0,
6050 4,
6051 Duration::ZERO,
6052 Step::Fix {
6053 reason: "1 check(s) failing: editorconfig".to_owned(),
6054 },
6055 ),
6056 (
6057 "green with comments is fixed, not merged",
6058 pr(Checks::Green, &[], 2),
6059 1,
6060 4,
6061 Duration::ZERO,
6062 Step::Fix {
6063 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
6064 .to_owned(),
6065 },
6066 ),
6067 (
6068 "green and clean merges",
6069 pr(Checks::Green, &[], 0),
6070 3,
6071 4,
6072 Duration::ZERO,
6073 Step::Merge,
6074 ),
6075 (
6076 "an unreadable rollup is waited on while the grace lasts",
6077 pr(Checks::Unknown, &[], 0),
6078 0,
6079 4,
6080 Duration::ZERO,
6081 Step::Wait,
6082 ),
6083 (
6084 "an unreadable rollup is never merged once the grace is spent",
6085 pr(Checks::Unknown, &[], 0),
6086 0,
6087 4,
6088 CHECKS_GRACE,
6089 Step::GiveUp {
6090 reason: "no check status is readable on the pull request after 3 minute(s); \
6091 refusing to merge on a guess"
6092 .to_owned(),
6093 },
6094 ),
6095 ];
6096 for (what, state, round, budget, waited, want) in cases {
6097 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
6098 }
6099 }
6100
6101 #[test]
6102 fn the_forge_verdict_survives_the_round_trip_from_gh() {
6103 let green = parse_pr(GREEN_OPEN).expect("parse");
6107 assert_eq!(green.blocking, Blocking::No);
6108 let red = parse_pr(RED_OPEN).expect("parse");
6109 assert_eq!(
6110 red.blocking,
6111 Blocking::No,
6112 "`UNSTABLE` is mergeable: the red check is one nobody requires"
6113 );
6114 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
6115 let quiet =
6117 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
6118 assert_eq!(quiet.blocking, Blocking::Unsaid);
6119 }
6120
6121 #[test]
6122 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
6123 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
6129 nonblocking.blocking = Blocking::No;
6130 assert_eq!(
6131 decide(&nonblocking, 0, 4, Duration::ZERO),
6132 Step::Merge,
6133 "the forge says nothing is in the way, so nothing is"
6134 );
6135
6136 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
6138 blocking.blocking = Blocking::Yes;
6139 assert!(matches!(
6140 decide(&blocking, 0, 4, Duration::ZERO),
6141 Step::Fix { .. }
6142 ));
6143
6144 let mut commented = pr(Checks::Red, &["coverage"], 1);
6147 commented.blocking = Blocking::No;
6148 assert!(matches!(
6149 decide(&commented, 0, 4, Duration::ZERO),
6150 Step::Fix { .. }
6151 ));
6152
6153 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
6155 unsaid.blocking = Blocking::Unsaid;
6156 assert!(matches!(
6157 decide(&unsaid, 0, 4, Duration::ZERO),
6158 Step::Fix { .. }
6159 ));
6160 }
6161
6162 #[test]
6163 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
6164 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
6165 red.blocking = Blocking::No;
6166 assert_eq!(
6167 decide(&red, 0, 4, Duration::ZERO),
6168 Step::Merge,
6169 "announcing must not change the decision"
6170 );
6171 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
6172 assert!(said.contains("yukimemi/magi"), "{said}");
6173 assert!(said.contains("#16"), "{said}");
6174 assert!(
6175 said.contains("https://github.com/yukimemi/magi/pull/16"),
6176 "{said}"
6177 );
6178 assert!(
6179 said.contains("test (windows-latest)") && said.contains("coverage"),
6180 "{said}"
6181 );
6182
6183 let green = pr(Checks::Green, &["stale"], 0);
6185 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
6186 }
6187
6188 #[test]
6189 fn red_merge_notices_omit_empty_check_details() {
6190 for checks in [vec![], vec!["", " "]] {
6191 let red = pr(Checks::Red, &checks, 0);
6192 let summary = red_merge_summary("owner/repo", &red).unwrap();
6193 let notice = crate::notices::merged_red("run-1", &summary);
6194 assert_eq!(
6195 notice.message,
6196 format!("Merged owner/repo PR #16 with red checks ({})", red.url)
6197 );
6198 assert_eq!(notice.severity, crate::notices::Severity::Warn);
6199 }
6200 let red = pr(Checks::Red, &["", " test ", " "], 0);
6201 let summary = red_merge_summary("owner/repo", &red).unwrap();
6202 assert!(summary.contains("with red checks: test ("), "{summary}");
6203 }
6204
6205 #[test]
6206 fn the_repo_label_comes_from_the_pull_request_url() {
6207 let p = Path::new("/tmp/checkout");
6208 assert_eq!(
6209 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
6210 "yukimemi/magi"
6211 );
6212 assert_eq!(repo_label(p, "not a url"), "checkout");
6213 }
6214
6215 #[test]
6216 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
6217 let mut conflicted = pr(Checks::Green, &[], 0);
6222 conflicted.blocking = Blocking::Conflict;
6223 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
6224
6225 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
6229 red.blocking = Blocking::Conflict;
6230 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
6231
6232 let mut merged = pr(Checks::Red, &[], 0);
6234 merged.blocking = Blocking::Conflict;
6235 merged.state = PrLifecycle::Merged;
6236 assert_eq!(
6237 decide(&merged, 0, 4, Duration::ZERO),
6238 Step::Done { merged: true }
6239 );
6240 }
6241
6242 #[test]
6243 fn the_forge_verdict_is_read_off_merge_state_status() {
6244 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
6247 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
6248 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
6249 }
6250 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
6251 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
6252 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
6253 for quiet in ["", "UNKNOWN"] {
6256 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
6257 assert!(Blocking::of(quiet).stops_a_merge());
6258 }
6259 }
6260
6261 #[test]
6262 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
6263 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
6264 let jj = "could not determine current branch: failed to run git: not on any branch";
6266
6267 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
6268 .expect("the forge says merged, so it merged");
6269 assert!(landed.ok);
6270 assert!(
6271 landed.detail.contains("but the pull request is merged"),
6272 "the record must not read as a clean success: {}",
6273 landed.detail
6274 );
6275 assert!(
6276 landed.detail.contains("not on any branch"),
6277 "and it must keep what the command actually said: {}",
6278 landed.detail
6279 );
6280
6281 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
6283 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
6284 assert!(merged_after_all(&argv, jj, None).is_none());
6286 }
6287
6288 #[test]
6289 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
6290 let mut state = pr(Checks::Red, &["editorconfig"], 3);
6291 state.state = PrLifecycle::Closed;
6292 assert_eq!(
6293 decide(&state, 0, 4, Duration::ZERO),
6294 Step::Done { merged: false },
6295 "a human closing the pull request ends the loop, whatever CI says"
6296 );
6297 }
6298
6299 #[test]
6300 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
6301 let red = decide(
6302 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
6303 4,
6304 4,
6305 Duration::ZERO,
6306 );
6307 match red {
6308 Step::GiveUp { reason } => {
6309 assert!(reason.contains("editorconfig"), "reason: {reason}");
6310 assert!(reason.contains("test (macos)"), "reason: {reason}");
6311 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
6312 }
6313 other => panic!("expected a give-up, got {other:?}"),
6314 }
6315
6316 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
6317 match commented {
6318 Step::GiveUp { reason } => {
6319 assert!(reason.contains("unresolved"), "reason: {reason}");
6320 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
6321 }
6322 other => panic!("expected a give-up, got {other:?}"),
6323 }
6324 }
6325
6326 #[test]
6327 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
6328 let candidate_commit = "magi: candidate A (uncommitted work)";
6329 let subject = merge_subject(candidate_commit, "add retries to the uploader");
6330 let argv = merge_argv(16, &subject);
6331
6332 assert!(argv.contains(&"--squash".to_owned()));
6333 assert!(argv.contains(&"--delete-branch".to_owned()));
6334 assert!(argv.contains(&"--subject".to_owned()));
6335 assert_eq!(
6336 argv.last().map(String::as_str),
6337 Some("add retries to the uploader"),
6338 "the subject must not be the candidate commit message"
6339 );
6340 assert_ne!(subject, candidate_commit);
6341 }
6342
6343 #[test]
6344 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
6345 assert_eq!(
6346 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
6347 "feat: a queue, an unattended loop, and a phone UI"
6348 );
6349 assert_eq!(
6350 merge_subject("", "# port the retry logic\n\ndetails"),
6351 "port the retry logic",
6352 "an empty title falls back to the task's first line, heading marks stripped"
6353 );
6354 }
6355
6356 #[test]
6357 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
6358 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
6359 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
6360 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
6361 assert_eq!(job_of("https://coderabbit.ai/status"), None);
6362 assert_eq!(run_of(""), None);
6363 }
6364
6365 #[test]
6366 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
6367 let mut out = Vec::new();
6368 push_if_outstanding(
6369 &mut out,
6370 ReviewComment {
6371 author: "yukimemi".to_owned(),
6372 path: None,
6373 line: None,
6374 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
6375 },
6376 );
6377 assert!(out.is_empty());
6378 }
6379
6380 fn run_state() -> RunState {
6384 let mut state = RunState::new(
6385 std::path::PathBuf::from("/repo/magi"),
6386 "main".to_owned(),
6387 "abcdef1234".to_owned(),
6388 "add retries to the uploader".to_owned(),
6389 crate::config::Config::default(),
6390 );
6391 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
6396 let nanos = std::time::SystemTime::now()
6397 .duration_since(std::time::UNIX_EPOCH)
6398 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
6399 state.id = format!(
6400 "20261004-{nanos:06}-{:04x}",
6401 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
6402 );
6403 state
6404 }
6405
6406 fn green_pr() -> PrState {
6407 PrState {
6408 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
6409 number: 42,
6410 state: PrLifecycle::Open,
6411 checks: Checks::Green,
6412 blocking: Blocking::No,
6414 failing: Vec::new(),
6415 review_comments: vec![ReviewComment {
6416 author: "coderabbitai".to_owned(),
6417 path: Some("src/land.rs".to_owned()),
6418 line: Some(212),
6419 body: "this branch never checks the exit code".to_owned(),
6420 }],
6421 }
6422 }
6423
6424 #[test]
6425 fn land_fixer_defers_review_and_verification_without_a_cache() {
6426 let state = run_state();
6427 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6428 assert!(p.contains(crate::prompt::child_run_ownership()), "{p}");
6429 assert!(p.contains("Commit in this worktree"), "{p}");
6430 }
6431
6432 #[test]
6433 fn github_facing_land_text_is_english_whatever_the_language() {
6434 let mut state = run_state();
6435 state.config.graph.language = "ja".to_owned();
6436 let comment = stop_comment(&state.id, "checks are still red");
6437 assert!(crate::github_text::check("", &comment).is_empty());
6438 assert!(comment.is_ascii(), "{comment}");
6439 assert!(comment.starts_with(MARKER));
6440
6441 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6442 let ja_at = p.find("Write all prose in ja").unwrap();
6443 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
6444 assert!(ja_at < rule_at, "{p}");
6445 assert!(p.contains("stays in Japanese"), "{p}");
6446
6447 state.config.graph.language = "en".to_owned();
6448 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6449 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
6450 assert!(!p.contains("does not apply"), "{p}");
6451 }
6452
6453 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
6454
6455 fn panel() -> String {
6456 approval_panel(
6457 &run_state(),
6458 &green_pr(),
6459 NUMSTAT,
6460 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
6461 &[
6462 "land: ask before merging".to_owned(),
6463 "land: colour the diff".to_owned(),
6464 ],
6465 "feat: merge approval from the phone",
6466 )
6467 }
6468
6469 #[test]
6470 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
6471 let html = panel();
6472 for needle in [
6473 "42",
6474 "main",
6475 "src/land.rs",
6476 "src/web.rs",
6477 "assets/logo.png",
6478 "feat: merge approval from the phone",
6479 "land: ask before merging",
6480 "land: colour the diff",
6481 "coderabbitai",
6482 "this branch never checks the exit code",
6483 "green",
6484 ] {
6485 assert!(html.contains(needle), "the panel must state `{needle}`");
6486 }
6487 }
6488
6489 fn winning_candidate(summary: &str) -> Candidate {
6492 Candidate {
6493 index: 0,
6494 label: 'A',
6495 agent: "opus".to_owned(),
6496 branch: "magi/x/A".to_owned(),
6497 worktree: PathBuf::from("/wt/A"),
6498 summary: summary.to_owned(),
6499 stat: String::new(),
6500 files: 1,
6501 commits: 1,
6502 empty: false,
6503 failed: None,
6504 verified_noop: None,
6505 duration_ms: 0,
6506 folded: false,
6507 }
6508 }
6509
6510 fn uncontested_tally() -> Tally {
6511 Tally {
6512 first_choice: BTreeMap::from([('A', 1)]),
6513 borda: BTreeMap::new(),
6514 winner: 'A',
6515 rankings: 1,
6516 unanimous_initial: true,
6517 deliberated: false,
6518 changed_votes: 0,
6519 unanimous_final: true,
6520 tie_break: None,
6521 judges: 1,
6522 present: 1,
6523 quorum: 1,
6524 met_quorum: true,
6525 uncontested: None,
6526 }
6527 }
6528
6529 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
6530 ReviewRecord {
6531 attempts: 0,
6532 reviewer,
6533 agent: agent.to_owned(),
6534 summary: summary.to_owned(),
6535 findings: Vec::new(),
6536 vote: None,
6537 failed: None,
6538 duration_ms: 0,
6539 }
6540 }
6541
6542 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
6543 let answered = reviews.len();
6544 ReviewRound {
6545 round,
6546 head: "abc1234".to_owned(),
6547 verified_head: None,
6548 verified_at: None,
6549 reviews,
6550 e2e: Vec::new(),
6551 verify_retried: false,
6552 e2e_deferred: false,
6553 e2e_defer_reason: None,
6554 fix: None,
6555 blocking: 0,
6556 answered,
6557 expected: answered,
6558 clean: true,
6559 progressed: false,
6560 vote_split: false,
6561 reconsideration: Vec::new(),
6562 verdict: None,
6563 }
6564 }
6565
6566 #[test]
6567 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
6568 let en = panel();
6569 assert!(en.contains("Task"), "{en}");
6570 assert!(en.contains("add retries to the uploader"), "{en}");
6571
6572 let mut state = run_state();
6573 state.config.graph.language = "ja".to_owned();
6574 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6575 assert!(ja.contains("タスク"), "{ja}");
6576 assert!(
6577 ja.contains("add retries to the uploader"),
6578 "the task itself is not translated: {ja}"
6579 );
6580 }
6581
6582 #[test]
6583 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
6584 let html = panel();
6588 assert!(!html.contains("What changed"), "{html}");
6589 assert!(!html.contains("Review verdict"), "{html}");
6590 }
6591
6592 #[test]
6593 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
6594 let mut state = run_state();
6595 state.candidates = vec![winning_candidate("")];
6596 state.tally = Some(uncontested_tally());
6597 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6598 assert!(
6599 !html.contains("What changed"),
6600 "an empty summary must not render an empty box: {html}"
6601 );
6602 }
6603
6604 #[test]
6605 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
6606 let mut state = run_state();
6607 state.candidates = vec![winning_candidate(
6608 "Added a retry loop around the uploader PUT call.",
6609 )];
6610 state.tally = Some(uncontested_tally());
6611 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6612 assert!(en.contains("What changed"), "{en}");
6613 assert!(
6614 en.contains("Added a retry loop around the uploader PUT call."),
6615 "{en}"
6616 );
6617
6618 state.config.graph.language = "ja".to_owned();
6619 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6620 assert!(ja.contains("変更内容"), "{ja}");
6621 assert!(
6622 ja.contains("Added a retry loop around the uploader PUT call."),
6623 "{ja}"
6624 );
6625 }
6626
6627 #[test]
6628 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
6629 let mut state = run_state();
6630 state.reviews = vec![
6631 review_round(
6632 1,
6633 vec![review_record(1, "alpha", "found a race, sent back")],
6634 ),
6635 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
6636 ];
6637 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6638 assert!(en.contains("Review verdict"), "{en}");
6639 assert!(en.contains("race is fixed, clean"), "{en}");
6640 assert!(
6641 !en.contains("found a race, sent back"),
6642 "only the round that actually cleared the merge should show: {en}"
6643 );
6644
6645 state.config.graph.language = "ja".to_owned();
6646 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6647 assert!(ja.contains("レビューの結論"), "{ja}");
6648 assert!(ja.contains("レビュアー"), "{ja}");
6649 assert!(ja.contains("race is fixed, clean"), "{ja}");
6650 }
6651
6652 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
6658 ReviewRecord {
6659 attempts: 0,
6660 reviewer,
6661 agent: agent.to_owned(),
6662 summary: String::new(),
6663 findings: Vec::new(),
6664 vote: None,
6665 failed: Some(reason.to_owned()),
6666 duration_ms: 0,
6667 }
6668 }
6669
6670 #[test]
6671 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
6672 let mut state = run_state();
6673 state.reviews = vec![review_round(
6674 1,
6675 vec![
6676 review_record(1, "alpha", "clean, nothing to add"),
6677 unanswered_review_record(2, "beta", "timed out"),
6678 ],
6679 )];
6680 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6681 assert!(en.contains("clean, nothing to add"), "{en}");
6682 assert!(
6683 en.contains("produced no answer: timed out"),
6684 "a seat that never answered must say so, not render a blank box: {en}"
6685 );
6686 assert!(
6687 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
6688 "no reviewer box may be left empty: {en}"
6689 );
6690
6691 state.config.graph.language = "ja".to_owned();
6692 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6693 assert!(ja.contains("回答なし: timed out"), "{ja}");
6694 }
6695
6696 #[test]
6697 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
6698 let html = panel();
6699 assert!(!html.contains("<script"), "no script survives the csp");
6700 assert!(!html.contains("<form"), "form-action is 'none'");
6701 let pr = green_pr();
6702 assert_eq!(
6703 html.matches("http").count(),
6704 html.matches(pr.url.as_str()).count(),
6705 "the only http url in the panel is the pull request's own link"
6706 );
6707 assert!(
6708 html.contains("target=\"_blank\""),
6709 "the PR link must open in a new tab so it escapes the iframe"
6710 );
6711 assert!(
6712 html.contains("rel=\"noopener noreferrer\""),
6713 "external links opening in new tabs must carry noopener noreferrer"
6714 );
6715 }
6716
6717 #[test]
6718 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
6719 let html = panel();
6720 assert!(
6721 html.contains(">+</span>"),
6722 "an added line carries a `+` in the gutter, not only a background"
6723 );
6724 assert!(
6725 html.contains(">-</span>"),
6726 "a removed line carries a `-` in the gutter, not only a background"
6727 );
6728 assert!(
6729 html.contains(">new line</span>"),
6730 "the marker is moved to the gutter, so the body is printed once without it"
6731 );
6732 }
6733
6734 #[test]
6735 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
6736 let total = DIFF_MAX_LINES + 100;
6737 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
6738 let html = approval_panel(
6739 &run_state(),
6740 &green_pr(),
6741 NUMSTAT,
6742 &diff,
6743 &[],
6744 "feat: something long",
6745 );
6746 assert!(
6747 html.contains(&format!("100 of {total} diff lines omitted")),
6748 "the note must say exactly how much was cut"
6749 );
6750 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
6751 assert!(
6752 !html.contains(&format!("line {DIFF_MAX_LINES}")),
6753 "nothing past the threshold is rendered"
6754 );
6755 assert!(
6756 html.contains("/repo/magi"),
6757 "the note says where the rest is"
6758 );
6759 }
6760
6761 #[test]
6762 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
6763 let html = approval_panel(
6764 &run_state(),
6765 &green_pr(),
6766 "1\t2\tsrc/<b>&\"x\"'.rs",
6767 "",
6768 &[],
6769 "subject",
6770 );
6771 assert!(html.contains("src/<b>&"x"'.rs"));
6772 assert!(
6773 !html.contains("<b>"),
6774 "an agent-influenced path must never become markup"
6775 );
6776 }
6777
6778 #[tokio::test]
6779 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
6780 let a = std::path::PathBuf::from("/repo/a");
6781 let b = std::path::PathBuf::from("/repo/b");
6782
6783 let held = repo_merge_lock(&a).lock_owned().await;
6784
6785 assert!(
6788 repo_merge_lock(&a).try_lock().is_err(),
6789 "a second merge into the same repository must not proceed concurrently"
6790 );
6791
6792 assert!(
6796 repo_merge_lock(&b).try_lock().is_ok(),
6797 "a different repository's merge lock must be independent"
6798 );
6799
6800 drop(held);
6801 assert!(
6802 repo_merge_lock(&a).try_lock().is_ok(),
6803 "the lock is released once the holder is done"
6804 );
6805 }
6806
6807 pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
6808 let mut state = landing_state();
6809 let mut rec = review_record(1, "alpha", "precise rejection reason");
6810 rec.vote = Some(if reject {
6811 crate::verdict::ReviewVote::Reject
6812 } else {
6813 crate::verdict::ReviewVote::Approve
6814 });
6815 if major {
6816 rec.findings = contested().findings;
6817 }
6818 let mut round = review_round(1, vec![rec]);
6819 round.clean = false;
6820 round.blocking = usize::from(major);
6821 state.reviews.push(round);
6822 state.config.graph.review_rounds = 1;
6823 state
6824 }
6825
6826 #[test]
6827 fn approval_policy_controls_are_independent_and_keep_all_reasons() {
6828 for every in [false, true] {
6829 for contested in [false, true] {
6830 for major in [false, true] {
6831 for reject in [false, true] {
6832 let mut state = repair_state(major, reject);
6833 state.config.graph.land_approval = every;
6834 state.config.graph.hold_contested_merge = contested;
6835 let reasons = approval_reasons(&state);
6836 assert_eq!(reasons.contains(&ApprovalReason::EveryMerge), every);
6837 assert_eq!(
6838 reasons.contains(&ApprovalReason::OpenBlockingFindings),
6839 contested && major
6840 );
6841 assert_eq!(
6842 reasons.contains(&ApprovalReason::FinalReject),
6843 contested && reject
6844 );
6845 assert_eq!(
6846 reasons.len(),
6847 usize::from(every)
6848 + usize::from(contested && major)
6849 + usize::from(contested && reject)
6850 );
6851 }
6852 }
6853 }
6854 }
6855 }
6856
6857 #[test]
6858 fn approval_policy_and_rendering_follow_reconsideration_in_both_directions() {
6859 use crate::verdict::ReviewVote::{Approve, Reject};
6860 for (initial_reject, final_vote) in [(true, Approve), (false, Reject)] {
6861 let mut state = repair_state(false, initial_reject);
6862 state.reviews[0]
6863 .reconsideration
6864 .push(crate::run::ReviewRevoteRecord {
6865 reviewer: 1,
6866 agent: "alpha".into(),
6867 vote: Some(final_vote),
6868 reason: "changed after reading the panel <evidence>".into(),
6869 failed: None,
6870 });
6871 assert_eq!(
6872 approval_reasons(&state).contains(&ApprovalReason::FinalReject),
6873 final_vote == Reject
6874 );
6875 for language in ["en", "ja"] {
6876 state.config.graph.language = language.into();
6877 let evidence = approval_evidence(&state);
6878 assert!(evidence.contains(&format!("→ {}", final_vote.label())));
6879 assert!(evidence.contains("changed after reading the panel <evidence>"));
6880 let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
6881 assert!(html.contains("changed after reading the panel <evidence>"));
6882 assert!(!html.contains("<evidence>"));
6883 }
6884 state.reviews[0].reconsideration[0].vote = None;
6885 state.reviews[0].reconsideration[0].failed = Some("timed out".into());
6886 assert_eq!(
6887 approval_reasons(&state).contains(&ApprovalReason::FinalReject),
6888 initial_reject
6889 );
6890 assert!(approval_evidence(&state).contains("timed out"));
6891 }
6892 }
6893
6894 #[tokio::test]
6895 async fn approval_merge_paths_apply_controls_to_major_and_reject_independently() {
6896 for every in [false, true] {
6897 for contested in [false, true] {
6898 for (major, reject) in [(false, false), (false, true), (true, false), (true, true)]
6899 {
6900 let mut state = repair_state(major, reject);
6901 state.config.graph.land_approval = every;
6902 state.config.graph.hold_contested_merge = contested;
6903 let forge = Scripted::new(
6904 vec![seen("abc", Checks::Green, "CLEAN", false)],
6905 vec![(true, "")],
6906 );
6907 land_with(&mut state, URL, &forge).await.unwrap();
6908 let expected = every || (contested && (major || reject));
6909 assert_eq!(state.land_approval.is_some(), expected);
6910 assert_eq!(forge.calls().contains(&"merge"), !expected);
6911 if expected {
6912 assert!(state.parked);
6913 }
6914 }
6915 }
6916 }
6917 }
6918
6919 #[tokio::test]
6920 async fn approval_snapshot_exposes_minor_findings_and_survives_disabled_controls() {
6921 let mut state = repair_state(false, true);
6922 state.reviews[0].clean = true;
6923 let mut finding = contested().findings.remove(0);
6924 finding.severity = crate::verdict::Severity::Minor;
6925 state.reviews[0].reviews[0].findings.push(finding);
6926 assert!(state.open_findings().is_empty());
6927 let mut pr = green_pr();
6928 pr.review_comments.clear();
6929 for language in ["en", "ja"] {
6930 state.config.graph.language = language.into();
6931 let html = approval_panel(&state, &pr, "", "", &[], "subject");
6932 assert!(html.contains("[R3-1-1] minor"));
6933 assert!(html.contains(if language == "ja" {
6934 "内部指摘の解決を意味するものではありません"
6935 } else {
6936 "This does not mean internal findings are resolved"
6937 }));
6938 assert!(
6939 html.find(if language == "ja" {
6940 "承認が必要な理由:"
6941 } else {
6942 "Approval required because:"
6943 })
6944 .unwrap()
6945 < html.find("[R3-1-1]").unwrap()
6946 );
6947 }
6948 state.config.graph.language = "en".into();
6949 let observed = seen("abc", Checks::Green, "CLEAN", false);
6950 assert_eq!(
6951 approval_gate(&mut state, &observed.pr, "subject", None, "abc")
6952 .await
6953 .unwrap(),
6954 ApprovalGate::Pending
6955 );
6956 let id = state.land_approval.as_ref().unwrap().question.clone();
6957 let q = ask::Questions::open().get(&id).unwrap();
6958 assert!(q.detail.starts_with("Approval required because:"));
6959 assert!(q.detail.contains("final reviewer reject"));
6960 assert!(q.detail.contains("[R3-1-1] minor"));
6961 assert!(
6962 q.detail
6963 .contains("No adoption or resolution evidence recorded")
6964 );
6965 state.config.graph.land_approval = false;
6966 state.config.graph.hold_contested_merge = false;
6967 state.reviews.clear();
6968 assert_eq!(approval_reasons(&state), [ApprovalReason::ExistingDecision]);
6969 assert!(deputy_brief(&q, Some(&state)).contains("final reviewer reject"));
6970 assert!(deputy_brief(&q, None).contains("[R3-1-1] minor"));
6971 let forge = Scripted::new(vec![observed], vec![]);
6972 land_with(&mut state, URL, &forge).await.unwrap();
6973 assert!(!forge.calls().contains(&"merge"));
6974 assert_eq!(state.land_approval.as_ref().unwrap().question, id);
6975 assert_eq!(ask::Questions::open().get(&id).unwrap().detail, q.detail);
6976 }
6977
6978 #[tokio::test]
6979 async fn approval_evidence_keeps_all_findings_and_bounds_unicode_prose() {
6980 let mut state = repair_state(false, true);
6981 state.config.graph.land_approval = false;
6982 let long_reason = format!("{}REASON_TAIL", "拒否🦀".repeat(200));
6983 let long_detail = format!("{}DETAIL_TAIL", "詳細🦀".repeat(200));
6984 let long_title = format!("{}TITLE_TAIL", "題🦀".repeat(100));
6985 state.reviews[0].reviews[0].summary = long_reason.clone();
6986 for i in 1..=10 {
6987 let mut finding = contested().findings.remove(0);
6988 finding.id = format!("R1-1-{i}");
6989 finding.severity = if i == 9 {
6990 crate::verdict::Severity::Major
6991 } else {
6992 crate::verdict::Severity::Minor
6993 };
6994 finding.title = long_title.clone();
6995 finding.detail = long_detail.clone();
6996 state.reviews[0].reviews[0].findings.push(finding);
6997 }
6998 state.reviews[0].fix = Some(crate::run::FixRecord {
6999 agent: "fixer".into(),
7000 addressed: vec!["R1-1-9".into()],
7001 rejected: vec![crate::verdict::Rejection {
7002 id: "R1-1-10".into(),
7003 why: long_reason.clone(),
7004 }],
7005 notes: long_detail.clone(),
7006 committed: false,
7007 failed: None,
7008 duration_ms: 0,
7009 continuation: None,
7010 });
7011 assert_eq!(
7012 approval_reasons(&state),
7013 [
7014 ApprovalReason::OpenBlockingFindings,
7015 ApprovalReason::FinalReject
7016 ]
7017 );
7018 let excerpt = approval_excerpt(&long_reason, 300);
7019 assert_eq!(excerpt.chars().count(), 300);
7020 assert!(excerpt.ends_with('…'));
7021 let boundary = "拒".repeat(300);
7022 assert_eq!(approval_excerpt(&boundary, 300), boundary);
7023 for language in ["en", "ja"] {
7024 state.config.graph.language = language.into();
7025 for revote in [
7026 None,
7027 Some(crate::verdict::ReviewVote::Reject),
7028 Some(crate::verdict::ReviewVote::Approve),
7029 ] {
7030 state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
7031 state.reviews[0].reconsideration.clear();
7032 if let Some(vote) = revote {
7033 state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Approve);
7034 state.reviews[0]
7035 .reconsideration
7036 .push(crate::run::ReviewRevoteRecord {
7037 reviewer: 1,
7038 agent: "alpha".into(),
7039 vote: Some(vote),
7040 reason: long_reason.clone(),
7041 failed: None,
7042 });
7043 }
7044 let evidence = approval_evidence(&state);
7045 let final_vote = revote.unwrap_or(crate::verdict::ReviewVote::Reject);
7046 assert!(evidence.contains(&format!("#1 (alpha): {}", final_vote.label())));
7047 assert!(approval_reasons(&state).contains(&ApprovalReason::OpenBlockingFindings));
7048 assert_eq!(
7049 approval_reasons(&state).contains(&ApprovalReason::FinalReject),
7050 final_vote == crate::verdict::ReviewVote::Reject
7051 );
7052 assert!(evidence.contains(&excerpt));
7053 assert!(evidence.contains("[R1-1-8] minor"));
7054 assert!(evidence.contains("[R1-1-9] major"));
7055 assert!(evidence.contains("[R1-1-10] minor"));
7056 for tail in ["REASON_TAIL", "DETAIL_TAIL", "TITLE_TAIL"] {
7057 assert!(!evidence.contains(tail));
7058 }
7059 assert!(evidence.contains(if language == "ja" {
7060 "採用申告(解決未確認)"
7061 } else {
7062 "claimed addressed (resolution unconfirmed)"
7063 }));
7064 assert!(evidence.contains(if language == "ja" {
7065 "不採用理由"
7066 } else {
7067 "declined"
7068 }));
7069 let major_evidence = evidence
7070 .split("[R1-1-9] major")
7071 .nth(1)
7072 .unwrap()
7073 .split("[R1-1-10]")
7074 .next()
7075 .unwrap();
7076 assert!(major_evidence.contains(if language == "ja" {
7077 "採用申告(解決未確認)"
7078 } else {
7079 "claimed addressed (resolution unconfirmed)"
7080 }));
7081 let last_evidence = evidence.split("[R1-1-10] minor").nth(1).unwrap();
7082 assert!(last_evidence.contains(if language == "ja" {
7083 "不採用理由"
7084 } else {
7085 "declined"
7086 }));
7087 let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
7088 assert!(html.contains(&esc(&evidence)));
7089 assert_eq!(
7090 approval_gate(&mut state, &green_pr(), "subject", None, "abc")
7091 .await
7092 .unwrap(),
7093 ApprovalGate::Pending
7094 );
7095 let id = &state.land_approval.take().unwrap().question;
7096 let q = ask::Questions::open().get(id).unwrap();
7097 assert!(q.detail.starts_with(&evidence));
7098 }
7099 state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
7100 }
7101 }
7102
7103 #[test]
7104 fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
7105 for (major, reject) in [(true, false), (false, true), (true, true)] {
7106 let state = repair_state(major, reject);
7107 assert!(repair_available(&state));
7108 assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
7109 }
7110 assert!(!repair_available(&repair_state(false, false)));
7111 let mut clean = repair_state(true, false);
7112 clean.reviews[0].clean = true;
7113 assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
7114 assert_eq!(approval(Some(REPAIR)), Approval::Repair);
7115 }
7116
7117 #[tokio::test]
7118 async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
7119 let mut state = repair_state(true, false);
7120 let pr = green_pr();
7121 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7122 .await
7123 .unwrap();
7124 let store = ask::Questions::open();
7125 let id = state.land_approval.as_ref().unwrap().question.clone();
7126 store
7128 .update(&id, |q| {
7129 q.choices = vec![APPROVE.into(), HOLD.into()];
7130 Ok(())
7131 })
7132 .unwrap();
7133 let upgraded = store.get(&id).unwrap();
7134 assert_eq!(upgraded.choices, approval_choices(&state));
7135 assert!(!upgraded.actions.contains_key(HOLD));
7136 assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
7137 assert!(
7138 store
7139 .list()
7140 .iter()
7141 .any(|q| q.id == id && q.choices == upgraded.choices)
7142 );
7143 assert!(
7144 crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
7145 );
7146 assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
7147 store
7148 .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
7149 .unwrap();
7150 store
7153 .update(&id, |q| {
7154 q.actions.clear();
7155 Ok(())
7156 })
7157 .unwrap();
7158 assert_eq!(
7159 store.get(&id).unwrap().chosen_action(),
7160 Some(&ask::ChoiceAction::Resume {
7161 run: state.id.clone()
7162 })
7163 );
7164 let binding = state.land_approval.clone();
7165 assert_eq!(
7166 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7167 .await
7168 .unwrap(),
7169 ApprovalGate::Repair
7170 );
7171 assert!(state.land_approval.is_none());
7172 assert_eq!(state.merge_repairs.len(), 1);
7173 let request = &state.merge_repairs[0];
7174 assert_eq!(request.question, id);
7175 assert_eq!(request.head, "abc");
7176 assert_eq!(request.pr, pr.url);
7177 assert_eq!(
7178 request.reviews[0].reviews[0].summary,
7179 "precise rejection reason"
7180 );
7181 assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
7182 assert_eq!(
7183 request.reviews[0].reviews[0].findings[0].detail,
7184 state.reviews[0].reviews[0].findings[0].detail
7185 );
7186 state.land_approval = binding;
7188 assert_eq!(
7189 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7190 .await
7191 .unwrap(),
7192 ApprovalGate::Repair
7193 );
7194 assert_eq!(state.merge_repairs.len(), 1);
7195 state.land_approval = None;
7196 state.config.graph.land_approval = false;
7197 state.config.graph.hold_contested_merge = false;
7198 state.reviews.clear();
7199 assert_eq!(approval_reasons(&state), [ApprovalReason::AfterRepair]);
7200 assert_eq!(
7201 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7202 .await
7203 .unwrap(),
7204 ApprovalGate::Pending
7205 );
7206 let fresh_id = &state.land_approval.as_ref().unwrap().question;
7207 assert_ne!(fresh_id, &id);
7208 let fresh = store.get(fresh_id).unwrap();
7209 assert!(
7210 fresh
7211 .detail
7212 .contains("fresh owner approval after merge repair")
7213 );
7214 assert!(fresh.detail.contains("No review recorded"));
7215 assert!(!fresh.detail.contains("precise rejection reason"));
7216 }
7217
7218 #[tokio::test]
7219 async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
7220 for choice in [REPAIR, HOLD] {
7221 let mut state = repair_state(false, true);
7222 let observed = seen("abc", Checks::Green, "CLEAN", false);
7223 approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
7224 .await
7225 .unwrap();
7226 let id = state.land_approval.as_ref().unwrap().question.clone();
7227 ask::Questions::open()
7228 .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
7229 .unwrap();
7230 let forge = Scripted::new(vec![observed], vec![(true, "")]);
7231 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7232 .await
7233 .unwrap();
7234 assert!(
7235 forge
7236 .argvs()
7237 .iter()
7238 .all(|a| a.iter().any(|s| s == "--disable-auto"))
7239 );
7240 assert!(!forge.calls().contains(&"fix"));
7241 assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
7242 assert_ne!(state.status, RunStatus::Merged);
7243 }
7244 }
7245
7246 #[test]
7247 fn only_the_merge_choice_merges_and_silence_holds() {
7248 let table = [
7249 (None, Approval::Hold),
7250 (Some("merge"), Approval::Merge),
7251 (Some(" merge\n"), Approval::Merge),
7252 (Some("hold"), Approval::Hold),
7253 (Some(""), Approval::Hold),
7254 (Some("yes"), Approval::Hold),
7255 ];
7256 for (answer, want) in table {
7257 assert_eq!(
7258 approval(answer),
7259 want,
7260 "answer {answer:?} must resolve to {want:?}"
7261 );
7262 }
7263 }
7264
7265 #[tokio::test]
7266 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
7267 let mut state = landing_state();
7268 state.config.graph.land_approval = true;
7269 let pr = green_pr();
7270
7271 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
7272 .await
7273 .unwrap();
7274 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
7275 assert!(
7276 !state.parked,
7277 "approval_gate itself never sets `parked`; only its caller does"
7278 );
7279
7280 let store = ask::Questions::open();
7281 let filed: Vec<_> = store
7282 .list()
7283 .into_iter()
7284 .filter(|q| q.run == state.id)
7285 .collect();
7286 assert_eq!(filed.len(), 1, "exactly one question is filed");
7287 assert_eq!(filed[0].node, APPROVAL_NODE);
7288 assert_eq!(filed[0].choices, approval_choices(&state));
7289 assert!(filed[0].status.open());
7290
7291 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
7295 .await
7296 .unwrap();
7297 assert_eq!(again, ApprovalGate::Pending);
7298 let still_one = store
7299 .list()
7300 .into_iter()
7301 .filter(|q| q.run == state.id)
7302 .count();
7303 assert_eq!(
7304 still_one, 1,
7305 "asking twice must not double-file the question"
7306 );
7307 }
7308
7309 #[tokio::test]
7310 async fn approving_the_existing_question_is_read_back_as_approved() {
7311 crate::run::pin_test_home();
7312 let mut state = run_state();
7313 state.config.graph.land_approval = true;
7314 let pr = green_pr();
7315 assert_eq!(
7316 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7317 .await
7318 .unwrap(),
7319 ApprovalGate::Pending
7320 );
7321
7322 let store = ask::Questions::open();
7323 let mut q = store
7324 .list()
7325 .into_iter()
7326 .find(|q| q.run == state.id)
7327 .expect("filed above");
7328 let talks = crate::talk::Talks::open();
7329 let cfg = crate::config::Config {
7330 agents: vec![crate::config::AgentSpec {
7331 id: "mock".into(),
7332 kind: crate::config::AgentKind::Command,
7333 model: None,
7334 command: vec!["true".into()],
7335 extra_args: Vec::new(),
7336 env: Default::default(),
7337 prompt_delivery: None,
7338 }],
7339 ..crate::config::Config::default()
7340 };
7341 let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
7342 crate::consult::begin(&store, &talks, &q, &talk).unwrap();
7343 crate::talk::drain(&mut talk, &talks).unwrap();
7344 assert_eq!(
7345 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7346 .await
7347 .unwrap(),
7348 ApprovalGate::Pending,
7349 "consultation cannot approve or hold the merge"
7350 );
7351 q = store.get(&q.id).unwrap();
7352 assert!(q.answer.is_none());
7353 talk.turns.push(crate::talk::Turn {
7354 sender: None,
7355 breaks: Some(Vec::new()),
7356 messages: Vec::new(),
7357 posted_by: None,
7358 who: crate::talk::Who::Operator,
7359 body: "merge abc".into(),
7360 at: Timestamp::now(),
7361 attachments: Vec::new(),
7362 usage: None,
7363 });
7364 talks.put(&mut talk).unwrap();
7365 crate::consult::validate_answer(
7366 &q,
7367 &talks,
7368 &talk.id,
7369 crate::queue::CHAT_NODE,
7370 APPROVE,
7371 Some("merge abc"),
7372 )
7373 .unwrap();
7374 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
7375 store.put(&mut q).unwrap();
7376
7377 assert_eq!(
7378 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7379 .await
7380 .unwrap(),
7381 ApprovalGate::Approved
7382 );
7383 }
7384
7385 #[tokio::test]
7386 async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
7387 crate::run::pin_test_home();
7388 let store = ask::Questions::open();
7389 for via_list in [false, true] {
7390 let mut state = repair_state(false, true);
7391 let pr = green_pr();
7392 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7393 .await
7394 .unwrap();
7395 let id = state.land_approval.as_ref().unwrap().question.clone();
7396 store
7397 .update(&id, |q| {
7398 q.actions.insert(
7399 HOLD.to_owned(),
7400 ask::ChoiceAction::Resume {
7401 run: state.id.clone(),
7402 },
7403 );
7404 Ok(())
7405 })
7406 .unwrap();
7407 let q = if via_list {
7408 store.list().into_iter().find(|q| q.id == id).unwrap()
7409 } else {
7410 store.get(&id).unwrap()
7411 };
7412 assert!(!q.actions.contains_key(HOLD));
7413 store
7414 .update(&id, |q| {
7415 assert!(!q.actions.contains_key(HOLD));
7417 assert!(!upgrade_repair_choice(q, &state));
7418 q.answer(ask::Answer::Choice(HOLD.to_owned()))
7419 })
7420 .unwrap();
7421 assert!(store.get(&id).unwrap().chosen_action().is_none());
7422 assert_eq!(
7423 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7424 .await
7425 .unwrap(),
7426 ApprovalGate::Held
7427 );
7428 }
7429 }
7430
7431 #[tokio::test]
7432 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
7433 crate::run::pin_test_home();
7434 let store = ask::Questions::open();
7435
7436 let mut held_state = run_state();
7437 held_state.config.graph.land_approval = true;
7438 let pr = green_pr();
7439 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
7440 .await
7441 .unwrap();
7442 let mut q = store
7443 .list()
7444 .into_iter()
7445 .find(|q| q.run == held_state.id)
7446 .expect("filed above");
7447 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
7448 store.put(&mut q).unwrap();
7449 assert_eq!(
7450 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
7451 .await
7452 .unwrap(),
7453 ApprovalGate::Held
7454 );
7455
7456 let mut abandoned_state = run_state();
7457 abandoned_state.config.graph.land_approval = true;
7458 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
7459 .await
7460 .unwrap();
7461 let mut q = store
7462 .list()
7463 .into_iter()
7464 .find(|q| q.run == abandoned_state.id)
7465 .expect("filed above");
7466 q.abandon("no answer within the timeout");
7467 store.put(&mut q).unwrap();
7468 assert_eq!(
7469 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
7470 .await
7471 .unwrap(),
7472 ApprovalGate::Held,
7473 "silence must never merge"
7474 );
7475 }
7476
7477 fn contested() -> ContestedHandoff {
7478 let finding = |id: &str, n: u32| crate::verdict::Finding {
7479 id: id.to_owned(),
7480 severity: crate::verdict::Severity::Major,
7481 file: Some("src/a.rs".to_owned()),
7482 line: Some(n),
7483 title: format!("problem {id}"),
7484 detail: String::new(),
7485 };
7486 ContestedHandoff {
7487 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
7488 rejecters: vec![(1, "alpha".to_owned())],
7489 }
7490 }
7491
7492 #[test]
7493 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
7494 let mut state = run_state();
7495 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
7496 state.contested_handoff = Some(contested());
7497 assert!(contested_to_ask(&state).is_some());
7498 state.config.graph.hold_contested_merge = false;
7499 assert!(
7500 contested_to_ask(&state).is_none(),
7501 "the switch restores today"
7502 );
7503 }
7504
7505 #[test]
7506 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
7507 let q = ask::Question::new(
7508 "run-1".to_owned(),
7509 APPROVAL_NODE.to_owned(),
7510 "land".to_owned(),
7511 "Merge?".to_owned(),
7512 String::new(),
7513 vec![APPROVE.to_owned(), HOLD.to_owned()],
7514 );
7515 let none = deputy_brief(&q, None);
7516 assert!(none.contains("could not be read"), "{none}");
7517 assert!(none.contains("Silence is a hold"), "{none}");
7518
7519 let mut state = run_state();
7520 state.pr = Some(crate::run::PrRecord {
7521 url: "https://example.test/pull/7".to_owned(),
7522 number: 7,
7523 state: "open".to_owned(),
7524 checks: "green".to_owned(),
7525 round: 0,
7526 rounds: 3,
7527 red_at_merge: Vec::new(),
7528 });
7529 state.contested_handoff = Some(contested());
7530 let b = deputy_brief(&q, Some(&state));
7531 assert!(b.contains("https://example.test/pull/7"), "{b}");
7532 assert!(b.contains("not recorded in this legacy question"), "{b}");
7533 assert!(!b.contains("This question was filed although"));
7534 state.contested_handoff = None;
7535 assert!(deputy_brief(&q, Some(&state)).contains("not recorded in this legacy question"));
7536 }
7537
7538 #[test]
7539 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
7540 for lang in ["en", "ja"] {
7541 let mut cfg = crate::config::Config::default();
7542 cfg.graph.language = lang.to_owned();
7543 let w = words(&cfg.graph.language);
7544 let text = w.approval_detail(
7545 "https://github.com/yukimemi/magi/pull/42",
7546 "main",
7547 "feat: x",
7548 Some(&contested()),
7549 );
7550 assert!(text.contains("pull/42"), "{text}");
7551 assert!(
7552 text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
7553 "{text}"
7554 );
7555 assert!(text.contains("R3-1-5"), "{text}");
7556 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
7557 assert!(text.contains("2"), "the rest are counted: {text}");
7558 assert!(text.contains("#1 (alpha)"), "{text}");
7559 }
7560 let plain = words("en").approval_detail("u", "main", "s", None);
7561 assert!(!plain.contains("reject"), "{plain}");
7562 }
7563
7564 #[tokio::test]
7565 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
7566 crate::run::pin_test_home();
7567 let mut state = run_state();
7568 state.config.graph.land_approval = false;
7569 state.contested_handoff = Some(contested());
7570 let pr = green_pr();
7571 let c = contested_to_ask(&state);
7572 assert_eq!(
7573 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
7574 .await
7575 .unwrap(),
7576 ApprovalGate::Pending,
7577 "silence is a hold"
7578 );
7579 let store = ask::Questions::open();
7580 let filed: Vec<_> = store
7581 .list()
7582 .into_iter()
7583 .filter(|q| q.run == state.id)
7584 .collect();
7585 assert_eq!(filed.len(), 1);
7586 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
7587
7588 assert_eq!(
7589 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
7590 .await
7591 .unwrap(),
7592 ApprovalGate::Pending
7593 );
7594 let mut q = store
7595 .list()
7596 .into_iter()
7597 .find(|q| q.run == state.id)
7598 .unwrap();
7599 assert_eq!(q.id, filed[0].id, "the same question after a resume");
7600 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
7601 store.put(&mut q).unwrap();
7602 assert_eq!(
7603 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
7604 .await
7605 .unwrap(),
7606 ApprovalGate::Approved
7607 );
7608 }
7609
7610 #[test]
7611 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
7612 let rows = parse_numstat(NUMSTAT);
7613 assert_eq!(
7614 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
7615 ["src/web.rs", "src/land.rs", "assets/logo.png"]
7616 );
7617 assert_eq!(rows[2].added, None, "a binary file has no line counts");
7618 }
7619 #[test]
7620 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
7621 let mut state = run_state();
7625 state.config.graph.language = "ja".to_owned();
7626 let pr = green_pr();
7627 let commits = ["c1".to_owned()];
7628
7629 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
7630 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
7631 assert!(ja.contains("squash されるコミット"), "{ja}");
7632 assert!(ja.contains("レビューコメント"), "{ja}");
7633 assert!(ja.contains("差分"), "{ja}");
7634 assert!(
7635 !ja.contains("Commits being squashed"),
7636 "no English left over"
7637 );
7638
7639 let w = words("ja");
7640 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
7641 assert!(
7642 w.approval_detail("http://x/1", "main", "feat: x", None)
7643 .contains("パネル")
7644 );
7645
7646 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
7648 assert!(ja.contains("feat: x"), "nor is the merge subject");
7649
7650 state.config.graph.language = "en".to_owned();
7653 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
7654 assert!(en.contains("Commits being squashed"), "{en}");
7655 assert_eq!(words("Klingon").html_lang, "en");
7656 }
7657
7658 #[test]
7662 fn pick_open_pr_classifies_by_count_and_base() {
7663 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
7664 assert_eq!(
7665 pick_open_pr(one, "main").unwrap(),
7666 OpenPr::One {
7667 url: "https://x/pull/58".into(),
7668 title: "t".into()
7669 }
7670 );
7671 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
7672 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
7673 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
7674 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
7675 assert_eq!(
7676 pick_open_pr(two, "main").unwrap(),
7677 OpenPr::Many(vec!["u1".into(), "u2".into()])
7678 );
7679 let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
7681 assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
7682 assert!(pick_open_pr("not json", "main").is_err());
7683 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
7685 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
7686 }
7687
7688 #[test]
7689 fn pick_merged_pr_picks_the_unique_match() {
7690 let json = r#"[
7691 {"url": "https://github.com/o/r/pull/42", "number": 42,
7692 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
7693 ]"#;
7694 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7695 let found = pick_merged_pr(json, "main", created_at)
7696 .expect("valid json")
7697 .expect("one unambiguous match");
7698 assert_eq!(found.url, "https://github.com/o/r/pull/42");
7699 assert_eq!(found.number, 42);
7700 }
7701
7702 #[test]
7706 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
7707 let json = r#"[
7708 {"url": "https://github.com/o/r/pull/42", "number": 42,
7709 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
7710 {"url": "https://github.com/o/r/pull/43", "number": 43,
7711 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
7712 ]"#;
7713 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7714 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7715 }
7716
7717 #[test]
7721 fn pick_merged_pr_ignores_a_different_base_branch() {
7722 let json = r#"[
7723 {"url": "https://github.com/o/r/pull/42", "number": 42,
7724 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
7725 ]"#;
7726 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7727 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7728 }
7729
7730 #[test]
7733 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
7734 let json = r#"[
7735 {"url": "https://github.com/o/r/pull/42", "number": 42,
7736 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
7737 ]"#;
7738 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7739 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7740 }
7741
7742 #[test]
7743 fn slug_of_pr_url_reads_host_owner_and_repo() {
7744 assert_eq!(
7745 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
7746 Some("github.com/yukimemi/shun")
7747 );
7748 }
7749
7750 #[test]
7751 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
7752 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
7753 assert_eq!(slug_of_pr_url("not a url at all"), None);
7754 assert_eq!(slug_of_pr_url("https://github.com"), None);
7755 }
7756
7757 #[test]
7758 fn slug_of_repo_url_reads_host_owner_and_repo() {
7759 assert_eq!(
7760 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
7761 Some("github.com/yukimemi/magi")
7762 );
7763 assert_eq!(slug_of_repo_url("https://github.com"), None);
7764 }
7765
7766 #[test]
7767 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
7768 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
7769 .expect("same repo, different case");
7770 }
7771
7772 #[test]
7777 fn ensure_same_repo_refuses_a_different_repo() {
7778 let err =
7779 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
7780 let msg = format!("{err:#}");
7781 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
7782 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
7783 }
7784
7785 #[test]
7790 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
7791 let err = ensure_same_repo(
7792 "github.com/yukimemi/magi",
7793 "github.example.com/yukimemi/magi",
7794 )
7795 .unwrap_err();
7796 let msg = format!("{err:#}");
7797 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
7798 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
7799 }
7800
7801 #[tokio::test]
7806 async fn find_external_merge_returns_none_without_a_winner() {
7807 let state = RunState::new(
7808 PathBuf::from("/no/such/repo"),
7809 "main".to_owned(),
7810 "0000000000000000000000000000000000000000".to_owned(),
7811 "irrelevant".to_owned(),
7812 crate::config::Config::default(),
7813 );
7814 assert_eq!(find_external_merge(&state).await.unwrap(), None);
7815 }
7816
7817 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
7818 let mut run = RunState::new(
7819 PathBuf::from(repo),
7820 "main".to_owned(),
7821 "abcdef1234".to_owned(),
7822 "x".to_owned(),
7823 crate::config::Config::default(),
7824 );
7825 run.id = id.to_owned();
7826 run.status = status;
7827 run.driver_exited = true;
7828 run.pr = Some(crate::run::PrRecord {
7829 number: url.rsplit('/').next().unwrap().parse().unwrap(),
7830 url: url.to_owned(),
7831 state: state.to_owned(),
7832 checks: "red".to_owned(),
7833 round: 0,
7834 rounds: 2,
7835 red_at_merge: Vec::new(),
7836 });
7837 run.save_under(home).unwrap();
7838 }
7839
7840 fn recorded(home: &Path, id: &str) -> String {
7841 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
7842 serde_json::from_str::<RunState>(&body)
7843 .unwrap()
7844 .pr
7845 .unwrap()
7846 .state
7847 }
7848
7849 const PR: &str = "https://github.com/o/r/pull/7";
7850
7851 #[test]
7852 fn write_through_updates_predecessors_and_siblings_only() {
7853 let tmp = tempfile::tempdir().unwrap();
7854 let h = tmp.path();
7855 pr_run(
7856 h,
7857 "20261004-100000-aaaa",
7858 "/repo/r",
7859 RunStatus::Superseded,
7860 PR,
7861 "open",
7862 );
7863 pr_run(
7864 h,
7865 "20261004-100100-bbbb",
7866 "/repo/r",
7867 RunStatus::Blocked,
7868 PR,
7869 "open",
7870 );
7871 pr_run(
7873 h,
7874 "20261004-100200-cccc",
7875 "/repo/r",
7876 RunStatus::Landing,
7877 PR,
7878 "open",
7879 );
7880 pr_run(
7882 h,
7883 "20261004-100300-dddd",
7884 "/repo/other",
7885 RunStatus::Blocked,
7886 "https://github.com/o/other/pull/7",
7887 "open",
7888 );
7889 pr_run(
7891 h,
7892 "20261004-100400-eeee",
7893 "/repo/r",
7894 RunStatus::Blocked,
7895 "https://github.com/o/r/pull/8",
7896 "open",
7897 );
7898 pr_run(
7899 h,
7900 "20261004-100500-ffff",
7901 "/repo/r",
7902 RunStatus::Merged,
7903 PR,
7904 "open",
7905 );
7906 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
7907
7908 assert_eq!(
7909 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
7910 2
7911 );
7912 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
7913 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
7914 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
7915 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
7916 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
7917 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
7919 assert_eq!(
7921 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
7922 0
7923 );
7924 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
7925 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
7926 }
7927
7928 #[test]
7929 fn lifecycle_updates_preserve_live_and_unknown_terminal_drivers() {
7930 let home = tempfile::tempdir().unwrap();
7931 let live_id = "20261004-100000-aaaa";
7932 let unknown_id = "20261004-100100-bbbb";
7933 for id in [live_id, unknown_id] {
7934 pr_run(home.path(), id, "repo", RunStatus::Ready, PR, "open");
7935 let mut s = RunState::load_under(id, home.path()).unwrap();
7936 s.driver_exited = false;
7937 if id == live_id {
7938 s.driver_pid = Some(std::process::id());
7939 s.driver_started_at = crate::proc::process_started_at(std::process::id());
7940 }
7941 s.save_under(home.path()).unwrap();
7942 }
7943 let known = BTreeMap::from([(PR.to_owned(), PrLifecycle::Merged)]);
7944 assert_eq!(apply_pr_states(home.path(), &known), 0);
7945 for id in [live_id, unknown_id] {
7946 assert_eq!(recorded(home.path(), id), "open");
7947 }
7948 }
7949
7950 #[test]
7951 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
7952 let tmp = tempfile::tempdir().unwrap();
7953 let h = tmp.path();
7954 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
7955 pr_run(
7956 h,
7957 "20261004-100000-aaaa",
7958 "/repo/r",
7959 RunStatus::Superseded,
7960 &url(1),
7961 "open",
7962 );
7963 pr_run(
7964 h,
7965 "20261004-100100-bbbb",
7966 "/repo/r",
7967 RunStatus::Blocked,
7968 &url(2),
7969 "open",
7970 );
7971 pr_run(
7972 h,
7973 "20261004-100200-cccc",
7974 "/repo/r",
7975 RunStatus::Ready,
7976 &url(3),
7977 "open",
7978 );
7979 pr_run(
7980 h,
7981 "20261004-100300-dddd",
7982 "/repo/r",
7983 RunStatus::Ready,
7984 &url(4),
7985 "open",
7986 );
7987 pr_run(
7988 h,
7989 "20261004-100400-eeee",
7990 "/repo/r",
7991 RunStatus::Implementing,
7992 &url(1),
7993 "open",
7994 );
7995 assert_eq!(stale_open_prs(h).len(), 4);
7996
7997 let mut known = BTreeMap::new();
7998 known.insert(url(1), PrLifecycle::Merged);
7999 known.insert(url(2), PrLifecycle::Closed);
8000 known.insert(url(3), PrLifecycle::Open);
8001 assert_eq!(apply_pr_states(h, &known), 2);
8003 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
8004 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
8005 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
8006 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
8007 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
8008 assert_eq!(apply_pr_states(h, &known), 0);
8009 }
8010
8011 use std::collections::VecDeque;
8014 use std::sync::Mutex;
8015
8016 struct Scripted {
8019 views: Mutex<VecDeque<Seen>>,
8020 merges: Mutex<VecDeque<(bool, String)>>,
8021 fix: Mutex<Option<Fixed>>,
8022 log: Mutex<Vec<&'static str>>,
8023 argvs: Mutex<Vec<Vec<String>>>,
8024 required: Mutex<Option<BTreeSet<String>>>,
8025 merged: Mutex<bool>,
8028 queued: Mutex<bool>,
8029 unreadable_after_merge: Mutex<bool>,
8031 reruns: Mutex<Vec<String>>,
8033 rerun_fails: Mutex<bool>,
8034 clock: Mutex<i64>,
8036 }
8037
8038 impl Scripted {
8039 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
8040 Self {
8041 views: Mutex::new(views.into()),
8042 merges: Mutex::new(
8043 merges
8044 .into_iter()
8045 .map(|(ok, m)| (ok, m.to_owned()))
8046 .collect(),
8047 ),
8048 fix: Mutex::new(None),
8049 log: Mutex::new(Vec::new()),
8050 argvs: Mutex::new(Vec::new()),
8051 required: Mutex::new(None),
8052 merged: Mutex::new(false),
8053 queued: Mutex::new(false),
8054 unreadable_after_merge: Mutex::new(false),
8055 reruns: Mutex::new(Vec::new()),
8056 rerun_fails: Mutex::new(false),
8057 clock: Mutex::new(1_000),
8058 }
8059 }
8060 fn argvs(&self) -> Vec<Vec<String>> {
8061 self.argvs.lock().unwrap().clone()
8062 }
8063 fn calls(&self) -> Vec<&'static str> {
8064 self.log.lock().unwrap().clone()
8065 }
8066 }
8067
8068 impl Forge for Scripted {
8069 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
8070 self.log.lock().unwrap().push("view");
8071 if *self.unreadable_after_merge.lock().unwrap()
8072 && !self.argvs.lock().unwrap().is_empty()
8073 {
8074 anyhow::bail!("forge unreachable");
8075 }
8076 let mut v = self.views.lock().unwrap();
8077 let mut seen = if v.len() > 1 {
8078 v.pop_front().unwrap()
8079 } else {
8080 v[0].clone()
8081 };
8082 if *self.merged.lock().unwrap() {
8083 seen.pr.state = PrLifecycle::Merged;
8084 }
8085 Ok(seen)
8086 }
8087 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
8088 self.log.lock().unwrap().push("merge");
8089 self.argvs.lock().unwrap().push(argv.to_vec());
8090 let out = self
8091 .merges
8092 .lock()
8093 .unwrap()
8094 .pop_front()
8095 .expect("unscripted merge");
8096 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
8097 {
8098 *self.merged.lock().unwrap() = true;
8099 }
8100 Ok(out)
8101 }
8102 async fn poll(&self) {
8103 self.log.lock().unwrap().push("poll");
8104 *self.clock.lock().unwrap() += POLL.as_secs() as i64;
8105 }
8106 async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
8107 self.log.lock().unwrap().push("rerun");
8108 self.reruns.lock().unwrap().push(run.to_owned());
8109 if *self.rerun_fails.lock().unwrap() {
8110 anyhow::bail!("gh run rerun: no");
8111 }
8112 Ok(())
8113 }
8114 fn now(&self) -> i64 {
8115 *self.clock.lock().unwrap()
8116 }
8117 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
8118 self.required.lock().unwrap().clone()
8119 }
8120 async fn fix(
8121 &self,
8122 _state: &mut RunState,
8123 _pr: &PrState,
8124 _round: usize,
8125 _budget: usize,
8126 _reason: &str,
8127 _logs: &str,
8128 ) -> Result<Fixed> {
8129 self.log.lock().unwrap().push("fix");
8130 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
8131 }
8132 }
8133
8134 const REFUSED: &str =
8135 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
8136
8137 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
8138 let mut pr = green_pr();
8139 pr.checks = checks;
8140 pr.blocking = Blocking::of(merge_state);
8141 if !comments {
8142 pr.review_comments.clear();
8143 }
8144 Seen {
8145 pr,
8146 title: "feat: x".to_owned(),
8147 failing_urls: Vec::new(),
8148 head: head.to_owned(),
8149 rollup_head: head.to_owned(),
8150 merge_state: merge_state.to_owned(),
8151 contexts: Vec::new(),
8152 base: "main".to_owned(),
8153 }
8154 }
8155
8156 fn landing_state() -> RunState {
8157 crate::run::pin_test_home();
8158 let mut state = run_state();
8159 state.config.graph.land_approval = false;
8160 state
8161 }
8162
8163 #[test]
8164 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
8165 assert!(!awaiting_new_head(None, "aaa"));
8166 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
8167 assert!(awaiting_new_head(Some("abc123"), "def456"));
8168 assert!(awaiting_new_head(Some("abc123"), ""));
8169 }
8170
8171 #[test]
8172 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
8173 let open = |c, m: &str| seen("a", c, m, false);
8174 let table = [
8175 (None, false, Refused::Pending),
8176 (
8177 Some(open(Checks::Pending, "BLOCKED")),
8178 false,
8179 Refused::Pending,
8180 ),
8181 (
8182 Some(open(Checks::Unknown, "BLOCKED")),
8183 false,
8184 Refused::Pending,
8185 ),
8186 (
8187 Some(open(Checks::Green, "UNKNOWN")),
8188 false,
8189 Refused::Pending,
8190 ),
8191 (Some(open(Checks::Green, "")), false, Refused::Pending),
8192 (
8193 Some(open(Checks::Green, "BLOCKED")),
8194 false,
8195 Refused::Recheck,
8196 ),
8197 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
8198 ];
8199 for (after, rechecked, want) in table {
8200 assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
8201 }
8202 let mut closed = open(Checks::Green, "CLEAN");
8203 closed.pr.state = PrLifecycle::Closed;
8204 assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
8205 }
8206
8207 #[tokio::test]
8208 async fn a_normal_landing_merges_on_the_first_look() {
8209 let mut state = landing_state();
8210 let forge = Scripted::new(
8211 vec![seen("a", Checks::Green, "CLEAN", false)],
8212 vec![(true, "")],
8213 );
8214 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8215 .await
8216 .unwrap();
8217 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
8219 assert_eq!(state.status, RunStatus::Merged);
8220 }
8221
8222 #[tokio::test]
8223 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
8224 let mut state = landing_state();
8225 let forge = Scripted::new(
8226 vec![seen("a", Checks::Green, "CLEAN", false)],
8227 std::iter::repeat_n((true, ""), 100).collect(),
8228 );
8229 *forge.queued.lock().unwrap() = true;
8230 let task = async {
8231 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8232 .await
8233 .unwrap();
8234 };
8235 task.await;
8238 assert_ne!(state.status, RunStatus::Merged);
8239 }
8240
8241 #[tokio::test]
8242 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
8243 let mut state = landing_state();
8244 let forge = Scripted::new(
8245 vec![seen("a", Checks::Green, "CLEAN", false)],
8246 std::iter::repeat_n((true, ""), 100).collect(),
8247 );
8248 *forge.unreadable_after_merge.lock().unwrap() = true;
8249 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8250 .await
8251 .ok();
8252 assert_ne!(state.status, RunStatus::Merged);
8253 }
8254
8255 #[tokio::test]
8256 async fn a_task_public_head_is_awaited_on_land_entry() {
8257 let mut state = landing_state();
8258 state.pr_head = Some("magi/task-abcd-1234".into());
8259 state.pr_pushed_head = Some("new".into());
8260 let forge = Scripted::new(
8261 vec![
8262 seen("old", Checks::Green, "CLEAN", false),
8263 seen("new", Checks::Green, "CLEAN", false),
8264 ],
8265 vec![(true, "")],
8266 );
8267 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8268 .await
8269 .unwrap();
8270 assert_eq!(
8271 forge.calls(),
8272 ["view", "poll", "view", "view", "merge", "view"]
8273 );
8274 assert_eq!(state.status, RunStatus::Merged);
8275 }
8276
8277 #[tokio::test]
8278 async fn a_superseded_public_head_owner_cannot_reenter_land() {
8279 let mut state = landing_state();
8280 state.pr_taken_over_by = Some("next".into());
8281 let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
8282 assert!(
8283 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8284 .await
8285 .is_err()
8286 );
8287 assert!(forge.calls().is_empty());
8288 }
8289
8290 #[tokio::test]
8291 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
8292 let mut state = landing_state();
8293 let forge = Scripted::new(
8294 vec![
8295 seen("old", Checks::Green, "CLEAN", true),
8296 seen("old", Checks::Green, "CLEAN", true),
8298 seen("new", Checks::Pending, "BLOCKED", true),
8299 seen("new", Checks::Green, "CLEAN", true),
8300 ],
8301 vec![(true, "")],
8302 );
8303 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
8304 head: "NEW".to_owned(),
8305 });
8306 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8307 .await
8308 .unwrap();
8309 assert_eq!(
8310 forge.calls(),
8311 [
8312 "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
8313 "view"
8314 ]
8315 );
8316 assert_eq!(state.status, RunStatus::Merged);
8317 }
8318
8319 #[tokio::test]
8320 async fn a_head_that_never_arrives_stops_naming_both_commits() {
8321 let mut state = landing_state();
8322 let forge = Scripted::new(
8323 vec![
8324 seen("old", Checks::Green, "CLEAN", true),
8325 seen("someone-elses", Checks::Green, "CLEAN", true),
8326 ],
8327 vec![],
8328 );
8329 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
8330 head: "mine".to_owned(),
8331 });
8332 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8333 .await
8334 .unwrap();
8335 assert!(!forge.calls().contains(&"merge"));
8336 let why = state.merge.as_ref().unwrap().detail.clone();
8337 assert!(
8338 why.contains("mine") && why.contains("someone-elses"),
8339 "{why}"
8340 );
8341 assert_eq!(state.status, RunStatus::Blocked);
8342 }
8343
8344 #[tokio::test]
8345 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
8346 let mut state = landing_state();
8347 let forge = Scripted::new(
8348 vec![
8349 seen("a", Checks::Green, "CLEAN", false),
8350 seen("a", Checks::Green, "CLEAN", false),
8351 seen("a", Checks::Pending, "BLOCKED", false),
8352 seen("a", Checks::Pending, "BLOCKED", false),
8353 seen("a", Checks::Green, "CLEAN", false),
8354 ],
8355 vec![(false, REFUSED), (true, "")],
8356 );
8357 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8358 .await
8359 .unwrap();
8360 assert_eq!(
8361 forge.calls(),
8362 [
8363 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
8364 "view"
8365 ]
8366 );
8367 assert_eq!(state.status, RunStatus::Merged);
8368 }
8369
8370 #[tokio::test]
8371 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
8372 let mut state = landing_state();
8373 let forge = Scripted::new(
8374 vec![
8375 seen("a", Checks::Green, "CLEAN", false),
8376 seen("a", Checks::Green, "BLOCKED", false),
8377 ],
8378 vec![(false, REFUSED), (false, REFUSED)],
8379 );
8380 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8381 .await
8382 .unwrap();
8383 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
8385 let why = state.merge.as_ref().unwrap().detail.clone();
8386 assert!(
8387 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
8388 "{why}"
8389 );
8390 assert_eq!(state.status, RunStatus::Blocked);
8391 }
8392
8393 #[test]
8394 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
8395 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
8396 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
8397 assert_eq!(bound_head("old", "old", Some("new")), None);
8399 assert_eq!(bound_head("new", "old", Some("new")), None);
8401 assert_eq!(bound_head("new", "old", None), None);
8402 assert_eq!(bound_head("", "", None), None);
8404 assert_eq!(bound_head("", "", Some("new")), None);
8405 assert_eq!(bound_head("abc", "", None), None);
8406 }
8407
8408 fn view_json(head: &str) -> String {
8409 format!(
8410 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
8411 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
8412 "reviews":[],"comments":[]}}"#
8413 )
8414 }
8415
8416 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
8417 format!(
8418 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
8419 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
8420 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
8421 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
8422 )
8423 }
8424
8425 #[test]
8426 fn rollup_is_bound_to_the_commit_in_the_same_node() {
8427 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
8430 assert_eq!(s.rollup_head, "old");
8431 assert_eq!(s.pr.checks, Checks::Red);
8432 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
8433 assert_eq!(s.failing_urls.len(), 1);
8434 }
8435
8436 #[test]
8437 fn checks_come_from_the_node_not_the_view() {
8438 let view = view_json("new").replace(
8439 r#""reviews""#,
8440 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
8441 );
8442 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
8443 assert_eq!(s.pr.checks, Checks::Green);
8444 assert!(s.pr.failing.is_empty());
8445 assert_eq!(
8446 bound_head(&s.head, &s.rollup_head, Some("new")),
8447 Some("new")
8448 );
8449 }
8450
8451 #[test]
8452 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
8453 for node in [
8454 None,
8455 Some("not json".to_owned()),
8456 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
8457 Some(node_json("new", "SUCCESS", true)),
8458 ] {
8459 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
8460 assert!(s.rollup_head.is_empty());
8461 assert_eq!(s.pr.checks, Checks::Unknown);
8462 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
8463 }
8464 }
8465
8466 #[test]
8467 fn the_merge_command_is_pinned_to_the_observed_head() {
8468 let argv = merge_argv_at(7, "feat: x", "deadbeef");
8469 let at = argv
8470 .iter()
8471 .position(|a| a == "--match-head-commit")
8472 .unwrap();
8473 assert_eq!(argv[at + 1], "deadbeef");
8474 }
8475
8476 #[tokio::test]
8477 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
8478 let mut state = landing_state();
8479 let mut stale = seen("new", Checks::Red, "CLEAN", false);
8482 stale.rollup_head = "old".to_owned();
8483 let forge = Scripted::new(
8484 vec![seen("old", Checks::Green, "CLEAN", true), stale],
8485 vec![],
8486 );
8487 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
8488 head: "new".to_owned(),
8489 });
8490 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8491 .await
8492 .unwrap();
8493 assert!(!forge.calls().contains(&"merge"));
8494 assert_eq!(state.status, RunStatus::Blocked);
8495 let why = state.merge.as_ref().unwrap().detail.clone();
8496 assert!(why.contains("new") && why.contains("old"), "{why}");
8497 }
8498
8499 #[test]
8500 fn a_refusal_read_against_another_commits_checks_is_pending() {
8501 let mut after = seen("a", Checks::Green, "BLOCKED", false);
8502 after.rollup_head = "old".to_owned();
8503 assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
8504 }
8505
8506 #[tokio::test]
8507 async fn a_matching_head_with_red_non_required_checks_still_merges() {
8508 let mut state = landing_state();
8509 let forge = Scripted::new(
8510 vec![seen("a", Checks::Red, "CLEAN", false)],
8511 vec![(true, "")],
8512 );
8513 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8514 .await
8515 .unwrap();
8516 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
8517 assert_eq!(state.status, RunStatus::Merged);
8518 }
8519
8520 #[tokio::test]
8521 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
8522 let mut state = landing_state();
8523 let forge = Scripted::new(
8524 vec![
8525 seen("a", Checks::Green, "CLEAN", false),
8526 seen("a", Checks::Green, "CLEAN", false),
8527 seen("b", Checks::Green, "BLOCKED", false),
8529 seen("b", Checks::Green, "CLEAN", false),
8530 ],
8531 vec![(false, REFUSED), (true, "")],
8532 );
8533 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8534 .await
8535 .unwrap();
8536 assert_eq!(
8537 forge.calls(),
8538 [
8539 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
8540 ]
8541 );
8542 assert_eq!(state.status, RunStatus::Merged);
8543 }
8544
8545 fn merged_view(head: &str) -> Seen {
8546 let mut m = seen(head, Checks::Green, "CLEAN", false);
8547 m.pr.state = PrLifecycle::Merged;
8548 m
8549 }
8550
8551 fn has(argv: &[String], flag: &str) -> bool {
8552 argv.iter().any(|a| a == flag)
8553 }
8554
8555 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
8556 let at = argv.iter().position(|a| a == flag)?;
8557 argv.get(at + 1).map(String::as_str)
8558 }
8559
8560 const URL: &str = "https://github.com/o/r/pull/42";
8561
8562 #[tokio::test]
8563 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
8564 let mut state = landing_state();
8565 let forge = Scripted::new(
8566 vec![
8567 seen("abc", Checks::Green, "CLEAN", false),
8568 seen("abc", Checks::Green, "CLEAN", false),
8569 ],
8570 vec![(true, "")],
8571 );
8572 land_with(&mut state, URL, &forge).await.unwrap();
8573 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
8574 let argv = &forge.argvs()[0];
8575 assert!(has(argv, "--squash") && has(argv, "--subject"));
8576 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
8577 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
8578 assert_eq!(state.status, RunStatus::Merged);
8579 assert!(state.land_armed_head.is_none());
8580 }
8581
8582 #[tokio::test]
8583 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
8584 let mut state = landing_state();
8585 state.land_armed_head = Some("a".to_owned());
8586 let forge = Scripted::new(
8587 vec![seen("a", Checks::Green, "CLEAN", false)],
8588 vec![(true, ""), (true, "")],
8589 );
8590 land_with(&mut state, URL, &forge).await.unwrap();
8591 let argvs = forge.argvs();
8592 assert!(has(&argvs[0], "--disable-auto"));
8593 assert!(!has(&argvs[1], "--auto"));
8594 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
8595 assert_eq!(state.status, RunStatus::Merged);
8596 assert!(state.land_armed_head.is_none());
8597 }
8598
8599 #[tokio::test]
8600 async fn an_approval_never_carries_over_to_a_new_head() {
8601 crate::run::pin_test_home();
8602 let mut state = run_state();
8603 state.config.graph.land_approval = true;
8604 let pr = green_pr();
8605 let store = ask::Questions::open();
8606
8607 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
8608 .await
8609 .unwrap();
8610 let mut q = store
8611 .list()
8612 .into_iter()
8613 .find(|q| q.run == state.id)
8614 .unwrap();
8615 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8616 store.put(&mut q).unwrap();
8617 assert_eq!(
8618 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
8619 .await
8620 .unwrap(),
8621 ApprovalGate::Approved,
8622 "the same head keeps its approval"
8623 );
8624
8625 assert_eq!(
8627 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
8628 .await
8629 .unwrap(),
8630 ApprovalGate::Pending
8631 );
8632 let all: Vec<_> = store
8633 .list()
8634 .into_iter()
8635 .filter(|q| q.run == state.id)
8636 .collect();
8637 assert_eq!(all.len(), 2);
8638
8639 state.land_approval = None;
8641 assert_eq!(
8642 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
8643 .await
8644 .unwrap(),
8645 ApprovalGate::Pending
8646 );
8647 let open = store
8648 .list()
8649 .into_iter()
8650 .filter(|q| q.run == state.id && q.status.open())
8651 .count();
8652 assert_eq!(open, 1, "the superseded question was retired");
8653 }
8654
8655 #[tokio::test]
8656 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
8657 let mut state = landing_state();
8658 let forge = Scripted::new(
8659 vec![
8660 seen("a", Checks::Green, "CLEAN", false),
8661 seen("b", Checks::Green, "CLEAN", false),
8663 ],
8664 vec![(true, "")],
8665 );
8666 land_with(&mut state, URL, &forge).await.unwrap();
8667 let argvs = forge.argvs();
8668 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
8669 assert!(!has(&argvs[0], "--auto"));
8670 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
8671 assert_eq!(state.status, RunStatus::Merged);
8672 }
8673
8674 fn passing(label: &str) -> CheckInfo {
8675 CheckInfo {
8676 label: label.to_owned(),
8677 verdict: Verdict::Pass,
8678 required: Some(false),
8679 run: None,
8680 }
8681 }
8682
8683 fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
8684 (label.to_owned(), run.map(str::to_owned))
8685 }
8686
8687 fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
8688 LandReruns {
8689 head: head.to_owned(),
8690 runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
8691 }
8692 }
8693
8694 #[test]
8695 fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
8696 let f = [failing("test (macos)", Some("7"))];
8697 assert_eq!(
8698 rerun_step(&f, None, "h", 100),
8699 RerunStep::Rerun(vec!["7".to_owned()])
8700 );
8701 let rec = reran("h", &[("7", 100)]);
8702 let grace = crate::release_watch::RERUN_GRACE;
8703 assert_eq!(
8704 rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
8705 RerunStep::Grace
8706 );
8707 assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
8708 }
8709
8710 #[test]
8711 fn a_failure_without_a_run_id_goes_to_the_fixer() {
8712 let f = [failing("a", Some("7")), failing("ci/external", None)];
8713 assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
8714 assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
8715 }
8716
8717 #[test]
8718 fn a_moved_head_forgets_the_reruns() {
8719 let f = [failing("a", Some("7"))];
8720 let rec = reran("old", &[("7", 100)]);
8721 assert_eq!(
8722 rerun_step(&f, Some(&rec), "new", 101),
8723 RerunStep::Rerun(vec!["7".to_owned()])
8724 );
8725 }
8726
8727 #[test]
8728 fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
8729 let f = [
8730 failing("a", Some("7")),
8731 failing("b", Some("7")),
8732 failing("c", Some("8")),
8733 ];
8734 assert_eq!(
8735 rerun_step(&f, None, "h", 0),
8736 RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
8737 );
8738 let rec = reran("h", &[("7", 0)]);
8739 assert_eq!(
8740 rerun_step(&f, Some(&rec), "h", 1),
8741 RerunStep::Rerun(vec!["8".to_owned()])
8742 );
8743 }
8744
8745 #[test]
8746 fn only_blocking_failures_are_candidates_for_a_rerun() {
8747 let check = |label: &str, verdict, required, run: &str| CheckInfo {
8748 label: label.to_owned(),
8749 verdict,
8750 required,
8751 run: Some(run.to_owned()),
8752 };
8753 let got = rerunnable(&[
8754 check("passed", Verdict::Pass, Some(true), "1"),
8755 check("optional", Verdict::Fail, Some(false), "2"),
8756 check("required", Verdict::Fail, Some(true), "3"),
8757 check("unknown", Verdict::Fail, None, "4"),
8758 ]);
8759 let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
8760 assert_eq!(labels, ["required", "unknown"]);
8761 }
8762
8763 fn red(head: &str, run: Option<&str>) -> Seen {
8764 let mut s = seen(head, Checks::Red, "BLOCKED", false);
8765 s.pr.failing = vec!["test (macos)".to_owned()];
8766 s.contexts = vec![CheckInfo {
8767 label: "test (macos)".to_owned(),
8768 verdict: Verdict::Fail,
8769 required: Some(true),
8770 run: run.map(str::to_owned),
8771 }];
8772 s
8773 }
8774
8775 const RERUN_PR: &str = "https://github.com/o/r/pull/42";
8776
8777 #[tokio::test]
8778 async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
8779 let mut state = landing_state();
8780 let forge = Scripted::new(
8781 vec![
8782 red("a", Some("7")),
8783 seen("a", Checks::Pending, "BLOCKED", false),
8784 seen("a", Checks::Green, "CLEAN", false),
8785 ],
8786 vec![(true, "")],
8787 );
8788 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8789 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8790 assert!(!forge.calls().contains(&"fix"));
8791 assert_eq!(state.status, RunStatus::Merged);
8792 assert_eq!(state.pr.as_ref().unwrap().round, 0);
8793 let argvs = forge.argvs();
8794 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
8795 assert!(
8796 state
8797 .events
8798 .iter()
8799 .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
8800 );
8801 }
8802
8803 #[tokio::test]
8804 async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
8805 let mut state = landing_state();
8806 let forge = Scripted::new(
8807 vec![
8808 red("a", Some("7")),
8809 seen("a", Checks::Green, "CLEAN", false),
8810 ],
8811 vec![(true, "")],
8812 );
8813 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8814 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8816 assert_eq!(forge.reruns.lock().unwrap().len(), 1);
8817 let calls = forge.calls();
8818 let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
8819 let fix_at = calls.iter().position(|c| *c == "fix");
8820 assert!(fix_at.is_none_or(|f| f > rerun_at));
8821 }
8822
8823 #[tokio::test]
8824 async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
8825 let mut state = landing_state();
8826 let mut v = red("a", Some("7"));
8827 v.pr.checks = Checks::Pending;
8828 v.contexts.push(CheckInfo {
8829 label: "slow".to_owned(),
8830 verdict: Verdict::Pending,
8831 required: Some(true),
8832 run: Some("8".to_owned()),
8833 });
8834 let forge = Scripted::new(
8835 vec![v, seen("a", Checks::Green, "CLEAN", false)],
8836 vec![(true, "")],
8837 );
8838 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8839 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8840 assert_eq!(state.status, RunStatus::Merged);
8841 }
8842
8843 #[test]
8844 fn a_run_with_a_job_still_going_is_not_rerun_yet() {
8845 let mut v = red("a", Some("7"));
8846 v.contexts.push(CheckInfo {
8847 label: "same run, still going".to_owned(),
8848 verdict: Verdict::Pending,
8849 required: Some(true),
8850 run: Some("7".to_owned()),
8851 });
8852 assert!(rerunnable(&v.contexts).is_empty());
8853 }
8854
8855 #[tokio::test]
8856 async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
8857 let mut state = landing_state();
8858 state.config.graph.land_rounds = 0;
8859 let forge = Scripted::new(
8860 vec![
8861 red("a", Some("7")),
8862 seen("a", Checks::Green, "CLEAN", false),
8863 ],
8864 vec![(true, "")],
8865 );
8866 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8867 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8868 assert_eq!(state.status, RunStatus::Merged);
8869 assert!(!forge.calls().contains(&"fix"));
8870 assert_eq!(state.pr.as_ref().unwrap().round, 0);
8871 }
8872
8873 #[tokio::test]
8874 async fn the_last_fix_rounds_new_head_gets_its_rerun() {
8875 let mut state = landing_state();
8876 state.config.graph.land_rounds = 1;
8877 let forge = Scripted::new(
8878 vec![
8879 seen("a", Checks::Green, "CLEAN", true),
8880 red("b", Some("7")),
8881 seen("b", Checks::Green, "CLEAN", false),
8882 ],
8883 vec![(true, "")],
8884 );
8885 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
8886 head: "b".to_owned(),
8887 });
8888 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8889 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8890 assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
8891 assert_eq!(state.pr.as_ref().unwrap().round, 1);
8892 assert_eq!(state.status, RunStatus::Merged);
8893 assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
8894 assert_eq!(
8895 value_of(&forge.argvs()[0], "--match-head-commit"),
8896 Some("b")
8897 );
8898 }
8899
8900 #[tokio::test]
8901 async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
8902 let mut state = landing_state();
8903 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8904 *forge.rerun_fails.lock().unwrap() = true;
8905 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8906 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8907 assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
8908 assert_eq!(
8909 forge.calls().iter().filter(|c| **c == "fix").count(),
8910 1,
8911 "{:?}",
8912 forge.calls()
8913 );
8914 }
8915
8916 #[tokio::test]
8917 async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
8918 let mut state = landing_state();
8919 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8920 state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
8922 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8923 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8924 assert!(forge.reruns.lock().unwrap().is_empty());
8925 }
8926
8927 #[tokio::test]
8928 async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
8929 let mut state = landing_state();
8930 state.config.graph.rerun_flaky_checks = false;
8931 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8932 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8933 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8934 assert!(forge.reruns.lock().unwrap().is_empty());
8935 assert!(forge.calls().contains(&"fix"));
8936
8937 let mut state = landing_state();
8938 let forge = Scripted::new(vec![red("a", None)], vec![]);
8939 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8940 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8941 assert!(forge.reruns.lock().unwrap().is_empty());
8942 assert!(forge.calls().contains(&"fix"));
8943 }
8944
8945 fn names(xs: &[&str]) -> BTreeSet<String> {
8946 xs.iter().map(|x| (*x).to_owned()).collect()
8947 }
8948
8949 #[test]
8950 fn a_required_check_the_rollup_never_listed_is_named() {
8951 let req = names(&["build"]);
8952 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
8953 assert!(why.contains("never reported: build"), "{why}");
8954 assert!(!why.contains("probably waiting for a review"), "{why}");
8955 }
8956
8957 #[test]
8958 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
8959 let why = waiting_on("BLOCKED", &[passing("review")], None);
8960 assert!(why.contains("could not be read"), "{why}");
8961 assert!(!why.contains("probably waiting for a review"), "{why}");
8962 }
8963
8964 #[test]
8965 fn all_required_reported_keeps_the_review_guess() {
8966 let req = names(&["build"]);
8967 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
8968 assert!(why.contains("probably waiting for a review"), "{why}");
8969 assert!(!why.contains("never reported"), "{why}");
8970 }
8971
8972 #[test]
8973 fn required_names_match_the_rollup_ignoring_case_only() {
8974 let req = names(&["Build"]);
8975 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
8976 assert!(!why.contains("never reported"), "{why}");
8977 }
8978
8979 #[test]
8980 fn required_contexts_are_read_from_protection_and_rulesets() {
8981 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
8982 assert_eq!(
8983 parse_classic_required(classic),
8984 Some(names(&["build", "lint"]))
8985 );
8986 let rules = r#"[{"type":"pull_request","parameters":{}},
8987 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
8988 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
8989 assert_eq!(parse_ruleset_required("nope"), None);
8990 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
8991 }
8992
8993 #[test]
8994 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
8995 let shown = BTreeSet::new();
8996 let ok = seen("a", Checks::Green, "CLEAN", false);
8997 let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
8998 assert!(guard(Some(&ok)));
8999 assert!(!guard(None));
9000 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
9001 let mut stale = ok.clone();
9002 stale.rollup_head = "old".to_owned();
9003 assert!(!guard(Some(&stale)));
9004 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
9005 assert!(!guard(Some(&merged_view("a"))));
9006 }
9007
9008 #[test]
9009 fn the_rollup_node_carries_whether_each_check_is_required() {
9010 let node = node_json("new", "SUCCESS", false)
9011 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
9012 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
9013 assert_eq!(s.contexts.len(), 1);
9014 assert_eq!(s.contexts[0].required, Some(true));
9015 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
9016 assert_eq!(s.contexts[0].required, None);
9017 }
9018
9019 #[tokio::test]
9020 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
9021 let mut state = landing_state();
9022 state.land_armed_head = Some("a".to_owned());
9023 let forge = Scripted::new(
9024 vec![seen("a", Checks::Green, "CLEAN", true)],
9025 vec![(false, "disable exploded")],
9026 );
9027 land_with(&mut state, URL, &forge).await.unwrap();
9028 assert!(!forge.calls().contains(&"fix"));
9029 assert_eq!(state.status, RunStatus::Blocked);
9030 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
9031 let why = state.merge.as_ref().unwrap().detail.clone();
9032 assert!(why.contains("disable exploded"), "{why}");
9033 }
9034}