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,
349}
350
351pub fn decide_with(
354 pr: &PrState,
355 round: usize,
356 budget: usize,
357 waited: Duration,
358 ci: CiExpectation,
359) -> Step {
360 match pr.state {
361 PrLifecycle::Merged => return Step::Done { merged: true },
362 PrLifecycle::Closed => return Step::Done { merged: false },
363 PrLifecycle::Open => {}
364 }
365
366 if pr.blocking == Blocking::Conflict {
369 return Step::Rebase;
370 }
371
372 let spent = round >= budget;
373 let checks = match ci {
374 CiExpectation::Expected => pr.checks,
375 CiExpectation::Absent => Checks::Green,
376 };
377 match checks {
378 Checks::Pending => Step::Wait,
379 Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
380 Checks::Unknown => Step::GiveUp {
381 reason: format!(
382 "no check status is readable on the pull request after {} minute(s); \
383 refusing to merge on a guess",
384 CHECKS_GRACE.as_secs() / 60
385 ),
386 },
387 Checks::Red if pr.blocking == Blocking::Unsaid && waited < CHECKS_GRACE => Step::Wait,
394 Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
395 Checks::Red => {
396 let what = format!(
397 "{} check(s) failing: {}",
398 pr.failing.len(),
399 pr.failing.join(", ")
400 );
401 if spent {
402 Step::GiveUp {
403 reason: format!("{what} — still red after {budget} fix round(s)"),
404 }
405 } else {
406 Step::Fix { reason: what }
407 }
408 }
409 Checks::Green if pr.review_comments.is_empty() => Step::Merge,
410 Checks::Green => {
411 let what = format!(
412 "{} but {} review comment(s) are unresolved: {}",
413 if ci == CiExpectation::Absent {
414 "CI is not expected"
415 } else {
416 "checks are green"
417 },
418 pr.review_comments.len(),
419 authors(&pr.review_comments)
420 );
421 if spent {
422 Step::GiveUp {
423 reason: format!("{what} — still unresolved after {budget} fix round(s)"),
424 }
425 } else {
426 Step::Fix { reason: what }
427 }
428 }
429 }
430}
431
432fn authors(comments: &[ReviewComment]) -> String {
434 let mut seen: Vec<&str> = Vec::new();
435 for c in comments {
436 if !seen.contains(&c.author.as_str()) {
437 seen.push(&c.author);
438 }
439 }
440 seen.join(", ")
441}
442
443pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
447 vec![
448 "pr".to_owned(),
449 "merge".to_owned(),
450 number.to_string(),
451 "--squash".to_owned(),
452 "--delete-branch".to_owned(),
453 "--subject".to_owned(),
454 subject.to_owned(),
455 ]
456}
457
458pub fn merge_argv_at(number: u64, subject: &str, head: &str) -> Vec<String> {
464 let mut argv = merge_argv(number, subject);
465 argv.push("--match-head-commit".to_owned());
466 argv.push(head.to_owned());
467 argv
468}
469
470pub fn disable_automerge_argv(number: u64) -> Vec<String> {
475 ["pr", "merge", &number.to_string(), "--disable-auto"]
476 .map(str::to_owned)
477 .to_vec()
478}
479
480fn direct_merge_is_safe(
486 fresh: Option<&Seen>,
487 approved_head: &str,
488 shown: &BTreeSet<String>,
489 round: usize,
490 budget: usize,
491 waited: Duration,
492 ci: CiExpectation,
493) -> bool {
494 let Some(fresh) = fresh else {
495 return false;
496 };
497 if fresh.pr.state != PrLifecycle::Open {
498 return false;
499 }
500 let Some(bound) = bound_head(&fresh.head, &fresh.rollup_head, None) else {
501 return false;
502 };
503 if !bound.eq_ignore_ascii_case(approved_head) {
504 return false;
505 }
506 let mut pr = fresh.pr.clone();
507 pr.review_comments.retain(|c| !shown.contains(&c.body));
508 decide_with(&pr, round, budget, waited, ci) == Step::Merge
509}
510
511fn waiting_on(
518 merge_state: &str,
519 contexts: &[CheckInfo],
520 required_set: Option<&BTreeSet<String>>,
521) -> String {
522 let state = if merge_state.is_empty() {
523 "unknown"
524 } else {
525 merge_state
526 };
527 let tag = |c: &CheckInfo| match c.verdict {
528 Verdict::Fail => "failed",
529 _ => "pending",
530 };
531 let unsettled: Vec<&CheckInfo> = contexts
532 .iter()
533 .filter(|c| c.verdict != Verdict::Pass)
534 .collect();
535 let required: Vec<String> = unsettled
536 .iter()
537 .filter(|c| c.required == Some(true))
538 .map(|c| format!("{} ({})", c.label, tag(c)))
539 .collect();
540 let unknown: Vec<String> = unsettled
541 .iter()
542 .filter(|c| c.required.is_none())
543 .map(|c| format!("{} ({})", c.label, tag(c)))
544 .collect();
545 let never: Vec<&str> = required_set
549 .map(|set| {
550 set.iter()
551 .filter(|name| !contexts.iter().any(|c| c.label.eq_ignore_ascii_case(name)))
552 .map(String::as_str)
553 .collect()
554 })
555 .unwrap_or_default();
556 let mut out = format!("merge state: {state}");
557 if !never.is_empty() {
558 let _ = write!(
559 out,
560 "; required checks never reported: {}",
561 never.join(", ")
562 );
563 }
564 if !required.is_empty() {
565 let _ = write!(
566 out,
567 "; required checks not passing: {}",
568 required.join(", ")
569 );
570 }
571 if !unknown.is_empty() {
572 let _ = write!(
573 out,
574 "; whether these are required could not be read, so they may be: {}",
575 unknown.join(", ")
576 );
577 }
578 if required.is_empty() && unknown.is_empty() && never.is_empty() {
579 if required_set.is_some() {
580 out.push_str(
581 "; no required check is pending, failing or unreported, so GitHub is probably \
582 waiting for a review or another branch rule",
583 );
584 } else {
585 out.push_str(
586 "; the required check list could not be read, so a required check that was \
587 never reported cannot be ruled out",
588 );
589 }
590 }
591 out
592}
593
594pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
601 let title = pr_title.trim();
602 if !title.is_empty() && !title.starts_with("magi: candidate") {
603 return title.to_owned();
604 }
605 let first = instruction
606 .lines()
607 .map(str::trim)
608 .find(|l| !l.is_empty())
609 .unwrap_or("magi: land the winning candidate");
610 first.trim_start_matches(['#', ' ']).to_owned()
611}
612
613pub const APPROVE: &str = "merge";
615
616pub const HOLD: &str = "hold";
618
619pub const REPAIR: &str = "fix findings and re-review";
621
622pub fn repair_available(state: &RunState) -> bool {
625 state.reviews.last().is_some_and(|r| {
626 state.open_findings().iter().any(|f| f.severity.blocks())
627 || r.final_votes()
628 .iter()
629 .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
630 })
631}
632
633#[derive(Debug, Clone, Copy, PartialEq, Eq)]
635pub enum ApprovalReason {
636 EveryMerge,
638 OpenBlockingFindings,
640 FinalReject,
642 ExistingDecision,
644 AfterRepair,
646}
647
648pub fn approval_reasons(state: &RunState) -> Vec<ApprovalReason> {
651 let mut reasons = Vec::new();
652 if state.owner_hold(crate::config::OwnerHold::LandApproval) {
653 reasons.push(ApprovalReason::EveryMerge);
654 }
655 if state.owner_hold(crate::config::OwnerHold::HoldContestedMerge) {
656 if state.open_findings().iter().any(|f| f.severity.blocks()) {
657 reasons.push(ApprovalReason::OpenBlockingFindings);
658 }
659 if state.reviews.last().is_some_and(|r| {
660 r.final_votes()
661 .iter()
662 .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
663 }) {
664 reasons.push(ApprovalReason::FinalReject);
665 }
666 }
667 if state.land_approval.is_some() {
668 reasons.push(ApprovalReason::ExistingDecision);
669 }
670 if !state.merge_repairs.is_empty() {
671 reasons.push(ApprovalReason::AfterRepair);
672 }
673 reasons
674}
675
676fn approval_excerpt(text: &str, max_chars: usize) -> String {
679 if text.chars().count() <= max_chars {
680 text.to_owned()
681 } else {
682 let mut excerpt: String = text.chars().take(max_chars.saturating_sub(1)).collect();
683 excerpt.push('…');
684 excerpt
685 }
686}
687
688fn approval_evidence(state: &RunState) -> String {
692 const TITLE_CHARS: usize = 100;
693 const REASON_CHARS: usize = 300;
694 let reason = |text: &str| approval_excerpt(text, REASON_CHARS);
695 let ja = words(&state.config.graph.language).html_lang == "ja";
696 let mut s = if ja {
697 "承認が必要な理由:\n"
698 } else {
699 "Approval required because:\n"
700 }
701 .to_owned();
702 for reason in approval_reasons(state) {
703 let label = match (reason, ja) {
704 (ApprovalReason::EveryMerge, false) => {
705 "land_approval=true: owner approval for every merge"
706 }
707 (ApprovalReason::EveryMerge, true) => {
708 "land_approval=true: すべてのマージに所有者の承認が必要"
709 }
710 (ApprovalReason::OpenBlockingFindings, false) => {
711 "hold_contested_merge=true: open Major-or-above internal findings"
712 }
713 (ApprovalReason::OpenBlockingFindings, true) => {
714 "hold_contested_merge=true: Major 以上の内部指摘が未解決"
715 }
716 (ApprovalReason::FinalReject, false) => {
717 "hold_contested_merge=true: final reviewer reject after reconsideration (even without Major findings)"
718 }
719 (ApprovalReason::FinalReject, true) => {
720 "hold_contested_merge=true: 再考後の最終票が reject(Major 指摘がなくても承認が必要)"
721 }
722 (ApprovalReason::ExistingDecision, false) => {
723 "existing owner decision: changing controls cannot bypass it"
724 }
725 (ApprovalReason::ExistingDecision, true) => {
726 "既存の所有者判断: 設定変更では迂回できません"
727 }
728 (ApprovalReason::AfterRepair, false) => {
729 "fresh owner approval after merge repair, even with an unchanged head"
730 }
731 (ApprovalReason::AfterRepair, true) => {
732 "マージ修復後の新しい所有者承認(head に変更がなくても必要)"
733 }
734 };
735 let control = match reason {
736 ApprovalReason::EveryMerge => Some(crate::config::OwnerHold::LandApproval),
737 ApprovalReason::OpenBlockingFindings | ApprovalReason::FinalReject => {
738 Some(crate::config::OwnerHold::HoldContestedMerge)
739 }
740 _ => None,
741 };
742 let source = control
743 .filter(|c| !c.base_value(&state.config.graph))
744 .and_then(|c| state.branch_owner_holds.iter().find(|h| h.control == c));
745 if let Some(source) = source {
746 if ja {
747 let _ = writeln!(
748 s,
749 "- {}=true(レビュー対象ブランチの {} 由来, {})",
750 source.control.key(),
751 source.file,
752 source.head
753 );
754 } else {
755 let _ = writeln!(
756 s,
757 "- {}=true from the reviewed branch's {} ({})",
758 source.control.key(),
759 source.file,
760 source.head
761 );
762 }
763 }
764 let _ = writeln!(s, "- {label}");
765 }
766 s.push_str(if ja {
767 "\n再考後の各レビュアーの最終票:\n"
768 } else {
769 "\nFinal reviewer votes after reconsideration:\n"
770 });
771 if let Some(round) = state.reviews.last() {
772 let votes = round.final_votes();
773 for r in &round.reviews {
774 let vote = votes
775 .iter()
776 .find(|(seat, _, _)| *seat == r.reviewer)
777 .map(|(_, _, v)| v.label())
778 .unwrap_or(if ja { "記録なし" } else { "not recorded" });
779 let _ = writeln!(s, "#{} ({}): {vote}", r.reviewer, r.agent);
780 let rv = round
781 .reconsideration
782 .iter()
783 .find(|rv| rv.reviewer == r.reviewer);
784 if let Some(rv) = rv.filter(|rv| rv.vote.is_some()) {
785 let initial = r.vote.map(|v| v.label()).unwrap_or("?");
786 let _ = writeln!(
787 s,
788 " {}: {initial} → {vote}",
789 if ja { "再考" } else { "reconsideration" }
790 );
791 let _ = writeln!(
792 s,
793 " {}: {}",
794 if ja { "理由" } else { "reason" },
795 reason(if rv.reason.trim().is_empty() {
796 if ja { "記録なし" } else { "not recorded" }
797 } else {
798 &rv.reason
799 })
800 );
801 } else {
802 let _ = writeln!(
803 s,
804 " {}: {}",
805 if ja {
806 "初回票を保持・理由"
807 } else {
808 "initial vote retained; reason"
809 },
810 reason(if r.summary.trim().is_empty() {
811 if ja { "記録なし" } else { "not recorded" }
812 } else {
813 &r.summary
814 })
815 );
816 if let Some(rv) = rv {
817 let _ = writeln!(
818 s,
819 " {}: {}",
820 if ja {
821 "再投票の回答なし"
822 } else {
823 "no reconsideration vote"
824 },
825 reason(rv.failed.as_deref().unwrap_or(if ja {
826 "理由の記録なし"
827 } else {
828 "reason not recorded"
829 }))
830 );
831 }
832 }
833 if let Some(failed) = &r.failed {
834 let _ = writeln!(
835 s,
836 " {}: {}",
837 if ja { "回答なし" } else { "no answer" },
838 reason(failed)
839 );
840 }
841 }
842 } else {
843 s.push_str(if ja {
844 "レビュー記録なし\n"
845 } else {
846 "No review recorded\n"
847 });
848 }
849 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" });
850 let findings = state.last_round_findings();
851 for f in &findings {
852 let _ = writeln!(
853 s,
854 "[{}] {} {}:{}: {}\n{}",
855 f.id,
856 f.severity,
857 f.file.as_deref().unwrap_or("?"),
858 f.line.map(|l| l.to_string()).unwrap_or_else(|| "?".into()),
859 approval_excerpt(&f.title, TITLE_CHARS),
860 reason(&f.detail)
861 );
862 let mut evidence = false;
863 for fix in state.reviews.iter().filter_map(|r| r.fix.as_ref()).chain(
864 state
865 .operator_fixes
866 .iter()
867 .filter_map(|request| request.fix.as_ref()),
868 ) {
869 if fix.addressed.contains(&f.id) {
870 evidence = true;
871 let _ = writeln!(
872 s,
873 " {} ({}): {}",
874 if ja {
875 "採用申告(解決未確認)"
876 } else {
877 "claimed addressed (resolution unconfirmed)"
878 },
879 fix.agent,
880 reason(&fix.notes)
881 );
882 }
883 for rejection in fix.rejected.iter().filter(|r| r.id == f.id) {
884 evidence = true;
885 let _ = writeln!(
886 s,
887 " {}: {}",
888 if ja { "不採用理由" } else { "declined" },
889 reason(&rejection.why)
890 );
891 }
892 }
893 if !evidence {
894 s.push_str(if ja {
895 " 採用・解決確認の記録なし\n"
896 } else {
897 " No adoption or resolution evidence recorded\n"
898 });
899 }
900 }
901 if findings.is_empty() {
902 s.push_str(if ja {
903 "最終ラウンドに指摘の記録なし\n"
904 } else {
905 "No findings recorded in the last round\n"
906 });
907 }
908 s
909}
910
911pub fn approval_choices(state: &RunState) -> Vec<String> {
913 let mut choices = vec![
914 APPROVE.to_owned(),
915 HOLD.to_owned(),
916 REPAIR.to_owned(),
917 crate::merge_decision::WITHDRAW.to_owned(),
918 crate::merge_decision::DISMISS.to_owned(),
919 ];
920 choices.extend(
921 state
922 .config
923 .agents
924 .iter()
925 .map(|a| format!("{}{id}", crate::merge_decision::REIMPLEMENT, id = a.id)),
926 );
927 choices
928}
929
930pub fn upgrade_repair_choice(q: &mut ask::Question, state: &RunState) -> bool {
933 if q.node != APPROVAL_NODE
934 || !state
935 .land_approval
936 .as_ref()
937 .is_some_and(|a| a.question == q.id)
938 {
939 return false;
940 }
941 let before = (q.choices.clone(), q.actions.clone(), q.merge_target.clone());
942 if q.status.open() {
943 q.actions.remove(HOLD);
946 for choice in approval_choices(state) {
947 if !q.choices.contains(&choice) {
948 q.choices.push(choice);
949 }
950 }
951 if q.merge_target.is_none() {
952 q.merge_target = state.pr.as_ref().map(|pr| crate::merge_decision::Target {
953 pr: pr.url.clone(),
954 head: state.land_approval.as_ref().unwrap().head.clone(),
955 language: state.config.graph.language.clone(),
956 });
957 }
958 }
959 for choice in q.choices.clone() {
960 if (q.status.open()
961 && crate::merge_decision::stops(&choice)
962 && choice != HOLD
963 && choice != crate::merge_decision::DISMISS)
964 || (choice == REPAIR && (q.status.open() || q.resolution().as_deref() == Some(REPAIR)))
965 {
966 q.actions.insert(
967 choice,
968 ask::ChoiceAction::Resume {
969 run: state.id.clone(),
970 },
971 );
972 }
973 }
974 before != (q.choices.clone(), q.actions.clone(), q.merge_target.clone())
975}
976
977pub fn unhedged(message: &str, quote: &str) -> bool {
985 let quote = quote.trim();
986 !quote.is_empty() && message.contains(quote) && !hedged(message) && !retracts(message)
987}
988
989fn hedged(text: &str) -> bool {
993 const WORDS: &[&str] = &[
994 "maybe",
995 "probably",
996 "perhaps",
997 "might",
998 "if",
999 "unless",
1000 "not",
1001 "no",
1002 "nope",
1003 "stop",
1004 "dont",
1005 "abort",
1006 "revert",
1007 "undo",
1008 "never",
1009 "wait",
1010 "hold",
1011 "cancel",
1012 "but",
1013 "think",
1014 "guess",
1015 "suppose",
1016 "unsure",
1017 "yet",
1018 "except",
1019 "only",
1020 "cannot",
1021 "should",
1022 "once",
1023 "provided",
1024 "providing",
1025 "when",
1026 "whenever",
1027 "after",
1028 "until",
1029 "before",
1030 "assuming",
1031 "given",
1032 "while",
1033 "whether",
1034 "depending",
1035 "pending",
1036 ];
1037 const JA: &[&str] = &[
1038 "かも",
1039 "たぶん",
1040 "多分",
1041 "なら",
1042 "たら",
1043 "ちょっと待",
1044 "しないで",
1045 "しない",
1046 "保留",
1047 "まだ",
1048 "ただし",
1049 "やめ",
1050 "いや",
1051 "止め",
1052 "だめ",
1053 "ダメ",
1054 "じゃない",
1055 "ではない",
1056 "ですか",
1057 "かな",
1058 "でしょう",
1059 "思う",
1060 "でも",
1061 "けど",
1062 "ただ",
1063 "次第",
1064 "場合",
1065 "限り",
1066 "条件",
1067 "とき",
1068 "まで",
1069 "後で",
1070 ];
1071 if text.contains(['?', '?']) || JA.iter().any(|w| text.contains(w)) {
1072 return true;
1073 }
1074 text.to_lowercase()
1075 .replace('\u{2019}', "'")
1076 .split(|c: char| !(c.is_alphanumeric() || c == '\'') || !c.is_ascii())
1077 .filter(|w| !w.is_empty())
1078 .any(|w| WORDS.contains(&w) || w.ends_with("n't"))
1079}
1080
1081fn retracts(message: &str) -> bool {
1084 let lower = message.to_lowercase();
1085 [
1086 "やっぱ",
1087 "待って",
1088 "撤回",
1089 "never mind",
1090 "actually",
1091 "on second thought",
1092 ]
1093 .iter()
1094 .any(|w| lower.contains(w))
1095 || lower
1096 .split(|c: char| !c.is_ascii_alphabetic())
1097 .any(|w| w == "wait")
1098}
1099
1100pub const APPROVAL_NODE: &str = "land-approval";
1106
1107pub const DIFF_MAX_LINES: usize = 400;
1115
1116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1118pub enum Approval {
1119 Merge,
1121 Repair,
1123 Hold,
1125}
1126
1127pub fn approval(answer: Option<&str>) -> Approval {
1135 match answer {
1136 Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
1137 Some(a) if a.trim() == REPAIR => Approval::Repair,
1138 _ => Approval::Hold,
1139 }
1140}
1141
1142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1144enum ApprovalGate {
1145 Approved,
1147 Repair,
1149 Held,
1152 Pending,
1154}
1155
1156fn esc(s: &str) -> String {
1166 let mut out = String::with_capacity(s.len());
1167 for c in s.chars() {
1168 match c {
1169 '&' => out.push_str("&"),
1170 '<' => out.push_str("<"),
1171 '>' => out.push_str(">"),
1172 '"' => out.push_str("""),
1173 '\'' => out.push_str("'"),
1174 _ => out.push(c),
1175 }
1176 }
1177 out
1178}
1179
1180#[derive(Debug, Clone, PartialEq, Eq)]
1182struct StatRow {
1183 path: String,
1184 added: Option<u64>,
1186 removed: Option<u64>,
1187}
1188
1189impl StatRow {
1190 fn churn(&self) -> u64 {
1193 self.added.unwrap_or(0) + self.removed.unwrap_or(0)
1194 }
1195}
1196
1197fn parse_numstat(numstat: &str) -> Vec<StatRow> {
1203 let mut rows: Vec<StatRow> = numstat
1204 .lines()
1205 .filter_map(|line| {
1206 let mut parts = line.splitn(3, '\t');
1207 let added = parts.next()?.trim();
1208 let removed = parts.next()?.trim();
1209 let path = parts.next()?.trim();
1210 if path.is_empty() {
1211 return None;
1212 }
1213 Some(StatRow {
1214 path: path.to_owned(),
1215 added: added.parse().ok(),
1216 removed: removed.parse().ok(),
1217 })
1218 })
1219 .collect();
1220 rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
1223 rows
1224}
1225
1226fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
1235 if line.starts_with("+++") || line.starts_with("---") {
1236 (" ", "color:#57606a;font-weight:600", line)
1237 } else if let Some(body) = line.strip_prefix('+') {
1238 ("+", "background:#e6ffec;color:#0a3622", body)
1239 } else if let Some(body) = line.strip_prefix('-') {
1240 ("-", "background:#ffebe9;color:#5c1a17", body)
1241 } else if line.starts_with("@@") {
1242 ("~", "background:#eef2ff;color:#3730a3", line)
1243 } else if let Some(body) = line.strip_prefix(' ') {
1244 (" ", "", body)
1245 } else {
1246 (" ", "color:#57606a;font-weight:600", line)
1247 }
1248}
1249
1250struct Words {
1259 html_lang: &'static str,
1260 task: &'static str,
1261 what_changed: &'static str,
1262 review_verdict: &'static str,
1263 reviewer: &'static str,
1264 reviewer_no_answer: &'static str,
1265 checks: &'static str,
1266 nothing_failing: &'static str,
1267 files_changed: &'static str,
1268 commits: &'static str,
1269 no_commits: &'static str,
1270 comments: &'static str,
1271 no_comments: &'static str,
1272 diff: &'static str,
1273 truncated: &'static str,
1274 lands_as: &'static str,
1275}
1276
1277const EN: Words = Words {
1278 html_lang: "en",
1279 task: "Task",
1280 what_changed: "What changed",
1281 review_verdict: "Review verdict",
1282 reviewer: "Reviewer",
1283 reviewer_no_answer: "produced no answer",
1284 checks: "Checks",
1285 nothing_failing: "Nothing failing.",
1286 files_changed: "file(s) changed",
1287 commits: "Commits being squashed",
1288 no_commits: "No commit subjects could be read from the branch.",
1289 comments: "External PR review comments",
1290 no_comments: "No unaddressed external PR comments at this observation. This does not mean internal findings are resolved.",
1291 diff: "Diff",
1292 truncated: "Truncated",
1293 lands_as: "They land as one commit titled",
1294};
1295
1296const JA: Words = Words {
1297 html_lang: "ja",
1298 task: "タスク",
1299 what_changed: "変更内容",
1300 review_verdict: "レビューの結論",
1301 reviewer: "レビュアー",
1302 reviewer_no_answer: "回答なし",
1303 checks: "チェック",
1304 nothing_failing: "失敗しているものはありません。",
1305 files_changed: "ファイル変更",
1306 commits: "squash されるコミット",
1307 no_commits: "ブランチからコミット件名を読めませんでした。",
1308 comments: "外部 PR レビューコメント",
1309 no_comments: "この時点で未対応の外部 PR コメントはありません。内部指摘の解決を意味するものではありません。",
1310 diff: "差分",
1311 truncated: "省略",
1312 lands_as: "これらは次の件名の1コミットとして入ります:",
1313};
1314
1315impl Words {
1316 fn lands_as_tail(&self) -> &'static str {
1320 if self.html_lang == "ja" {
1321 "。この件名も承認の対象です。"
1322 } else {
1323 ", which you are approving too."
1324 }
1325 }
1326
1327 fn approval_summary(&self, number: u64, subject: &str) -> String {
1329 if self.html_lang == "ja" {
1330 format!("プルリクエスト #{number} をマージ: {subject}")
1331 } else {
1332 format!("merge pull request #{number}: {subject}")
1333 }
1334 }
1335
1336 fn approval_detail(
1338 &self,
1339 url: &str,
1340 base: &str,
1341 subject: &str,
1342 contested: Option<&ContestedHandoff>,
1343 ) -> String {
1344 let body = if self.html_lang == "ja" {
1345 format!(
1346 "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
1347 できる状態です。差分の要約・パッチ・squash されるコミットは\
1348 下のパネルにあります。"
1349 )
1350 } else {
1351 format!(
1352 "{url} is green and ready to squash into `{base}` as `{subject}`. \
1353 The panel holds the diffstat, the patch and the commits being squashed."
1354 )
1355 };
1356 match contested {
1357 Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1358 None => body,
1359 }
1360 }
1361
1362 fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1365 const SHOWN: usize = 5;
1366 const TITLE_CHARS: usize = 100;
1367 let ja = self.html_lang == "ja";
1368 let mut out = if ja {
1369 format!(
1370 "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1371 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1372 )
1373 } else {
1374 format!(
1375 "{url} is held for approval although merge approvals are off: the \
1376 review ended with blocking findings still open and a reviewer \
1377 voting reject.\n"
1378 )
1379 };
1380 for f in c.findings.iter().take(SHOWN) {
1381 let at = match (&f.file, f.line) {
1382 (Some(file), Some(line)) => format!("{file}:{line}"),
1383 (Some(file), None) => file.clone(),
1384 _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1385 };
1386 let title: String = f.title.chars().take(TITLE_CHARS).collect();
1387 let _ = writeln!(out, "- {} {} {at}: {title}", f.id, f.severity);
1388 }
1389 if c.findings.len() > SHOWN {
1390 let more = c.findings.len() - SHOWN;
1391 let _ = writeln!(
1392 out,
1393 "{}",
1394 if ja {
1395 format!("- ほか {more} 件")
1396 } else {
1397 format!("- and {more} more")
1398 }
1399 );
1400 }
1401 let seats: Vec<String> = c
1402 .rejecters
1403 .iter()
1404 .map(|(seat, agent)| format!("#{seat} ({agent})"))
1405 .collect();
1406 let _ = write!(
1407 out,
1408 "{} {}",
1409 if ja {
1410 "却下したレビュアー:"
1411 } else {
1412 "Rejected by reviewer:"
1413 },
1414 seats.join(", ")
1415 );
1416 out
1417 }
1418
1419 fn truncated_note(
1421 &self,
1422 omitted: usize,
1423 total: usize,
1424 shown: usize,
1425 where_: &str,
1426 base: &str,
1427 head: &str,
1428 ) -> String {
1429 if self.html_lang == "ja" {
1430 format!(
1431 "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1432 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1433 プルリクエストにあります。"
1434 )
1435 } else {
1436 format!(
1437 "{omitted} of {total} diff lines omitted after the first {shown}. \
1438 The whole patch is in <code>{where_}</code> \
1439 (<code>git diff {base}...{head}</code>) and on the pull request."
1440 )
1441 }
1442 }
1443}
1444
1445fn words(language: &str) -> &'static Words {
1448 if crate::lang::is_japanese(language) {
1449 &JA
1450 } else {
1451 &EN
1452 }
1453}
1454
1455pub fn approval_panel(
1467 state: &RunState,
1468 pr: &PrState,
1469 diffstat: &str,
1470 diff: &str,
1471 commits: &[String],
1472 subject: &str,
1473) -> String {
1474 let rows = parse_numstat(diffstat);
1475 let w = words(&state.config.graph.language);
1476 let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1477
1478 let _ = writeln!(
1479 h,
1480 "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1481 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1482 w.html_lang
1483 );
1484 let _ = writeln!(
1485 h,
1486 "<title>merge #{} — {}</title>\n</head>",
1487 pr.number,
1488 esc(subject)
1489 );
1490 h.push_str(
1491 "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1492 'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1493 word-break:break-word\">\n",
1494 );
1495
1496 let _ = writeln!(
1498 h,
1499 "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1500 <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1501 <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1502 <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1503 <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#0969da\">{}</a></p>",
1504 pr.number,
1505 esc(&state.base_branch),
1506 esc(subject),
1507 esc(&state.id),
1508 esc(&pr.url),
1509 esc(&pr.url),
1510 );
1511
1512 let _ = writeln!(
1513 h,
1514 "<section style=\"padding:12px;background:#fff8c5;border:1px solid #9a6700;border-radius:6px;white-space:pre-wrap\">{}</section>",
1515 esc(&approval_evidence(state))
1516 );
1517
1518 let _ = writeln!(
1521 h,
1522 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1523 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1524 w.task,
1525 esc(&state.instruction)
1526 );
1527
1528 if let Some(summary) = state
1530 .winner()
1531 .map(|c| c.summary.as_str())
1532 .filter(|s| !s.is_empty())
1533 {
1534 let _ = writeln!(
1535 h,
1536 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1537 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1538 w.what_changed,
1539 esc(summary)
1540 );
1541 }
1542
1543 if let Some(round) = state.reviews.last() {
1546 let _ = writeln!(
1547 h,
1548 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1549 w.review_verdict
1550 );
1551 for r in &round.reviews {
1552 let body = match &r.failed {
1559 Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1560 None => esc(&r.summary),
1561 };
1562 let _ = writeln!(
1563 h,
1564 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1565 border-radius:6px\">\
1566 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1567 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1568 w.reviewer,
1569 r.reviewer,
1570 esc(&r.agent),
1571 body,
1572 );
1573 }
1574 }
1575
1576 let _ = writeln!(
1577 h,
1578 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1579 w.checks,
1580 esc(pr.checks.as_str())
1581 );
1582 if pr.failing.is_empty() {
1583 let _ = writeln!(
1584 h,
1585 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1586 w.nothing_failing
1587 );
1588 } else {
1589 h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1590 for f in &pr.failing {
1591 let _ = writeln!(h, "<li>{}</li>", esc(f));
1592 }
1593 h.push_str("</ul>\n");
1594 }
1595
1596 let _ = writeln!(
1599 h,
1600 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1601 rows.len(),
1602 w.files_changed
1603 );
1604 h.push_str(
1605 "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1606 <thead><tr>\
1607 <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1608 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1609 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1610 </th></tr></thead>\n<tbody>\n",
1611 );
1612 let mut total_added = 0u64;
1613 let mut total_removed = 0u64;
1614 for r in &rows {
1615 total_added += r.added.unwrap_or(0);
1616 total_removed += r.removed.unwrap_or(0);
1617 let cell = |n: Option<u64>| match n {
1618 Some(n) => n.to_string(),
1619 None => "bin".to_owned(),
1620 };
1621 let _ = writeln!(
1622 h,
1623 "<tr>\
1624 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1625 font-family:ui-monospace,monospace\">{}</td>\
1626 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1627 color:#0a3622\">{}</td>\
1628 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1629 color:#5c1a17\">{}</td></tr>",
1630 esc(&r.path),
1631 cell(r.added),
1632 cell(r.removed),
1633 );
1634 }
1635 let _ = writeln!(
1636 h,
1637 "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1638 <td style=\"padding:4px 2px\">total</td>\
1639 <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1640 <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1641 </tr></tfoot>\n</table>"
1642 );
1643
1644 let _ = writeln!(
1646 h,
1647 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1648 w.commits
1649 );
1650 if commits.is_empty() {
1651 h.push_str(&format!(
1652 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1653 w.no_commits
1654 ));
1655 } else {
1656 h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1657 for c in commits {
1658 let _ = writeln!(h, "<li>{}</li>", esc(c));
1659 }
1660 h.push_str("</ol>\n");
1661 }
1662 let _ = writeln!(
1663 h,
1664 "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1665 w.lands_as,
1666 esc(subject),
1667 w.lands_as_tail()
1668 );
1669
1670 let _ = writeln!(
1672 h,
1673 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1674 w.comments
1675 );
1676 if pr.review_comments.is_empty() {
1677 h.push_str(&format!(
1678 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1679 w.no_comments
1680 ));
1681 } else {
1682 for c in &pr.review_comments {
1683 let anchor = match (&c.path, c.line) {
1684 (Some(p), Some(l)) => format!("{p}:{l}"),
1685 (Some(p), None) => p.clone(),
1686 _ => "pull request thread".to_owned(),
1687 };
1688 let _ = writeln!(
1689 h,
1690 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1691 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1692 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1693 esc(&c.author),
1694 esc(&anchor),
1695 esc(&tail(&c.body, 800)),
1696 );
1697 }
1698 }
1699
1700 let total = diff.lines().count();
1702 let shown = total.min(DIFF_MAX_LINES);
1703 let _ = writeln!(
1704 h,
1705 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1706 w.diff
1707 );
1708 h.push_str(
1709 "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1710 border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1711 );
1712 for line in diff.lines().take(shown) {
1713 let (gutter, style, body) = diff_row(line);
1714 let _ = writeln!(
1715 h,
1716 "<div style=\"display:flex;{style}\">\
1717 <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1718 border-right:1px solid #d0d7de\">{gutter}</span>\
1719 <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1720 esc(body),
1721 );
1722 }
1723 h.push_str("</div>\n");
1724 if total > shown {
1725 let omitted = total - shown;
1726 let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1727 let where_ = state.winner().map_or_else(
1728 || state.repo.display().to_string(),
1729 |w| w.worktree.display().to_string(),
1730 );
1731 let _ = writeln!(
1732 h,
1733 "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1734 font-size:13px\">{}: {}</p>",
1735 w.truncated,
1736 w.truncated_note(
1737 omitted,
1738 total,
1739 shown,
1740 &esc(&where_),
1741 &esc(&state.base_branch),
1742 &esc(head),
1743 ),
1744 );
1745 }
1746
1747 h.push_str("</body>\n</html>\n");
1748 h
1749}
1750
1751fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1754 if state.owner_hold(crate::config::OwnerHold::HoldContestedMerge) {
1755 state.contested_handoff.clone()
1756 } else {
1757 None
1758 }
1759}
1760
1761pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1769 let mut s = format!(
1770 "This is the merge approval for run {run} (`magi show {run}`). The question's \
1771 own text above names the pull request. Answering `{APPROVE}` squash-merges \
1772 it into the base branch, which cannot be undone; `{HOLD}` leaves the pull \
1773 request open. Silence is a hold: the owner not answering never merges. Only \
1774 the owner choosing `{APPROVE}`, or clearly telling you to merge in their \
1775 own words, merges. Whether their wording is a clear, unconditional instruction \
1776 is your judgement alone: if it is doubtful, conditional, retracted or a \
1777 question, do not settle - ask back with `magi ask --thread`. Doubt and \
1778 silence are a hold.\n\n\
1779 This brief is a snapshot from when you were attached: check `magi show {run}` \
1780 and `gh pr view` (read-only) before telling the owner anything current. \
1781 You run with permission to write the question record, and what keeps you \
1782 from touching anything else is this brief and your instructions - so do \
1783 not change files, branches or the pull request.",
1784 run = q.run
1785 );
1786 if let Some(target) = &q.merge_target {
1787 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));
1788 }
1789 if q.choices.iter().any(|c| c == REPAIR) {
1790 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.");
1791 }
1792 if q.detail.contains("Approval required because:") || q.detail.contains("承認が必要な理由:")
1793 {
1794 s.push_str("\n\nSaved approval evidence (at question creation):\n");
1795 s.push_str(&q.detail);
1796 } else {
1797 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.");
1798 }
1799 let Some(state) = state else {
1800 s.push_str(
1801 "\n\nThe run's record could not be read, so the pull request, the panel \
1802 summary and any contested findings are not known to you beyond the \
1803 question's own text. Say so to the owner rather than guessing.",
1804 );
1805 return s;
1806 };
1807 if let Some(pr) = &state.pr {
1808 s.push_str(&format!(
1809 "\n\nPull request #{} {} (recorded state: {}, last seen).",
1810 pr.number, pr.url, pr.state
1811 ));
1812 }
1813 s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1814 if let Some(w) = state.winner() {
1815 s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1816 }
1817
1818 s
1819}
1820
1821fn merge_consent_superseded(state: &RunState, question: &ask::Question) -> Result<bool> {
1825 if !question.status.open() && approval(question.resolution().as_deref()) != Approval::Merge {
1826 return Ok(false);
1827 }
1828 let Some(task_id) = state
1829 .origin
1830 .as_ref()
1831 .and_then(|origin| origin.task.as_ref())
1832 else {
1833 return Ok(false);
1834 };
1835 let task = crate::queue::Queue::open()
1836 .get(task_id)
1837 .context("cannot verify operator decisions for the merge approval")?;
1838 Ok(task
1839 .operator_decided_at
1840 .is_some_and(|at| at > question.answered_at.unwrap_or(question.asked_at)))
1841}
1842
1843async fn approval_gate(
1863 state: &mut RunState,
1864 pr: &PrState,
1865 subject: &str,
1866 contested: Option<&ContestedHandoff>,
1867 head: &str,
1868) -> Result<ApprovalGate> {
1869 let store = ask::Questions::open();
1870 let reusable = state.land_approval.as_ref().and_then(|a| {
1875 store
1876 .list()
1877 .into_iter()
1878 .find(|q| q.id == a.question)
1879 .filter(|q| {
1880 q.status != ask::QuestionStatus::Abandoned
1881 && (a.head.eq_ignore_ascii_case(head)
1882 || q.resolution()
1883 .as_deref()
1884 .is_some_and(crate::merge_decision::stops))
1885 })
1886 });
1887 let reusable = match reusable {
1888 Some(q) if merge_consent_superseded(state, &q)? => None,
1889 other => other,
1890 };
1891 if reusable.is_none() {
1892 for stale in store
1893 .list()
1894 .into_iter()
1895 .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1896 {
1897 let why =
1898 "the pull request head or operator decision changed; asked again for approval";
1899 if let Err(e) = store.update(&stale.id, |q| {
1900 q.abandon(why);
1901 Ok(())
1902 }) {
1903 tracing::warn!("could not retire the superseded approval question: {e:#}");
1904 }
1905 }
1906 }
1907
1908 let q = match reusable {
1909 Some(mut q) => {
1910 if upgrade_repair_choice(&mut q, state) {
1911 q = store
1912 .update(&q.id, |current| {
1913 upgrade_repair_choice(current, state);
1914 Ok(())
1915 })?
1916 .0;
1917 }
1918 q
1919 }
1920 None => {
1921 let worktree = match state.winner() {
1922 Some(w) => w.worktree.clone(),
1923 None => state.repo.clone(),
1924 };
1925 let head = if head.is_empty() {
1928 state
1929 .winner()
1930 .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1931 } else {
1932 head.to_owned()
1933 };
1934 let remote = &state.config.merge.remote;
1937 let tracking = format!("{remote}/{}", state.base_branch);
1938 let base = if git::rev_exists(&worktree, &tracking).await {
1939 tracking
1940 } else {
1941 String::new()
1942 };
1943 let range = format!("{base}...{head}");
1944 let numstat = if base.is_empty() {
1948 String::new()
1949 } else {
1950 git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1951 .await
1952 .map(|o| o.stdout)
1953 .unwrap_or_default()
1954 };
1955 let diff = if base.is_empty() {
1956 String::new()
1957 } else {
1958 git::diff(&worktree, &base, &head).await.unwrap_or_default()
1959 };
1960 let commits: Vec<String> = if base.is_empty() {
1961 Vec::new()
1962 } else {
1963 git::git_raw(
1964 &worktree,
1965 &[
1966 "log",
1967 "--reverse",
1968 "--format=%s",
1969 &format!("{base}..{head}"),
1970 ],
1971 )
1972 .await
1973 .map(|o| o.stdout)
1974 .unwrap_or_default()
1975 .lines()
1976 .filter(|l| !l.trim().is_empty())
1977 .map(str::to_owned)
1978 .collect()
1979 };
1980
1981 let w = words(&state.config.graph.language);
1982 let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1983 let mut fresh = ask::Question::new(
1984 state.id.clone(),
1985 APPROVAL_NODE.to_owned(),
1986 "land".to_owned(),
1987 w.approval_summary(pr.number, subject),
1988 format!(
1989 "{}\n\n{}",
1990 approval_evidence(state),
1991 w.approval_detail(&pr.url, &state.base_branch, subject, contested)
1992 ),
1993 approval_choices(state),
1994 );
1995 fresh.merge_target = Some(crate::merge_decision::Target {
1996 pr: pr.url.clone(),
1997 head: head.clone(),
1998 language: state.config.graph.language.clone(),
1999 });
2000 if fresh.choices.iter().any(|c| c == REPAIR) {
2001 fresh.actions.insert(
2002 REPAIR.to_owned(),
2003 ask::ChoiceAction::Resume {
2004 run: state.id.clone(),
2005 },
2006 );
2007 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.");
2008 }
2009 for choice in fresh.choices.clone() {
2010 if crate::merge_decision::stops(&choice)
2011 && choice != HOLD
2012 && choice != crate::merge_decision::DISMISS
2013 {
2014 fresh.actions.insert(
2015 choice,
2016 ask::ChoiceAction::Resume {
2017 run: state.id.clone(),
2018 },
2019 );
2020 }
2021 }
2022 store
2023 .put_panel(&mut fresh, &html, &[])
2024 .context("write the merge approval panel")?;
2025 store
2026 .put_executable(&mut fresh)
2027 .context("file the merge approval question")?;
2028 state.land_approval = Some(LandApproval {
2029 question: fresh.id.clone(),
2030 head: head.clone(),
2031 });
2032 state.event(
2033 "land",
2034 format!("asking for merge approval ({})", fresh.short()),
2035 );
2036 state.save()?;
2037 if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
2038 tracing::warn!(
2042 "could not notify about merge approval question {}: {e:#} - \
2043 the web UI is the only surface for it now",
2044 fresh.short()
2045 );
2046 }
2047 fresh
2048 }
2049 };
2050
2051 Ok(match q.status {
2052 ask::QuestionStatus::Open => ApprovalGate::Pending,
2053 ask::QuestionStatus::Abandoned => ApprovalGate::Held,
2057 ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
2061 Approval::Merge => ApprovalGate::Approved,
2062 Approval::Repair if !state.reviews.is_empty() => {
2063 if !state.merge_repairs.iter().any(|r| r.question == q.id) {
2064 state.merge_repairs.push(crate::run::MergeRepair {
2065 question: q.id.clone(),
2066 head: head.to_owned(),
2067 pr: pr.url.clone(),
2068 reviews: state.reviews.clone(),
2069 gate: state.gate.clone(),
2070 gate_head: state.gate_head.clone(),
2071 gate_fixes: state.gate_fixes.clone(),
2072 pre_gate: state.pre_gate.clone(),
2073 phase: crate::run::MergeRepairPhase::Requested,
2074 failure: None,
2075 });
2076 state.land_approval = None;
2077 state.event(
2078 "repair",
2079 "owner requested fix findings and re-review; merge is not approved",
2080 );
2081 state.save()?;
2082 }
2083 ApprovalGate::Repair
2084 }
2085 Approval::Repair | Approval::Hold => ApprovalGate::Held,
2086 },
2087 })
2088}
2089
2090fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
2093 let mut failing = Vec::new();
2094 let mut pending = false;
2095 let mut unknown = false;
2096 for check in rollup {
2097 match check.verdict() {
2098 Verdict::Pass => {}
2099 Verdict::Pending => pending = true,
2100 Verdict::Fail => failing.push(check.label()),
2101 Verdict::Unknown => unknown = true,
2102 }
2103 }
2104 let checks = if rollup.is_empty() {
2105 Checks::Unknown
2106 } else if pending {
2107 Checks::Pending
2108 } else if !failing.is_empty() {
2109 Checks::Red
2110 } else if unknown {
2111 Checks::Unknown
2112 } else {
2113 Checks::Green
2114 };
2115 (checks, failing)
2116}
2117
2118pub fn parse_pr(json: &str) -> Result<PrState> {
2121 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2122 let state = match raw.state.to_ascii_uppercase().as_str() {
2123 "OPEN" => PrLifecycle::Open,
2124 "MERGED" => PrLifecycle::Merged,
2125 "CLOSED" => PrLifecycle::Closed,
2126 other => bail!("unknown pull request state `{other}`"),
2127 };
2128
2129 let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
2130
2131 let mut review_comments = Vec::new();
2132 for r in raw.reviews {
2133 push_if_outstanding(
2134 &mut review_comments,
2135 ReviewComment {
2136 author: r.author.login,
2137 path: None,
2138 line: None,
2139 body: r.body,
2140 },
2141 );
2142 }
2143 for c in raw.comments {
2144 push_if_outstanding(
2145 &mut review_comments,
2146 ReviewComment {
2147 author: c.author.login,
2148 path: None,
2149 line: None,
2150 body: c.body,
2151 },
2152 );
2153 }
2154
2155 Ok(PrState {
2156 url: raw.url,
2157 number: raw.number,
2158 state,
2159 checks,
2160 failing,
2161 review_comments,
2162 blocking: Blocking::of(&raw.merge_state_status),
2163 })
2164}
2165
2166#[derive(Debug, Clone, PartialEq, Eq)]
2168pub(crate) struct CheckView {
2169 pub name: String,
2170 pub verdict: Verdict,
2171 pub run: Option<String>,
2173 pub url: Option<String>,
2174}
2175
2176#[derive(Debug, Clone, PartialEq, Eq)]
2180pub(crate) struct RollupView {
2181 pub url: String,
2182 pub number: u64,
2183 pub state: PrLifecycle,
2184 pub head: String,
2185 pub checks: Vec<CheckView>,
2186}
2187
2188pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
2191 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2192 let state = match raw.state.to_ascii_uppercase().as_str() {
2193 "OPEN" => PrLifecycle::Open,
2194 "MERGED" => PrLifecycle::Merged,
2195 "CLOSED" => PrLifecycle::Closed,
2196 other => bail!("unknown pull request state `{other}`"),
2197 };
2198 let checks = raw
2199 .status_check_rollup
2200 .iter()
2201 .map(|c| CheckView {
2202 name: c.label(),
2203 verdict: c.verdict(),
2204 run: c.url().and_then(run_of),
2205 url: c.url().map(str::to_owned),
2206 })
2207 .collect();
2208 Ok(RollupView {
2209 url: raw.url,
2210 number: raw.number,
2211 state,
2212 head: raw.head_ref_oid,
2213 checks,
2214 })
2215}
2216
2217pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
2227 let view = gh(
2228 repo,
2229 &[
2230 "pr".to_owned(),
2231 "view".to_owned(),
2232 pr_url.to_owned(),
2233 "--json".to_owned(),
2234 "state".to_owned(),
2235 ],
2236 )
2237 .await?;
2238 if !view.0 {
2239 bail!("gh pr view {pr_url}: {}", view.1);
2240 }
2241 Ok(parse_pr(&view.1)?.state)
2245}
2246
2247#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2251pub struct ExternalMerge {
2252 pub url: String,
2254 pub number: u64,
2256}
2257
2258#[derive(Debug, Deserialize)]
2259#[serde(rename_all = "camelCase")]
2260struct GhMergedPr {
2261 url: String,
2262 number: u64,
2263 merged_at: String,
2264 base_ref_name: String,
2265}
2266
2267fn pick_merged_pr(
2281 json: &str,
2282 base_branch: &str,
2283 created_at: Timestamp,
2284) -> Result<Option<ExternalMerge>> {
2285 let raw: Vec<GhMergedPr> =
2286 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2287 let mut matches: Vec<ExternalMerge> = Vec::new();
2288 for pr in raw {
2289 if pr.base_ref_name != base_branch {
2290 continue;
2291 }
2292 let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
2293 continue;
2294 };
2295 if merged_at < created_at {
2296 continue;
2297 }
2298 matches.push(ExternalMerge {
2299 url: pr.url,
2300 number: pr.number,
2301 });
2302 }
2303 if matches.len() == 1 {
2304 Ok(matches.pop())
2305 } else {
2306 Ok(None)
2307 }
2308}
2309
2310#[derive(Debug, Clone, PartialEq, Eq)]
2312pub enum OpenPr {
2313 None,
2315 One {
2317 url: String,
2319 title: String,
2321 },
2322 Many(Vec<String>),
2324}
2325
2326#[derive(Debug, Deserialize)]
2327#[serde(rename_all = "camelCase")]
2328struct GhOpenPr {
2329 url: String,
2333 #[serde(default)]
2334 title: String,
2335 base_ref_name: String,
2336 #[serde(default)]
2339 is_cross_repository: bool,
2340}
2341
2342pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
2348 let raw: Vec<GhOpenPr> =
2349 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2350 let mut hits: Vec<GhOpenPr> = raw
2351 .into_iter()
2352 .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
2353 .collect();
2354 Ok(match hits.len() {
2355 0 => OpenPr::None,
2356 1 => {
2357 let p = hits.remove(0);
2358 OpenPr::One {
2359 url: p.url,
2360 title: p.title,
2361 }
2362 }
2363 _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
2364 })
2365}
2366
2367pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
2371 let (ok, out) = gh(
2372 repo,
2373 &[
2374 "pr".to_owned(),
2375 "list".to_owned(),
2376 "--head".to_owned(),
2377 branch.to_owned(),
2378 "--base".to_owned(),
2379 base.to_owned(),
2380 "--state".to_owned(),
2381 "open".to_owned(),
2382 "--json".to_owned(),
2383 "number,url,title,baseRefName,isCrossRepository".to_owned(),
2384 ],
2385 )
2386 .await?;
2387 if !ok {
2388 bail!("gh pr list failed: {out}");
2389 }
2390 pick_open_pr(&out, base)
2391}
2392
2393#[derive(Debug, Deserialize)]
2394#[serde(rename_all = "camelCase")]
2395struct GhPrHead {
2396 head_ref_name: String,
2397 base_ref_name: String,
2398 state: String,
2399 is_cross_repository: bool,
2402 head_ref_oid: String,
2405}
2406
2407#[derive(Debug, Clone, PartialEq, Eq)]
2409pub struct Refusal {
2410 pub retry: bool,
2413 pub why: String,
2415}
2416
2417impl Refusal {
2418 fn final_(why: String) -> Self {
2419 Self { retry: false, why }
2420 }
2421}
2422
2423pub fn closable(
2432 json: &str,
2433 branch: &str,
2434 base: &str,
2435 verified: &[String],
2436) -> std::result::Result<(), Refusal> {
2437 let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
2438 retry: true,
2439 why: format!("could not read the pull request ({e})"),
2440 })?;
2441 if pr.head_ref_name != branch {
2442 return Err(Refusal::final_(format!(
2443 "its head is `{}`, not this run's `{branch}`",
2444 pr.head_ref_name
2445 )));
2446 }
2447 if pr.is_cross_repository {
2448 return Err(Refusal::final_("its head lives in a fork".to_owned()));
2449 }
2450 if pr.base_ref_name != base {
2451 return Err(Refusal::final_(format!(
2452 "it targets `{}`, not `{base}`",
2453 pr.base_ref_name
2454 )));
2455 }
2456 if !pr.state.eq_ignore_ascii_case("open") {
2457 return Err(Refusal::final_(format!(
2458 "it is already {}",
2459 pr.state.to_ascii_lowercase()
2460 )));
2461 }
2462 if !verified.contains(&pr.head_ref_oid) {
2466 return Err(Refusal {
2467 retry: true,
2468 why: format!(
2469 "its head {} is not a commit this run checked against the base",
2470 crate::already::short_sha(&pr.head_ref_oid)
2471 ),
2472 });
2473 }
2474 Ok(())
2475}
2476
2477async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2482 let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2483 return false;
2484 };
2485 is_forge_url(url.trim())
2486}
2487
2488fn is_forge_url(url: &str) -> bool {
2489 url.contains("://") && !url.starts_with("file://")
2490 || url
2491 .split_once(':')
2492 .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2493}
2494
2495fn forge_unavailable(message: &str) -> bool {
2498 message.contains("known GitHub host") || message.contains("spawn gh")
2499}
2500
2501pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2504 let how = match evidence.proof {
2505 crate::already::Proof::PatchId => format!(
2506 "carried by commit {} on `{base}` with the same patch",
2507 evidence.names()
2508 ),
2509 crate::already::Proof::Ancestry => {
2510 format!("already in the history of `{base}` as {}", evidence.names())
2511 }
2512 crate::already::Proof::Tree => format!(
2513 "already part of `{base}` (merging this branch changes nothing at {})",
2514 crate::already::short_sha(&evidence.tip)
2515 ),
2516 };
2517 format!(
2518 "Closing: everything this branch adds is {how}, so there is nothing left to \
2519 land. This pull request was closed automatically after that was verified; \
2520 reopen it if you disagree."
2521 )
2522}
2523
2524pub async fn close_superseded_pr(
2536 state: &mut RunState,
2537 branch: &str,
2538 evidence: &crate::already::Evidence,
2539 verified: &[String],
2540) -> Result<std::result::Result<String, String>> {
2541 let repo = state.repo.clone();
2542 let base = state.base_branch.clone();
2543 let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2544 Some(p) => p.url.clone(),
2545 None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2550 return Ok(Err(
2551 "the remote is not a forge, so there is no pull request".to_owned(),
2552 ));
2553 }
2554 None => match find_open_pr(&repo, branch, &base).await {
2555 Err(e) if forge_unavailable(&format!("{e:#}")) => {
2556 return Ok(Err(format!("no forge to ask: {e:#}")));
2557 }
2558 Err(e) => return Err(e),
2559 Ok(OpenPr::One { url, .. }) => url,
2560 Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2561 Ok(OpenPr::Many(urls)) => {
2562 return Ok(Err(format!(
2563 "{} open pull requests name it; not choosing between them",
2564 urls.len()
2565 )));
2566 }
2567 },
2568 };
2569 let (ok, view) = gh(
2570 &repo,
2571 &[
2572 "pr".to_owned(),
2573 "view".to_owned(),
2574 url.clone(),
2575 "--json".to_owned(),
2576 "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2577 ],
2578 )
2579 .await?;
2580 if !ok {
2581 bail!("gh pr view {url} failed: {view}");
2582 }
2583 if let Err(refusal) = closable(&view, branch, &base, verified) {
2584 if refusal.retry {
2587 bail!("left {url} open: {}", refusal.why);
2588 }
2589 return Ok(Err(format!("left {url} open: {}", refusal.why)));
2590 }
2591 let (ok, out) = gh(
2592 &repo,
2593 &[
2594 "pr".to_owned(),
2595 "close".to_owned(),
2596 url.clone(),
2597 "--comment".to_owned(),
2598 superseded_comment(&base, evidence),
2599 ],
2600 )
2601 .await?;
2602 if !ok {
2603 bail!("gh pr close {url} failed: {out}");
2604 }
2605 if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2606 p.state = "closed".to_owned();
2607 }
2608 Ok(Ok(url))
2609}
2610
2611pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2615 let (title, _) = crate::github_text::prepare(state, title, "");
2616 edit_pr_title(repo, url, &title).await
2617}
2618
2619pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2623 edit_pr_title(repo, url, title).await
2624}
2625
2626pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2629 let (ok, _) = gh(
2630 repo,
2631 &[
2632 "pr".to_owned(),
2633 "edit".to_owned(),
2634 url.to_owned(),
2635 "--title".to_owned(),
2636 title.to_owned(),
2637 "--body".to_owned(),
2638 body.to_owned(),
2639 ],
2640 )
2641 .await
2642 .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2643 if !ok {
2644 bail!("gh pr edit failed while posting the chosen rewrite");
2646 }
2647 Ok(())
2648}
2649
2650async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2651 let (ok, out) = gh(
2652 repo,
2653 &[
2654 "pr".to_owned(),
2655 "edit".to_owned(),
2656 url.to_owned(),
2657 "--title".to_owned(),
2658 title.to_owned(),
2659 ],
2660 )
2661 .await?;
2662 if !ok {
2663 bail!("gh pr edit failed: {out}");
2664 }
2665 Ok(())
2666}
2667
2668pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2681 let Some(winner) = state.winner() else {
2682 return Ok(None);
2683 };
2684 let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2685 let out = gh(
2686 &state.repo,
2687 &[
2688 "pr".to_owned(),
2689 "list".to_owned(),
2690 "--head".to_owned(),
2691 branch.clone(),
2692 "--state".to_owned(),
2693 "merged".to_owned(),
2694 "--json".to_owned(),
2695 "url,number,mergedAt,baseRefName".to_owned(),
2696 ],
2697 )
2698 .await?;
2699 if !out.0 {
2700 bail!("gh pr list --head {branch}: {}", out.1);
2701 }
2702 pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2703}
2704
2705pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2717 let out = tokio::process::Command::new("git")
2718 .args(["merge-base", "--is-ancestor", branch, base_branch])
2719 .current_dir(repo)
2720 .quiet()
2721 .stdin(std::process::Stdio::null())
2722 .output()
2723 .await
2724 .context("spawn git merge-base --is-ancestor")?;
2725 Ok(out.status.success())
2726}
2727
2728fn forge_slug(url: &str) -> Option<(String, &str)> {
2736 let rest = url.rsplit("://").next()?;
2737 let (host, path) = rest.split_once('/')?;
2738 if host.is_empty() {
2739 return None;
2740 }
2741 Some((host.to_ascii_lowercase(), path))
2742}
2743
2744pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2752 let (host, path) = forge_slug(url)?;
2753 let mut segments = path.split('/');
2754 let owner = segments.next()?;
2755 let repo = segments.next()?;
2756 let kind = segments.next()?;
2757 if owner.is_empty() || repo.is_empty() || kind != "pull" {
2758 return None;
2759 }
2760 Some(format!("{host}/{owner}/{repo}"))
2761}
2762
2763fn slug_of_repo_url(url: &str) -> Option<String> {
2767 let (host, path) = forge_slug(url)?;
2768 let mut segments = path.split('/');
2769 let owner = segments.next()?;
2770 let repo = segments.next()?;
2771 if owner.is_empty() || repo.is_empty() {
2772 return None;
2773 }
2774 Some(format!("{host}/{owner}/{repo}"))
2775}
2776
2777pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2798 if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2799 return Ok(());
2800 }
2801 bail!(
2802 "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2803 request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2804 actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2805 verify by hand before treating this as a false positive)"
2806 );
2807}
2808
2809async fn repo_slug(repo: &Path) -> Result<String> {
2820 let out = gh(
2821 repo,
2822 &[
2823 "repo".to_owned(),
2824 "view".to_owned(),
2825 "--json".to_owned(),
2826 "url".to_owned(),
2827 ],
2828 )
2829 .await?;
2830 if !out.0 {
2831 bail!("gh repo view --json url: {}", out.1);
2832 }
2833 #[derive(Debug, Deserialize)]
2834 struct GhRepo {
2835 url: String,
2836 }
2837 let parsed: GhRepo = serde_json::from_str(&out.1)
2838 .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2839 slug_of_repo_url(&parsed.url)
2840 .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2841}
2842
2843pub async fn correct_manual_merge(
2891 state: &mut RunState,
2892 url: &str,
2893) -> Result<(RunStatus, RunStatus)> {
2894 let Some(pr_slug) = slug_of_pr_url(url) else {
2895 bail!(
2896 "could not parse an owner/repo out of {url}; refusing to guess which repository \
2897 this pull request belongs to"
2898 );
2899 };
2900 let run_slug = repo_slug(&state.repo).await?;
2901 ensure_same_repo(&run_slug, &pr_slug)?;
2902 correct_merge(state, url).await
2903}
2904
2905pub(crate) async fn correct_confirmed_external_merge(
2916 state: &mut RunState,
2917 url: &str,
2918) -> Result<(RunStatus, RunStatus)> {
2919 correct_merge(state, url).await
2920}
2921
2922fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2924 let Some(pr) = a.pr.as_ref() else {
2925 return false;
2926 };
2927 if !url.is_empty()
2928 && pr
2929 .url
2930 .trim_end_matches('/')
2931 .eq_ignore_ascii_case(url.trim_end_matches('/'))
2932 {
2933 return true;
2934 }
2935 number > 0
2936 && pr.number == number
2937 && match (a.repo.canonicalize(), repo.canonicalize()) {
2938 (Ok(x), Ok(y)) => x == y,
2939 _ => a.repo == repo,
2940 }
2941}
2942
2943fn rewrite_open_prs(
2957 home: &Path,
2958 decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2959) -> usize {
2960 let now = Timestamp::now();
2961 let mut changed = 0;
2962 for id in crate::run::list_ids_in(&home.join("runs")) {
2963 let Ok(_owner) = crate::pr_head::owner_lock(home, &id) else {
2964 continue;
2965 };
2966 let path = home.join("runs").join(&id).join("run.json");
2967 let Ok(body) = std::fs::read_to_string(&path) else {
2968 continue;
2969 };
2970 let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2971 continue;
2972 };
2973 if !state.status.done()
2974 || state.pr.as_ref().is_none_or(|p| p.state != "open")
2975 || state.liveness_under(home, crate::daemon::is_working_on(home, &id, now))
2976 != crate::run::Liveness::Dead
2977 {
2978 continue;
2979 }
2980 let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2981 continue;
2982 };
2983 if let Some(pr) = state.pr.as_mut() {
2984 pr.state = to.as_str().to_owned();
2985 }
2986 let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2987 state.event(
2988 "land",
2989 format!("recorded {url} as {}: another run settled it", to.as_str()),
2990 );
2991 match state.save_under(home) {
2992 Ok(()) => changed += 1,
2993 Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2994 }
2995 }
2996 changed
2997}
2998
2999pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
3004 if to == PrLifecycle::Open {
3005 return;
3006 }
3007 let Some(home) = crate::run::try_home() else {
3008 return;
3009 };
3010 write_pr_state_through_in(&home, run, to);
3011}
3012
3013pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
3014 let Some(pr) = run.pr.as_ref() else {
3015 return 0;
3016 };
3017 let (url, number) = (pr.url.clone(), pr.number);
3018 rewrite_open_prs(home, &mut |other| {
3019 (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
3020 })
3021}
3022
3023pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
3026 let now = Timestamp::now();
3027 let mut out = Vec::new();
3028 for id in crate::run::list_ids_in(&home.join("runs")) {
3029 let path = home.join("runs").join(&id).join("run.json");
3030 let Ok(body) = std::fs::read_to_string(&path) else {
3031 continue;
3032 };
3033 let Ok(state) = serde_json::from_str::<RunState>(&body) else {
3034 continue;
3035 };
3036 if let Some(pr) = state.pr.as_ref()
3037 && state.status.done()
3038 && pr.state == "open"
3039 && !pr.url.is_empty()
3040 && !crate::daemon::is_working_on(home, &id, now)
3041 {
3042 out.push((id, state.repo.clone(), pr.url.clone()));
3043 }
3044 }
3045 out
3046}
3047
3048pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
3051 rewrite_open_prs(home, &mut |s| {
3052 s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
3053 })
3054}
3055
3056pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
3063 let mut known = BTreeMap::new();
3064 let mut failed = 0;
3065 let mut seen = BTreeSet::new();
3066 for (_, repo, url) in stale_open_prs(home) {
3067 if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
3068 continue;
3069 }
3070 match lifecycle(&repo, &url).await {
3071 Ok(state) => {
3072 known.insert(url, state);
3073 }
3074 Err(e) => {
3075 tracing::warn!("repair pr state of {url}: {e:#}");
3076 failed += 1;
3077 }
3078 }
3079 }
3080 (apply_pr_states(home, &known), failed)
3081}
3082
3083async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
3084 match lifecycle(&state.repo, url).await? {
3085 PrLifecycle::Merged => {}
3086 other => bail!(
3087 "{url} is {}, not merged; refusing to record {} as merged on a guess",
3088 other.as_str(),
3089 state.id
3090 ),
3091 }
3092 let before = state.status;
3093 if let Err(e) = land(state, url).await {
3094 state.status = RunStatus::Blocked;
3101 state.event("fold", format!("manual-merge correction failed: {e:#}"));
3102 state.save()?;
3103 return Err(e).context(format!("confirming the merge of {url}"));
3104 }
3105 state.event(
3106 "fold",
3107 "operator recorded this pull request as a manual merge; this run never \
3108 re-entered `land`, so `bump::after_merge` did not run for it - a release \
3109 bump this change might warrant has to be filed by hand",
3110 );
3111 if state.status == RunStatus::Merged {
3114 crate::followup::after_merge(state, url).await;
3115 }
3116 state.save()?;
3117 Ok((before, state.status))
3118}
3119
3120pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
3127 let raw: Vec<GhInline> =
3128 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
3129 let mut out = Vec::new();
3130 for c in raw {
3131 push_if_outstanding(
3132 &mut out,
3133 ReviewComment {
3134 author: c.user.login,
3135 path: c.path,
3136 line: c.line,
3137 body: c.body,
3138 },
3139 );
3140 }
3141 Ok(out)
3142}
3143
3144fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
3150 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
3151 return;
3152 }
3153 if comment.path.is_none() && is_noise(&comment.body) {
3154 return;
3155 }
3156 out.push(comment);
3157}
3158
3159pub fn is_noise(body: &str) -> bool {
3177 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
3178 return true;
3179 }
3180 let mut content = false;
3181 for line in strip_blocks(body).lines() {
3182 let line = unquote(line);
3183 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
3184 continue;
3185 }
3186 content = true;
3187 break;
3188 }
3189 !content
3190}
3191
3192fn strip_blocks(body: &str) -> String {
3194 let mut out = String::with_capacity(body.len());
3195 let mut rest = body;
3196 loop {
3197 let open = ["<!--", "<details>"]
3198 .iter()
3199 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
3200 .min_by_key(|(i, _)| *i);
3201 let Some((at, tag)) = open else {
3202 out.push_str(rest);
3203 return out;
3204 };
3205 out.push_str(&rest[..at]);
3206 let after = &rest[at + tag.len()..];
3207 let close = if tag == "<!--" { "-->" } else { "</details>" };
3208 match after.find(close) {
3209 Some(end) => rest = &after[end + close.len()..],
3210 None => return out,
3212 }
3213 }
3214}
3215
3216fn unquote(line: &str) -> &str {
3218 let mut s = line.trim();
3219 while let Some(rest) = s.strip_prefix('>') {
3220 s = rest.trim_start();
3221 }
3222 s.trim()
3223}
3224
3225fn is_checklist(line: &str) -> bool {
3227 let rest = line
3228 .strip_prefix("- ")
3229 .or_else(|| line.strip_prefix("* "))
3230 .unwrap_or("");
3231 let rest = rest.trim_start();
3232 matches!(
3233 rest.get(..3),
3234 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
3235 )
3236}
3237
3238fn is_decoration(line: &str) -> bool {
3240 line.starts_with('#')
3241 || line.starts_with("[!")
3242 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
3243}
3244
3245fn is_banner(line: &str) -> bool {
3252 let plain = drop_spans(line, "**", "**");
3253 let plain = if plain.contains("](") {
3254 drop_spans(&plain, "[", ")")
3255 } else {
3256 plain
3257 };
3258 !plain.chars().any(char::is_alphanumeric)
3259}
3260
3261fn drop_spans(s: &str, open: &str, close: &str) -> String {
3265 let mut out = String::with_capacity(s.len());
3266 let mut rest = s;
3267 while let Some(at) = rest.find(open) {
3268 out.push_str(&rest[..at]);
3269 let after = &rest[at + open.len()..];
3270 match after.find(close) {
3271 Some(end) => rest = &after[end + close.len()..],
3272 None => return out,
3273 }
3274 }
3275 out.push_str(rest);
3276 out
3277}
3278
3279fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
3297 static LOCKS: std::sync::LazyLock<
3298 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
3299 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
3300 LOCKS
3301 .lock()
3302 .unwrap_or_else(std::sync::PoisonError::into_inner)
3303 .entry(repo.to_path_buf())
3304 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
3305 .clone()
3306}
3307
3308fn repo_label(repo: &Path, pr_url: &str) -> String {
3311 let parts: Vec<&str> = pr_url.split('/').collect();
3312 if let Some(at) = parts.iter().rposition(|p| *p == "pull")
3313 && at >= 2
3314 && !parts[at - 1].is_empty()
3315 && !parts[at - 2].is_empty()
3316 {
3317 return format!("{}/{}", parts[at - 2], parts[at - 1]);
3318 }
3319 repo.file_name()
3320 .map(|n| n.to_string_lossy().into_owned())
3321 .unwrap_or_default()
3322}
3323
3324fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
3328 (pr.checks == Checks::Red).then(|| {
3329 let details = crate::notices::format_list(&pr.failing)
3330 .map(|names| format!(": {names}"))
3331 .unwrap_or_default();
3332 format!(
3333 "Merged {repo_name} PR #{} with red checks{details} ({})",
3334 pr.number, pr.url
3335 )
3336 })
3337}
3338
3339async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
3343 let repo_name = repo_label(&state.repo, &pr.url);
3344 let Some(summary) = red_merge_summary(&repo_name, pr) else {
3345 return;
3346 };
3347 if let Some(rec) = state.pr.as_mut() {
3348 rec.red_at_merge = pr.failing.clone();
3349 }
3350 state.event("land", summary.clone());
3351 crate::notices::raise_with(
3354 crate::notices::merged_red(&state.id, &summary),
3355 &state.config.notify,
3356 );
3357}
3358
3359pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
3366 land_with(state, pr_url, &GhForge).await
3367}
3368
3369trait Forge {
3373 async fn close(&self, repo: &Path, pr_url: &str, comment: &str) -> Result<()> {
3374 let (ok, out) = gh(
3375 repo,
3376 &[
3377 "pr".into(),
3378 "close".into(),
3379 pr_url.into(),
3380 "--comment".into(),
3381 comment.into(),
3382 ],
3383 )
3384 .await?;
3385 if !ok {
3386 bail!("PR closure failed: {out}");
3387 }
3388 Ok(())
3389 }
3390 async fn attribution(&self, _state: &RunState, _seen: &Seen) -> Option<CiEvidence> {
3391 None
3392 }
3393 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
3394 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
3395 async fn poll(&self);
3396 async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
3398 fn now(&self) -> i64;
3400 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3403 #[allow(clippy::too_many_arguments)]
3404 async fn fix(
3405 &self,
3406 state: &mut RunState,
3407 pr: &PrState,
3408 round: usize,
3409 budget: usize,
3410 reason: &str,
3411 logs: &str,
3412 ) -> Result<Fixed>;
3413}
3414
3415struct GhForge;
3416
3417impl Forge for GhForge {
3418 async fn attribution(&self, state: &RunState, seen: &Seen) -> Option<CiEvidence> {
3419 collect_ci_evidence(state, seen).await
3420 }
3421 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3422 observe(repo, pr_url).await
3423 }
3424 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3425 gh(repo, argv).await
3426 }
3427 async fn poll(&self) {
3428 tokio::time::sleep(POLL).await;
3429 }
3430 async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3431 let args = [
3432 "run".to_owned(),
3433 "rerun".to_owned(),
3434 run.to_owned(),
3435 "--failed".to_owned(),
3436 ];
3437 let (ok, out) = gh(repo, &args).await?;
3438 if !ok {
3439 bail!("gh run rerun {run}: {out}");
3440 }
3441 Ok(())
3442 }
3443 fn now(&self) -> i64 {
3444 jiff::Timestamp::now().as_second()
3445 }
3446 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3447 required_contexts_of(repo, base).await
3448 }
3449 async fn fix(
3450 &self,
3451 state: &mut RunState,
3452 pr: &PrState,
3453 round: usize,
3454 budget: usize,
3455 reason: &str,
3456 logs: &str,
3457 ) -> Result<Fixed> {
3458 fix_round(state, pr, round, budget, reason, logs).await
3459 }
3460}
3461
3462fn encode_path_segment(s: &str) -> String {
3464 let mut out = String::new();
3465 for b in s.bytes() {
3466 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3467 out.push(b as char);
3468 } else {
3469 let _ = write!(out, "%{b:02X}");
3470 }
3471 }
3472 out
3473}
3474
3475async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3480 let enc = encode_path_segment(base);
3481 let mut all = BTreeSet::new();
3482 let classic = gh(
3485 repo,
3486 &[
3487 "api".to_owned(),
3488 format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3489 ],
3490 )
3491 .await
3492 .ok()?;
3493 if classic.0 {
3494 all.extend(parse_classic_required(&classic.1)?);
3495 } else if !classic.1.contains("404") {
3496 return None;
3497 }
3498 let rules = gh(
3499 repo,
3500 &[
3501 "api".to_owned(),
3502 format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3503 ],
3504 )
3505 .await
3506 .ok()?;
3507 if !rules.0 {
3508 return None;
3509 }
3510 all.extend(parse_ruleset_required(&rules.1)?);
3511 Some(all)
3512}
3513
3514fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3517 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3518 let mut out = BTreeSet::new();
3519 for c in v.get("contexts")?.as_array()? {
3520 out.insert(c.as_str()?.to_owned());
3521 }
3522 for c in v
3523 .get("checks")
3524 .and_then(|c| c.as_array())
3525 .into_iter()
3526 .flatten()
3527 {
3528 if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3529 out.insert(name.to_owned());
3530 }
3531 }
3532 Some(out)
3533}
3534
3535fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3537 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3538 let mut out = BTreeSet::new();
3539 for rule in v.as_array()? {
3540 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3541 continue;
3542 }
3543 let checks = rule
3544 .pointer("/parameters/required_status_checks")?
3545 .as_array()?;
3546 for c in checks {
3547 out.insert(c.get("context")?.as_str()?.to_owned());
3548 }
3549 }
3550 Some(out)
3551}
3552
3553fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3561 awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3562}
3563
3564fn bound_head<'a>(
3574 seen_head: &'a str,
3575 rollup_head: &str,
3576 awaiting: Option<&str>,
3577) -> Option<&'a str> {
3578 if seen_head.is_empty()
3579 || awaiting_new_head(awaiting, seen_head)
3580 || !rollup_head.eq_ignore_ascii_case(seen_head)
3581 {
3582 return None;
3583 }
3584 Some(seen_head)
3585}
3586
3587#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3589enum Refused {
3590 Pending,
3592 Recheck,
3596 Final,
3599}
3600
3601fn classify_refusal(
3604 after: Option<&Seen>,
3605 rechecked: bool,
3606 observed_head: &str,
3607 ci: CiExpectation,
3608) -> Refused {
3609 let Some(after) = after else {
3610 return Refused::Pending;
3612 };
3613 if after.pr.state != PrLifecycle::Open {
3614 return Refused::Final;
3615 }
3616 if !after.head.eq_ignore_ascii_case(observed_head) {
3619 return Refused::Pending;
3620 }
3621 if bound_head(&after.head, &after.rollup_head, None).is_none() {
3623 return Refused::Pending;
3624 }
3625 let state = after.merge_state.to_ascii_uppercase();
3626 if (ci == CiExpectation::Expected
3627 && matches!(after.pr.checks, Checks::Pending | Checks::Unknown))
3628 || state.is_empty()
3629 || state == "UNKNOWN"
3630 {
3631 return Refused::Pending;
3632 }
3633 if rechecked {
3634 Refused::Final
3635 } else {
3636 Refused::Recheck
3637 }
3638}
3639
3640async fn disarm<F: Forge>(
3645 forge: &F,
3646 state: &mut RunState,
3647 repo: &Path,
3648 number: u64,
3649) -> std::result::Result<(), String> {
3650 let argv = disable_automerge_argv(number);
3651 let out = {
3652 let merge_lock = repo_merge_lock(repo);
3653 let _merge_slot = merge_lock.lock().await;
3654 forge.merge(repo, &argv).await
3655 };
3656 match out {
3657 Ok((true, _)) => {
3658 state.land_armed_head = None;
3659 state.event("land", "auto-merge disabled");
3660 state.save().map_err(|e| format!("{e:#}"))?;
3661 Ok(())
3662 }
3663 Ok((false, msg)) => Err(msg),
3664 Err(e) => Err(format!("{e:#}")),
3665 }
3666}
3667
3668async fn stop_disarmed<F: Forge>(
3672 forge: &F,
3673 state: &mut RunState,
3674 repo: &Path,
3675 pr: &PrState,
3676 why: &str,
3677) -> Result<()> {
3678 if state.land_armed_head.is_none() {
3679 return stop(state, repo, pr, why).await;
3680 }
3681 match disarm(forge, state, repo, pr.number).await {
3682 Ok(()) => stop(state, repo, pr, why).await,
3683 Err(e) => {
3684 let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3685 stop(state, repo, pr, &why).await
3686 }
3687 }
3688}
3689
3690fn owner_requested_stop(state: &RunState) -> Result<bool> {
3692 let Some(binding) = &state.land_approval else {
3693 return Ok(false);
3694 };
3695 let q = ask::Questions::open().get(&binding.question)?;
3696 Ok(q.run == state.id
3697 && q.node == APPROVAL_NODE
3698 && q.resolution()
3699 .as_deref()
3700 .is_some_and(crate::merge_decision::stops))
3701}
3702
3703async fn owner_stop<F: Forge>(state: &mut RunState, pr: &mut PrState, forge: &F) -> Result<bool> {
3706 use crate::merge_decision as md;
3707 let Some(binding) = &state.land_approval else {
3708 return Ok(false);
3709 };
3710 let q = ask::Questions::open().get(&binding.question)?;
3711 let Some(choice) = q.resolution().filter(|c| md::stops(c)) else {
3712 return Ok(false);
3713 };
3714 if q.run != state.id || q.node != APPROVAL_NODE {
3715 bail!("stop decision names another run");
3716 }
3717 let target = q.merge_target.clone().unwrap_or_else(|| md::Target {
3718 pr: pr.url.clone(),
3719 head: binding.head.clone(),
3720 language: state.config.graph.language.clone(),
3721 });
3722 if target.pr != pr.url {
3723 bail!("stop decision names another PR");
3724 }
3725 if state.merge_decision.is_none() {
3726 state.merge_decision = Some(md::Decision {
3727 question: q.id.clone(),
3728 target,
3729 choice: choice.clone(),
3730 closed: false,
3731 replacement_run: None,
3732 replacement_pr: None,
3733 });
3734 state.save()?;
3735 }
3736 let repo = state.repo.clone();
3737 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3739 stop(state, &repo, pr, &format!("owner stop requested, but auto-merge could not be disabled: {e}; no repair or replacement started")).await?;
3740 return Ok(true);
3741 }
3742 if choice.eq_ignore_ascii_case(HOLD) {
3743 stop(
3744 state,
3745 &repo,
3746 pr,
3747 "owner held the decision: PR left open; no further repair or merge work",
3748 )
3749 .await?;
3750 return Ok(true);
3751 }
3752 if md::selected_agent(&choice).is_some()
3753 || state
3754 .origin
3755 .as_ref()
3756 .and_then(|o| o.task.as_ref())
3757 .is_some()
3758 {
3759 let owners: Vec<_> = crate::queue::Queue::open()
3760 .list()
3761 .into_iter()
3762 .filter(|task| task.runs.last() == Some(&state.id))
3763 .collect();
3764 let owner = (owners.len() == 1).then(|| &owners[0]);
3765 if !owner.is_some_and(|task| {
3766 task.runs.last() == Some(&state.id)
3767 && (!task
3768 .operator_decided_at
3769 .is_some_and(|at| at > q.answered_at.unwrap_or(q.asked_at))
3770 || task.resumption.as_ref().is_some_and(|release| {
3771 release.question_id.as_deref() == Some(q.id.as_str())
3772 && task.operator_decided_at == Some(release.at)
3773 }))
3774 }) {
3775 stop(state, &repo, pr, "replacement requires the current original queue task owner and an unsuperseded answer; PR left open").await?;
3776 return Ok(true);
3777 }
3778 }
3779 if pr.state == PrLifecycle::Merged {
3780 stop(
3781 state,
3782 &repo,
3783 pr,
3784 "PR already merged; withdrawal/replacement cannot be applied",
3785 )
3786 .await?;
3787 return Ok(true);
3788 }
3789 let comment = format!(
3790 "{MARKER}\nOwner decision on question {}: {}. Previous attempt: `{}`. Original instruction and review history remain in the task record.",
3791 q.id, choice, state.id
3792 );
3793 if pr.state == PrLifecycle::Open {
3794 if let Err(e) = forge.close(&repo, &pr.url, &comment).await {
3795 stop(
3796 state,
3797 &repo,
3798 pr,
3799 &format!(
3800 "owner requested closure but it failed: {e}; task held, no replacement started"
3801 ),
3802 )
3803 .await?;
3804 return Ok(true);
3805 }
3806 }
3807 let confirmed = forge.view(&repo, &pr.url).await?;
3808 if confirmed.pr.state != PrLifecycle::Closed {
3809 stop(
3810 state,
3811 &repo,
3812 pr,
3813 "PR closure was not confirmed; task held, no replacement started",
3814 )
3815 .await?;
3816 return Ok(true);
3817 }
3818 *pr = confirmed.pr;
3819 state.merge_decision.as_mut().unwrap().closed = true;
3820 if let Some(record) = &mut state.pr {
3821 record.state = "closed".into();
3822 }
3823 state.status = RunStatus::Superseded;
3824 state.merge = Some(MergeOutcome {
3825 mode: MergeMode::Pr,
3826 ok: false,
3827 detail: format!("owner decision: {choice}; PR closure confirmed"),
3828 empty: false,
3829 });
3830 state.event(
3831 "land",
3832 format!(
3833 "{} applied: PR closure confirmed; old execution retired",
3834 choice
3835 ),
3836 );
3837 state.save()?;
3838 Ok(true)
3839}
3840
3841fn check_landing_resources(
3843 state: &mut RunState,
3844 node: &str,
3845 outcomes: &[crate::run::CommandOutcome],
3846) -> Result<()> {
3847 if let Some(blocked) = outcomes.iter().find(|outcome| outcome.resource_blocked) {
3848 let detail = format!("{}: {}", blocked.command, blocked.output_tail);
3849 state
3850 .resource_block
3851 .get_or_insert_with(|| crate::run::ResourceBlock {
3852 cause: crate::run::ResourceBlockCause::Other,
3853 node: node.into(),
3854 kind: "shared build cache".into(),
3855 detail: detail.clone(),
3856 at: jiff::Timestamp::now(),
3857 });
3858 state.event("resource", &detail);
3859 state.save()?;
3860 bail!("landing verification could not start: {detail}");
3861 }
3862 Ok(())
3863}
3864
3865async fn publish_landing_head<F: Forge>(
3868 state: &mut RunState,
3869 observed: &str,
3870 pr_url: &str,
3871 forge: &F,
3872) -> Result<String> {
3873 crate::pr_head::ensure_owner(state)?;
3874 let winner = state.winner().cloned().context("landing has no winner")?;
3875 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3876 let expected = state.pr_pushed_head.as_deref().unwrap_or(observed);
3877 anyhow::ensure!(
3878 observed == expected
3879 || (observed == head
3880 && state.gate_head.as_deref() == Some(&head)
3881 && state.gate_allows_progress()),
3882 "public head moved outside this run; refusing to overwrite it"
3883 );
3884 anyhow::ensure!(
3885 git::is_clean(&winner.worktree).await?,
3886 "landing worktree is dirty"
3887 );
3888 if state.gate_head.as_deref() != Some(&head) || !state.gate_ran || !state.gate_allows_progress()
3889 {
3890 state.gate.clear();
3891 state.gate_head = None;
3892 state.gate_ran = false;
3893 state.pre_gate.clear();
3894 state.pre_gate_commit = None;
3895 state.save()?;
3896 let inherited = crate::repo_lock::WaitPolicy::current();
3897 let wait = crate::repo_lock::WaitPolicy::new(
3898 Duration::from_secs(state.config.graph.timeout_resource_wait),
3899 move || inherited.cancelled(),
3900 );
3901 let mut outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3902 loop {
3903 if outcomes.iter().any(|outcome| outcome.resource_blocked) {
3904 state.gate = outcomes.clone();
3905 check_landing_resources(state, "gate", &outcomes)?;
3906 }
3907 if outcomes.iter().all(|o| o.ok()) {
3908 break;
3909 }
3910 match Box::pin(crate::graph::repair_landing_gate(state, &winner, &outcomes)).await? {
3911 crate::graph::GateFix::Retry => {
3912 outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3913 }
3914 crate::graph::GateFix::Stop => break,
3915 crate::graph::GateFix::Defer => {
3916 if let Some(e2e) = &state.gate_repair_e2e {
3920 let outcomes = e2e.outcomes.clone();
3921 check_landing_resources(state, "e2e", &outcomes)?;
3922 }
3923 bail!("landing gate repair deferred");
3924 }
3925 }
3926 }
3927 state.gate = outcomes;
3928 state.gate_ran = true;
3929 state.gate_head = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
3930 state.save()?;
3931 }
3932 anyhow::ensure!(
3933 state.gate_allows_progress(),
3934 "landing gate failed; nothing published"
3935 );
3936 let checked = state
3937 .gate_head
3938 .clone()
3939 .context("landing gate has no checked SHA")?;
3940 anyhow::ensure!(
3941 git::rev_parse(&winner.worktree, "HEAD").await? == checked
3942 && git::is_clean(&winner.worktree).await?,
3943 "landing tree changed after gate"
3944 );
3945 let inherited = crate::repo_lock::WaitPolicy::current();
3946 let wait = crate::repo_lock::WaitPolicy::new(
3947 Duration::from_secs(state.config.graph.timeout_resource_wait),
3948 move || inherited.cancelled(),
3949 );
3950 let e2e = crate::graph::check_gate_repair_e2e(state, &winner, &wait).await?;
3951 if matches!(e2e, crate::graph::GateFix::Defer) {
3952 let outcomes = state
3953 .gate_repair_e2e
3954 .as_ref()
3955 .context("deferred landing e2e has no outcomes")?
3956 .outcomes
3957 .clone();
3958 check_landing_resources(state, "e2e", &outcomes)?;
3959 }
3960 anyhow::ensure!(
3961 matches!(e2e, crate::graph::GateFix::Retry),
3962 "post-repair e2e has not passed on the landing head; nothing published"
3963 );
3964 anyhow::ensure!(
3965 git::rev_parse(&winner.worktree, "HEAD").await? == checked
3966 && git::is_clean(&winner.worktree).await?,
3967 "landing tree changed during post-repair e2e"
3968 );
3969 crate::pr_head::ensure_owner(state)?;
3970 if owner_requested_stop(state)? {
3971 bail!("owner requested stop before publication");
3972 }
3973 let current = forge.view(&state.repo, pr_url).await?;
3974 anyhow::ensure!(
3975 current.pr.state == PrLifecycle::Open,
3976 "PR closed or merged while gating; nothing published"
3977 );
3978 anyhow::ensure!(
3979 current.head == observed || current.head == checked,
3980 "PR head changed while gating; nothing published"
3981 );
3982 let public = state.pr_branch().unwrap_or(&winner.branch).to_owned();
3983 let lease = format!("--force-with-lease=refs/heads/{public}:{observed}");
3984 let refspec = format!("{checked}:refs/heads/{public}");
3985 let lock = repo_merge_lock(&state.repo);
3986 let _slot = lock.lock().await;
3987 let pushed = git::git_raw(
3988 &winner.worktree,
3989 &["push", &lease, &state.config.merge.remote, &refspec],
3990 )
3991 .await?;
3992 anyhow::ensure!(
3993 pushed.ok(),
3994 "publishing gated landing head failed: {}",
3995 pushed.stderr
3996 );
3997 state.pr_pushed_head = Some(checked.clone());
3998 state.land_sync_pending = false;
3999 state.save()?;
4000 Ok(checked)
4001}
4002
4003async fn restore_landing_worktree(state: &mut RunState) -> Result<()> {
4006 let winner = state.winner().cloned().context("landing has no winner")?;
4007 let inherited = crate::repo_lock::WaitPolicy::current();
4008 let wait = crate::repo_lock::WaitPolicy::new(
4009 Duration::from_secs(state.config.graph.timeout_resource_wait),
4010 move || inherited.cancelled(),
4011 );
4012 let _coordination = crate::repo_lock::SeatLease::operation_with(
4013 &winner.worktree,
4014 state.config.coordination.as_ref(),
4015 &wait,
4016 )
4017 .await?;
4018 if !git::is_clean(&winner.worktree).await? {
4019 let from = state
4020 .land_rebase_from
4021 .as_deref()
4022 .context("landing worktree is dirty")?;
4023 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4024 anyhow::ensure!(head != from, "landing worktree is dirty");
4025 for args in [
4026 vec!["diff", "--cached", "--quiet", from],
4027 vec!["diff", "--quiet"],
4028 ] {
4029 anyhow::ensure!(
4030 git::git_raw(&winner.worktree, &args).await?.ok(),
4031 "landing worktree has edits beyond the interrupted rebase"
4032 );
4033 }
4034 let untracked = git::git(&winner.worktree, &["ls-files", "--others", "-z"]).await?;
4038 let tracked = git::git(
4039 &winner.worktree,
4040 &["ls-tree", "-r", "--name-only", "-z", "HEAD"],
4041 )
4042 .await?;
4043 for local in untracked.split('\0').filter(|p| !p.is_empty()) {
4044 anyhow::ensure!(
4045 !tracked.split('\0').filter(|p| !p.is_empty()).any(|path| {
4046 path == local
4047 || path
4048 .strip_prefix(local)
4049 .is_some_and(|rest| rest.starts_with('/'))
4050 || local
4051 .strip_prefix(path)
4052 .is_some_and(|rest| rest.starts_with('/'))
4053 }),
4054 "landing worktree has an untracked or ignored path conflicting with HEAD: {local}"
4055 );
4056 }
4057 git::git(&winner.worktree, &["read-tree", "-m", "-u", "HEAD"]).await?;
4058 }
4059 crate::worktree_setup::prepare(&state.config, &state.repo, &winner.worktree).await?;
4060 state.land_rebase_from = None;
4061 state.save()?;
4062 Ok(())
4063}
4064
4065async fn rebase_landing_worktree(
4066 state: &mut RunState,
4067 onto: &str,
4068) -> Result<crate::rebase::Rebased> {
4069 restore_landing_worktree(state).await?;
4070 let winner = state.winner().cloned().context("landing has no winner")?;
4071 state.land_sync_pending = true;
4072 state.land_rebase_from = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
4073 state.save()?;
4074 let scratch = state.dir().join("rebase");
4075 let result = crate::rebase::rebase_with_fixer(state, &scratch, &winner.branch, onto).await?;
4076 if result == crate::rebase::Rebased::Applied {
4077 restore_landing_worktree(state).await?;
4078 }
4079 Ok(result)
4080}
4081
4082async fn resume_landing_head<F: Forge>(
4085 state: &mut RunState,
4086 observed: &str,
4087 pr_url: &str,
4088 forge: &F,
4089) -> Result<String> {
4090 crate::pr_head::ensure_owner(state)?;
4091 let winner = state.winner().cloned().context("landing has no winner")?;
4092 let local = git::rev_parse(&winner.worktree, "HEAD").await?;
4093 let expected = state
4094 .pr_pushed_head
4095 .as_deref()
4096 .or(state.gate_head.as_deref())
4097 .context("missing public head lease")?;
4098 anyhow::ensure!(
4099 observed == expected
4100 || (observed == local
4101 && ((state.gate_head.as_deref() == Some(&local) && state.gate_allows_progress())
4102 || git::is_ancestor(&winner.worktree, expected, &local).await)),
4103 "public head moved outside this run; refusing to sync"
4104 );
4105 state.pr_pushed_head = Some(observed.to_owned());
4112 state.save()?;
4113 restore_landing_worktree(state).await?;
4114 let remote = state.config.merge.remote.clone();
4115 let base = state.base_branch.clone();
4116 let fetched = git::fetch(&state.repo, &remote, &base).await?;
4117 anyhow::ensure!(
4118 fetched.ok(),
4119 "cannot fetch landing base: {}",
4120 fetched.stderr
4121 );
4122 let onto = format!("{remote}/{base}");
4123 let tip = git::rev_parse(&state.repo, &onto).await?;
4124 if !git::is_ancestor(&state.repo, &tip, &local).await {
4125 match rebase_landing_worktree(state, &onto).await? {
4126 crate::rebase::Rebased::Applied => {}
4127 crate::rebase::Rebased::Stopped(why) => bail!("landing rebase stopped: {why}"),
4128 }
4129 }
4130 publish_landing_head(state, observed, pr_url, forge).await
4131}
4132
4133async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
4134 crate::pr_head::ensure_owner(state)?;
4135 state.land_unrelated = None;
4136 let reopening_hold = crate::merge_decision::resumable_hold(state);
4137 let repo = state.repo.clone();
4138 let budget = state.config.graph.land_rounds;
4139 let mut round = 0usize;
4140 let mut rebases = 0usize;
4143 let ci = state.config.merge.ci.expectation();
4144 if ci == CiExpectation::Absent {
4145 state.event(
4146 "land",
4147 "CI not expected ([merge] ci = \"none\"); check status ignored",
4148 );
4149 }
4150 let mut waited = Duration::ZERO;
4151 let mut shown: BTreeSet<String> = BTreeSet::new();
4156 let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
4158 let mut rechecked = false;
4160
4161 state.status = RunStatus::Landing;
4169 state.event("land", format!("watching {pr_url}"));
4170 state.save()?;
4171
4172 let mut resumed_armed = state.land_armed_head.is_some();
4176
4177 loop {
4178 let seen = forge.view(&repo, pr_url).await?;
4179 let mut pr = seen.pr.clone();
4180 pr.review_comments.retain(|c| !shown.contains(&c.body));
4181 state.pr = Some(crate::run::PrRecord {
4182 url: pr.url.clone(),
4183 number: pr.number,
4184 state: pr.state.as_str().to_owned(),
4185 checks: pr.checks.as_str().to_owned(),
4186 round,
4187 rounds: budget,
4188 red_at_merge: Vec::new(),
4189 });
4190 state.save()?;
4191
4192 if pr.state == PrLifecycle::Open {
4193 state.observe_branch_owner_holds(&seen.head).await;
4194 state.save()?;
4195 }
4196 crate::pr_head::ensure_owner(state)?;
4197 if reopening_hold {
4198 anyhow::ensure!(
4199 pr.state == PrLifecycle::Open,
4200 "held PR is no longer open; cannot request a fresh decision"
4201 );
4202 disarm(forge, state, &repo, pr.number)
4205 .await
4206 .map_err(anyhow::Error::msg)?;
4207 let previous = state.merge_decision.take();
4208 let binding = state.land_approval.take();
4209 state.config.graph.land_approval = true;
4210 let subject = merge_subject(
4211 crate::graph::landing_title(state, &seen.title),
4212 &crate::graph::landing_subject_source(state),
4213 );
4214 let contested = contested_to_ask(state);
4215 if let Err(error) =
4216 approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
4217 {
4218 state.merge_decision = previous;
4219 state.land_approval = binding;
4220 return Err(error);
4221 }
4222 state.end_pass_parked(crate::run::ParkCause::Question);
4223 state.parked = true;
4224 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()));
4225 state.save()?;
4226 return Ok(pr);
4227 }
4228 if owner_stop(state, &mut pr, forge).await? {
4229 return Ok(pr);
4230 }
4231
4232 if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
4233 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4238 let why = format!(
4239 "a previous pass may have armed auto-merge and it could not be disabled \
4240 on resume: {e}"
4241 );
4242 stop(state, &repo, &pr, &why).await?;
4243 return Ok(pr);
4244 }
4245 }
4246
4247 if pr.state == PrLifecycle::Open
4252 && !seen.head.is_empty()
4253 && state
4254 .land_armed_head
4255 .as_deref()
4256 .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
4257 {
4258 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4259 let why = format!(
4260 "the head moved while auto-merge was armed and it could not be disabled: {e}"
4261 );
4262 stop(state, &repo, &pr, &why).await?;
4263 return Ok(pr);
4264 }
4265 }
4266
4267 if state.land_sync_pending && pr.state == PrLifecycle::Open {
4268 if owner_stop(state, &mut pr, forge).await? {
4271 return Ok(pr);
4272 }
4273 let repair_answer = state.land_approval.as_ref().is_some_and(|binding| {
4274 binding.head.eq_ignore_ascii_case(&seen.head)
4275 && ask::Questions::open()
4276 .get(&binding.question)
4277 .is_ok_and(|q| {
4278 q.run == state.id
4279 && q.node == APPROVAL_NODE
4280 && q.status == ask::QuestionStatus::Answered
4281 && matches!(approval(q.resolution().as_deref()), Approval::Repair)
4282 })
4283 });
4284 if repair_answer {
4285 let subject = merge_subject(
4286 crate::graph::landing_title(state, &seen.title),
4287 &crate::graph::landing_subject_source(state),
4288 );
4289 let contested = contested_to_ask(state);
4290 if matches!(
4291 approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await?,
4292 ApprovalGate::Repair
4293 ) {
4294 if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4295 state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4296 state.save()?;
4297 }
4298 return Ok(pr);
4299 }
4300 }
4301 let synced = resume_landing_head(state, &seen.head, pr_url, forge).await;
4302 if owner_stop(state, &mut pr, forge).await? {
4305 return Ok(pr);
4306 }
4307 let head = synced?;
4308 awaiting_head = Some(head);
4309 waited = Duration::ZERO;
4310 rechecked = false;
4311 continue;
4313 }
4314
4315 if pr.state == PrLifecycle::Open {
4316 if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
4317 if waited >= WAIT_CEILING {
4318 let want = awaiting_head.as_deref().unwrap_or_default();
4319 let why = format!(
4320 "the pull request's checks were still not about one readable head after \
4321 {} minutes (expected {}, pull request points at {}, checks are for {}); \
4322 someone may have pushed over it",
4323 WAIT_CEILING.as_secs() / 60,
4324 if want.is_empty() { "any" } else { want },
4325 if seen.head.is_empty() {
4326 "nothing readable"
4327 } else {
4328 &seen.head
4329 },
4330 if seen.rollup_head.is_empty() {
4331 "nothing readable"
4332 } else {
4333 &seen.rollup_head
4334 },
4335 );
4336 stop_disarmed(forge, state, &repo, &pr, &why).await?;
4337 return Ok(pr);
4338 }
4339 waited += POLL;
4340 forge.poll().await;
4341 continue;
4342 }
4343 if awaiting_head.take().is_some() {
4346 waited = Duration::ZERO;
4349 }
4350 }
4351
4352 let decision_wait = if pr.checks == Checks::Red && pr.blocking == Blocking::Unsaid {
4355 let first = state
4356 .land_unsaid
4357 .get_or_insert_with(|| (seen.head.clone(), forge.now()));
4358 if !first.0.eq_ignore_ascii_case(&seen.head) {
4359 *first = (seen.head.clone(), forge.now());
4360 }
4361 let elapsed = Duration::from_secs(forge.now().saturating_sub(first.1).max(0) as u64);
4362 state.save()?;
4363 elapsed
4364 } else {
4365 waited
4366 };
4367 let proposed = decide_with(&pr, round, budget, decision_wait, ci);
4368 if pr.checks == Checks::Red && pr.blocking == Blocking::Unsaid && proposed == Step::Wait {
4369 if waited >= WAIT_CEILING {
4370 stop_disarmed(
4371 forge,
4372 state,
4373 &repo,
4374 &pr,
4375 "merge state remained unreadable; refusing to merge on a guess",
4376 )
4377 .await?;
4378 return Ok(pr);
4379 }
4380 waited += POLL;
4381 forge.poll().await;
4382 continue;
4383 }
4384
4385 if ci == CiExpectation::Expected
4390 && pr.state == PrLifecycle::Open
4391 && state.config.graph.rerun_flaky_checks
4392 {
4393 let include_optional = pr.checks == Checks::Red
4394 && matches!(proposed, Step::Fix { .. } | Step::GiveUp { .. });
4395 let failing = rerunnable_with(&seen.contexts, include_optional);
4396 let now = forge.now();
4397 match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
4398 RerunStep::Rerun(runs) => {
4399 let rec = state.land_reruns.get_or_insert_with(Default::default);
4400 if !rec.head.eq_ignore_ascii_case(&seen.head) {
4401 *rec = LandReruns {
4402 head: seen.head.clone(),
4403 runs: BTreeMap::new(),
4404 };
4405 }
4406 for run in &runs {
4407 rec.runs.insert(run.clone(), now);
4408 }
4409 state.save()?;
4413 let mut any = false;
4414 for run in &runs {
4415 let names = failing
4416 .iter()
4417 .filter(|(_, r)| r.as_deref() == Some(run))
4418 .map(|(l, _)| l.as_str())
4419 .collect::<Vec<_>>()
4420 .join(", ");
4421 match forge.rerun(&repo, run).await {
4422 Ok(()) => {
4423 any = true;
4424 state.event(
4425 "land",
4426 format!("reran failed check(s) {names} (run {run})"),
4427 );
4428 }
4429 Err(e) => state.event(
4431 "land",
4432 format!("could not rerun check(s) {names} (run {run}): {e:#}"),
4433 ),
4434 }
4435 }
4436 state.save()?;
4437 if any {
4438 waited = Duration::ZERO;
4439 forge.poll().await;
4440 continue;
4441 }
4442 }
4443 RerunStep::Grace => {
4444 if waited >= WAIT_CEILING {
4445 let why = format!(
4446 "checks were still failing after a rerun and {} minutes",
4447 WAIT_CEILING.as_secs() / 60
4448 );
4449 stop(state, &repo, &pr, &why).await?;
4450 return Ok(pr);
4451 }
4452 waited += POLL;
4453 forge.poll().await;
4454 continue;
4455 }
4456 RerunStep::Fix => {}
4457 }
4458 }
4459
4460 let step = proposed;
4461 let armed_here = state
4466 .land_armed_head
4467 .as_deref()
4468 .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
4469 if armed_here && matches!(step, Step::Merge | Step::Wait) {
4470 if waited >= WAIT_CEILING {
4471 let required = if seen.base.is_empty() {
4472 None
4473 } else {
4474 forge.required_contexts(&repo, &seen.base).await
4475 };
4476 let why = format!(
4477 "auto-merge was armed on {} but the pull request did not merge within {} \
4478 minutes ({})",
4479 seen.head,
4480 WAIT_CEILING.as_secs() / 60,
4481 waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
4482 );
4483 stop_disarmed(forge, state, &repo, &pr, &why).await?;
4484 return Ok(pr);
4485 }
4486 waited += POLL;
4487 forge.poll().await;
4488 continue;
4489 }
4490 if armed_here && !matches!(step, Step::Done { .. }) {
4491 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4494 let why = format!("auto-merge could not be disabled: {e}");
4495 stop(state, &repo, &pr, &why).await?;
4496 return Ok(pr);
4497 }
4498 }
4499 if pr.checks == Checks::Red
4500 && matches!(step, Step::Fix { .. } | Step::GiveUp { .. })
4501 && pr.review_comments.is_empty()
4502 && let Some(evidence) = forge.attribution(state, &seen).await
4503 && evidence.unrelated(&seen.head, &pr.failing)
4504 {
4505 if owner_stop(state, &mut pr, forge).await? {
4506 return Ok(pr);
4507 }
4508 let fresh = forge.view(&repo, pr_url).await?;
4510 if fresh.head != seen.head
4511 || fresh.rollup_head != seen.rollup_head
4512 || fresh.pr.state != PrLifecycle::Open
4513 || fresh.pr.checks != Checks::Red
4514 || fresh.pr.failing != pr.failing
4515 || fresh
4516 .pr
4517 .review_comments
4518 .iter()
4519 .any(|c| !shown.contains(&c.body))
4520 {
4521 continue;
4522 }
4523 hold_unrelated(state, &pr, evidence).await?;
4524 return Ok(pr);
4525 }
4526 match step {
4527 Step::Wait => {
4528 if waited >= WAIT_CEILING {
4529 let why = format!(
4530 "checks were still running after {} minutes",
4531 WAIT_CEILING.as_secs() / 60
4532 );
4533 stop(state, &repo, &pr, &why).await?;
4534 return Ok(pr);
4535 }
4536 waited += POLL;
4537 forge.poll().await;
4538 }
4539 Step::Done { merged } => {
4540 if merged {
4541 state.pr_merged_head = (!seen.head.is_empty()).then(|| seen.head.clone());
4542 }
4543 if let Some(armed) = state.land_armed_head.take() {
4548 if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
4549 let msg = format!(
4550 "{} merged on {} but the owner approved {armed}; review what landed",
4551 pr.url, seen.head
4552 );
4553 tracing::warn!("{msg}");
4554 state.event("land", msg);
4555 crate::notices::raise_with(
4559 crate::notices::Notice::warn(
4560 &format!("merged-unapproved-head:{}", state.id),
4561 "A pull request merged on a commit the owner did not approve; \
4562 review what landed",
4563 )
4564 .link(crate::notices::Link::Run {
4565 id: state.id.clone(),
4566 }),
4567 &state.config.notify,
4568 );
4569 }
4570 }
4571 state.status = if merged {
4572 RunStatus::Merged
4573 } else {
4574 RunStatus::Ready
4575 };
4576 let detail = if merged {
4577 format!("{} was merged", pr.url)
4578 } else {
4579 format!("{} was closed without merging", pr.url)
4580 };
4581 state.merge = Some(MergeOutcome {
4582 mode: MergeMode::Pr,
4583 ok: merged,
4584 detail: detail.clone(),
4585 empty: false,
4586 });
4587 state.event("land", detail);
4588 state.save()?;
4589 write_pr_state_through(state, pr.state);
4590 return Ok(pr);
4591 }
4592 Step::Merge => {
4593 state.check_delivery_evidence(&seen.head)?;
4594 let subject = merge_subject(
4595 crate::graph::landing_title(state, &seen.title),
4596 &crate::graph::landing_subject_source(state),
4597 );
4598 let contested = contested_to_ask(state);
4604 if !approval_reasons(state).is_empty() {
4605 match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
4606 .await?
4607 {
4608 ApprovalGate::Approved => {}
4609 ApprovalGate::Repair => {
4610 if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4613 state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4614 state.save()?;
4615 }
4616 return Ok(pr);
4617 }
4618 ApprovalGate::Held => {
4619 if owner_stop(state, &mut pr, forge).await? {
4622 return Ok(pr);
4623 }
4624 stop(
4625 state,
4626 &repo,
4627 &pr,
4628 "the owner did not approve the merge (held or unanswered)",
4629 )
4630 .await?;
4631 return Ok(pr);
4632 }
4633 ApprovalGate::Pending => {
4641 state.end_pass_parked(crate::run::ParkCause::Question);
4642 state.parked = true;
4643 state.event(
4644 "land",
4645 "parked awaiting merge approval - resumes once answered",
4646 );
4647 state.save()?;
4648 return Ok(pr);
4649 }
4650 }
4651 }
4652 let observed_head = seen.head.clone();
4663 {
4666 let fresh = forge.view(&repo, pr_url).await.ok();
4667 if !direct_merge_is_safe(
4668 fresh.as_ref(),
4669 &observed_head,
4670 &shown,
4671 round,
4672 budget,
4673 waited,
4674 ci,
4675 ) {
4676 if waited >= WAIT_CEILING {
4677 let why = "the pull request did not settle on the approved head \
4678 before it could be merged";
4679 stop(state, &repo, &pr, why).await?;
4680 return Ok(pr);
4681 }
4682 state.event(
4683 "land",
4684 "the pull request changed before merging; looking again",
4685 );
4686 waited += POLL;
4687 forge.poll().await;
4688 continue;
4689 }
4690 }
4691 let argv = merge_argv_at(pr.number, &subject, &observed_head);
4692 let out = {
4693 let merge_lock = repo_merge_lock(&repo);
4694 let _merge_slot = merge_lock.lock().await;
4695 crate::pr_head::ensure_owner(state)?;
4696 if let Some(task_id) = state
4697 .origin
4698 .as_ref()
4699 .and_then(|origin| origin.task.as_ref())
4700 {
4701 let task = crate::queue::Queue::open()
4702 .get(task_id)
4703 .context("cannot verify the task owner immediately before merge")?;
4704 if task.runs.last() != Some(&state.id)
4705 || task.operator_held()
4706 || task.status == crate::queue::TaskStatus::Done
4707 {
4708 bail!(
4709 "task ownership or an operator hold superseded this merge approval"
4710 );
4711 }
4712 }
4713 if let Some(binding) = &state.land_approval {
4714 let current = ask::Questions::open().get(&binding.question)?;
4715 if merge_consent_superseded(state, ¤t)?
4716 || current.run != state.id
4717 || !binding.head.eq_ignore_ascii_case(&observed_head)
4718 || approval(current.resolution().as_deref()) != Approval::Merge
4719 || current.merge_target.as_ref().is_some_and(|t| {
4720 t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4721 })
4722 {
4723 bail!(
4724 "merge decision is stale or no longer authorizes the reviewed head"
4725 );
4726 }
4727 }
4728 forge.merge(&repo, &argv).await?
4729 };
4730 if out.0 {
4731 let confirmed = forge.view(&repo, pr_url).await.is_ok_and(|c| {
4737 if c.pr.state == PrLifecycle::Merged {
4738 state.pr_merged_head = (!c.head.is_empty()).then_some(c.head);
4739 true
4740 } else {
4741 false
4742 }
4743 });
4744 if !confirmed {
4745 if waited >= WAIT_CEILING {
4746 let why = "the merge request succeeded but the pull request \
4747 could not be confirmed merged after waiting";
4748 stop(state, &repo, &pr, why).await?;
4749 return Ok(pr);
4750 }
4751 state.event(
4752 "land",
4753 "merge accepted but the pull request is not confirmed merged yet; waiting",
4754 );
4755 state.save()?;
4756 waited += POLL;
4757 forge.poll().await;
4758 continue;
4759 }
4760 pr.state = PrLifecycle::Merged;
4761 state.status = RunStatus::Merged;
4762 state.merge = Some(MergeOutcome {
4763 mode: MergeMode::Pr,
4764 ok: true,
4765 detail: format!("gh {}", argv.join(" ")),
4766 empty: false,
4767 });
4768 if let Some(pr_record) = state.pr.as_mut() {
4773 pr_record.state = pr.state.as_str().to_owned();
4774 }
4775 state.event("land", format!("merged {} as `{subject}`", pr.url));
4776 announce_red_merge(state, &pr).await;
4777 state.save()?;
4778 write_pr_state_through(state, pr.state);
4779 return Ok(pr);
4780 }
4781 let after_seen = forge.view(&repo, pr_url).await.ok();
4782 let after = after_seen.as_ref().map(|s| s.pr.state);
4783 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4784 state.pr_merged_head = after_seen
4785 .as_ref()
4786 .map(|s| s.head.clone())
4787 .filter(|h| !h.is_empty());
4788 pr.state = PrLifecycle::Merged;
4789 state.status = RunStatus::Merged;
4790 state.merge = Some(outcome);
4791 if let Some(pr_record) = state.pr.as_mut() {
4792 pr_record.state = pr.state.as_str().to_owned();
4793 }
4794 state.event("land", format!("merged {} as `{subject}`", pr.url));
4795 announce_red_merge(state, &pr).await;
4796 state.save()?;
4797 write_pr_state_through(state, pr.state);
4798 return Ok(pr);
4799 }
4800 let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head, ci);
4801 match verdict {
4802 Refused::Final => {
4803 let merge_state = after_seen
4804 .as_ref()
4805 .map(|s| s.merge_state.as_str())
4806 .filter(|m| !m.is_empty())
4807 .unwrap_or("unknown");
4808 let why = format!(
4812 "the merge was refused: {} (merge state: {merge_state})",
4813 out.1
4814 );
4815 stop(state, &repo, &pr, &why).await?;
4816 return Ok(pr);
4817 }
4818 verdict => {
4819 if waited >= WAIT_CEILING {
4824 let why = format!(
4825 "the merge was still refused after {} minutes: {}",
4826 WAIT_CEILING.as_secs() / 60,
4827 out.1
4828 );
4829 stop(state, &repo, &pr, &why).await?;
4830 return Ok(pr);
4831 }
4832 if verdict == Refused::Recheck {
4833 rechecked = true;
4834 }
4835 state.event(
4836 "land",
4837 "merge refused while the branch policy is not satisfied yet; waiting",
4838 );
4839 state.save()?;
4840 waited += POLL;
4841 forge.poll().await;
4842 }
4843 }
4844 }
4845 Step::Rebase => {
4846 if rebases >= budget {
4852 let why = format!(
4853 "the base moved under this branch {budget} time(s) and it still does \
4854 not merge; rebasing again would only race it"
4855 );
4856 stop(state, &repo, &pr, &why).await?;
4857 return Ok(pr);
4858 }
4859 rebases += 1;
4860 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4861 stop(
4862 state,
4863 &repo,
4864 &pr,
4865 "the pull request conflicts and this run has no winning branch to rebase",
4866 )
4867 .await?;
4868 return Ok(pr);
4869 };
4870 let base = state.base_branch.clone();
4871 state.event(
4872 "land",
4873 format!("{} no longer merges; rebasing onto {base}", pr.url),
4874 );
4875 state.save()?;
4876
4877 let remote = state.config.merge.remote.clone();
4881 git::fetch(&repo, &remote, &base).await.ok();
4882 if owner_stop(state, &mut pr, forge).await? {
4883 return Ok(pr);
4884 }
4885 let onto = format!("{remote}/{base}");
4886 let rebased = match rebase_landing_worktree(state, &onto).await {
4887 Ok(crate::rebase::Rebased::Applied) => Ok(None),
4888 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4889 Err(e) => Err(e),
4890 };
4891 if owner_stop(state, &mut pr, forge).await? {
4892 return Ok(pr);
4893 }
4894 match rebased {
4895 Ok(None) => {
4896 let published =
4897 publish_landing_head(state, &seen.head, pr_url, forge).await;
4898 if owner_stop(state, &mut pr, forge).await? {
4899 return Ok(pr);
4900 }
4901 let head = published?;
4902 crate::graph::refresh_reviewed_commits(state, &branch).await;
4903 state.pr_pushed_head = Some(head.clone());
4904 awaiting_head = Some(head);
4905 rechecked = false;
4906 state.event("land", format!("rebased {branch} onto {base}"));
4907 state.save()?;
4908 waited = Duration::ZERO;
4911 tokio::time::sleep(POLL).await;
4912 }
4913 Ok(Some(conflict)) => {
4916 let why = format!(
4917 "{} conflicts with {base} and the rebase did not apply: {}",
4918 pr.url,
4919 conflict.chars().take(600).collect::<String>()
4920 );
4921 stop(state, &repo, &pr, &why).await?;
4922 return Ok(pr);
4923 }
4924 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4925 Err(e) => {
4926 let why = format!("could not rebase {branch} onto {base}: {e:#}");
4927 stop(state, &repo, &pr, &why).await?;
4928 return Ok(pr);
4929 }
4930 }
4931 }
4932 Step::GiveUp { reason } => {
4933 stop(state, &repo, &pr, &reason).await?;
4934 return Ok(pr);
4935 }
4936 Step::Fix { reason } => {
4937 round += 1;
4938 waited = Duration::ZERO;
4939 for c in &pr.review_comments {
4940 shown.insert(c.body.clone());
4941 }
4942 state.event("land", format!("round {round}: {reason}"));
4943 state.save()?;
4944
4945 let logs = failing_logs(&repo, &seen.failing_urls).await;
4946 let was_red = pr.checks == Checks::Red;
4947 if owner_stop(state, &mut pr, forge).await? {
4948 return Ok(pr);
4949 }
4950 let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4951 if owner_stop(state, &mut pr, forge).await? {
4952 return Ok(pr);
4953 }
4954 match fixed {
4955 Fixed::Committed { head } => {
4956 state.pr_pushed_head = Some(head.clone());
4959 awaiting_head = Some(head);
4960 rechecked = false;
4961 waited = Duration::ZERO;
4962 forge.poll().await;
4963 }
4964 Fixed::Declined if was_red => {
4965 if let Some(evidence) = forge.attribution(state, &seen).await
4970 && evidence.unrelated(&seen.head, &pr.failing)
4971 {
4972 let fresh = forge.view(&repo, pr_url).await?;
4973 if fresh.head != seen.head
4974 || fresh.rollup_head != seen.rollup_head
4975 || fresh.pr.state != PrLifecycle::Open
4976 || fresh.pr.checks != Checks::Red
4977 || fresh.pr.failing != pr.failing
4978 || fresh
4979 .pr
4980 .review_comments
4981 .iter()
4982 .any(|c| !shown.contains(&c.body))
4983 {
4984 continue;
4985 }
4986 if owner_stop(state, &mut pr, forge).await? {
4987 return Ok(pr);
4988 }
4989 hold_unrelated(state, &pr, evidence).await?;
4990 return Ok(pr);
4991 }
4992 let why = format!(
4993 "the fixer produced no commit while {} check(s) were failing \
4994 ({}); stopping instead of looping on an unchanged tree",
4995 pr.failing.len(),
4996 pr.failing.join(", ")
4997 );
4998 stop(state, &repo, &pr, &why).await?;
4999 return Ok(pr);
5000 }
5001 Fixed::Declined => state.event(
5006 "land",
5007 format!("round {round}: fixer declined the comments, nothing committed"),
5008 ),
5009 Fixed::Failed(why) => {
5010 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
5011 return Ok(pr);
5012 }
5013 }
5014 state.save()?;
5015 }
5016 }
5017 }
5018}
5019
5020pub const CI_HOLD_NODE: &str = "land-ci-unrelated";
5022
5023#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
5025pub struct CiHold {
5026 pub head: String,
5028 pub question: String,
5030 pub evidence: CiEvidence,
5032}
5033
5034#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
5036struct CiFailure {
5037 path: String,
5038 start_line: u64,
5039 end_line: u64,
5040 title: String,
5041 message: String,
5042}
5043
5044#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
5045struct CiCheckEvidence {
5046 label: String,
5047 head_check: u64,
5048 base_check: u64,
5049 app: u64,
5050 run: String,
5051 base_run: String,
5052 head_failures: Vec<CiFailure>,
5053 base_failures: Vec<CiFailure>,
5054 rerun_exhausted: bool,
5055}
5056
5057#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
5059pub struct CiEvidence {
5060 head: String,
5061 base: String,
5062 changed: Vec<String>,
5063 checks: Vec<CiCheckEvidence>,
5064}
5065
5066impl CiEvidence {
5067 fn unrelated(&self, head: &str, failing: &[String]) -> bool {
5070 if self.head != head
5071 || self.base.is_empty()
5072 || failing.is_empty()
5073 || self.changed.iter().any(|p| {
5074 p.starts_with(".github/")
5075 || p.starts_with(".config/")
5076 || p.starts_with(".cargo/")
5077 || matches!(
5078 p.rsplit('/').next().unwrap_or(p),
5079 "Cargo.toml"
5080 | "Cargo.lock"
5081 | "Makefile.toml"
5082 | "build.rs"
5083 | "rust-toolchain.toml"
5084 )
5085 })
5086 {
5087 return false;
5088 }
5089 failing.iter().all(|label| {
5090 let matches: Vec<_> = self.checks.iter().filter(|c| &c.label == label).collect();
5091 matches.len() == 1
5092 && matches[0].rerun_exhausted
5093 && !matches[0].head_failures.is_empty()
5094 && matches[0].head_failures.iter().all(|f| {
5095 !f.path.is_empty()
5098 && !f.path.starts_with('.')
5099 && !f.path.contains("..")
5100 && !f.path.starts_with('/')
5101 && !self.changed.contains(&f.path)
5102 && matches[0].base_failures.contains(f)
5103 })
5104 })
5105 }
5106}
5107
5108async fn ci_json(repo: &Path, endpoint: String) -> Option<serde_json::Value> {
5109 let (ok, body) = gh(repo, &["api".into(), endpoint]).await.ok()?;
5110 if !ok {
5111 return None;
5112 }
5113 serde_json::from_str(&body).ok()
5114}
5115
5116async fn ci_annotations(repo: &Path, check: &serde_json::Value) -> Option<Vec<CiFailure>> {
5117 let count = check.pointer("/output/annotations_count")?.as_u64()?;
5118 if count == 0 || count >= 100 {
5119 return None;
5120 }
5121 let id = check.get("id")?.as_u64()?;
5122 let value = ci_json(
5123 repo,
5124 format!("repos/{{owner}}/{{repo}}/check-runs/{id}/annotations?per_page=100"),
5125 )
5126 .await?;
5127 let annotations = value.as_array()?;
5128 if annotations.len() != count as usize {
5129 return None;
5130 }
5131 annotations
5132 .iter()
5133 .filter(|a| a["annotation_level"] == "failure")
5134 .map(|a| {
5135 Some(CiFailure {
5136 path: a["path"].as_str()?.into(),
5137 start_line: a["start_line"].as_u64()?,
5138 end_line: a["end_line"].as_u64()?,
5139 title: a["title"].as_str()?.into(),
5140 message: a["message"].as_str()?.into(),
5141 })
5142 })
5143 .collect()
5144}
5145
5146async fn ci_runs(repo: &Path, sha: &str) -> Option<Vec<serde_json::Value>> {
5147 let value = ci_json(
5148 repo,
5149 format!("repos/{{owner}}/{{repo}}/commits/{sha}/check-runs?per_page=100&filter=latest"),
5150 )
5151 .await?;
5152 let runs = value["check_runs"].as_array()?;
5153 if value["total_count"].as_u64()? != runs.len() as u64 || runs.len() >= 100 {
5154 return None;
5155 }
5156 Some(runs.clone())
5157}
5158
5159async fn collect_ci_evidence(state: &RunState, seen: &Seen) -> Option<CiEvidence> {
5160 let repo = &state.repo;
5161 let base_ref = format!("refs/remotes/{}/{}", state.config.merge.remote, seen.base);
5162 let base = git::git_raw(repo, &["rev-parse", "--verify", &base_ref])
5163 .await
5164 .ok()?;
5165 if !base.ok() {
5166 return None;
5167 }
5168 let range = format!("{}...{}", base.stdout, seen.head);
5169 let diff = git::git_raw(
5171 repo,
5172 &["diff", "--no-renames", "--name-only", "-z", &range, "--"],
5173 )
5174 .await
5175 .ok()?;
5176 if !diff.ok() {
5177 return None;
5178 }
5179 let head_runs = ci_runs(repo, &seen.head).await?;
5180 let base_runs = ci_runs(repo, &base.stdout).await?;
5181 let mut checks = Vec::new();
5182 for label in &seen.pr.failing {
5183 let matching = |runs: &[serde_json::Value]| {
5184 runs.iter()
5185 .filter(|r| r["name"].as_str() == Some(label))
5186 .cloned()
5187 .collect::<Vec<_>>()
5188 };
5189 let heads = matching(&head_runs);
5190 let bases = matching(&base_runs);
5191 if heads.len() != 1 || bases.len() != 1 {
5192 return None;
5193 }
5194 let h = &heads[0];
5195 let b = &bases[0];
5196 if h["conclusion"] != "failure"
5197 || b["conclusion"] != "failure"
5198 || h["status"] != "completed"
5199 || b["status"] != "completed"
5200 || h["head_sha"].as_str()? != seen.head
5201 || b["head_sha"].as_str()? != base.stdout
5202 || h.pointer("/app/id")?.as_u64()? != b.pointer("/app/id")?.as_u64()?
5203 {
5204 return None;
5205 }
5206 let run = run_of(h["details_url"].as_str()?)?;
5207 let contexts: Vec<_> = seen.contexts.iter().filter(|c| &c.label == label).collect();
5208 if contexts.len() != 1
5209 || contexts[0].run.as_ref() != Some(&run)
5210 || contexts[0].verdict != Verdict::Fail
5211 {
5212 return None;
5213 }
5214 let workflow =
5217 ci_json(repo, format!("repos/{{owner}}/{{repo}}/actions/runs/{run}")).await?;
5218 let base_run = run_of(b["details_url"].as_str()?)?;
5219 let base_workflow = ci_json(
5220 repo,
5221 format!("repos/{{owner}}/{{repo}}/actions/runs/{base_run}"),
5222 )
5223 .await?;
5224 if base_workflow["head_sha"].as_str()? != base.stdout
5225 || base_workflow["head_branch"].as_str()? != seen.base
5226 || base_workflow["status"] != "completed"
5227 || base_workflow["conclusion"] != "failure"
5228 || base_workflow["workflow_id"].as_u64()? != workflow["workflow_id"].as_u64()?
5229 {
5230 return None;
5231 }
5232 let exhausted = state
5233 .land_reruns
5234 .as_ref()
5235 .is_some_and(|r| r.head == seen.head && r.runs.contains_key(&run))
5236 && workflow["head_sha"].as_str()? == seen.head
5237 && workflow["run_attempt"].as_u64()? > 1
5238 && workflow["status"] == "completed";
5239 checks.push(CiCheckEvidence {
5240 label: label.clone(),
5241 head_check: h["id"].as_u64()?,
5242 base_check: b["id"].as_u64()?,
5243 app: h.pointer("/app/id")?.as_u64()?,
5244 run,
5245 base_run,
5246 head_failures: ci_annotations(repo, h).await?,
5247 base_failures: ci_annotations(repo, b).await?,
5248 rerun_exhausted: exhausted,
5249 });
5250 }
5251 Some(CiEvidence {
5252 head: seen.head.clone(),
5253 base: base.stdout,
5254 changed: diff
5255 .stdout
5256 .split('\0')
5257 .filter(|p| !p.is_empty())
5258 .map(str::to_owned)
5259 .collect(),
5260 checks,
5261 })
5262}
5263
5264async fn hold_unrelated(state: &mut RunState, pr: &PrState, evidence: CiEvidence) -> Result<()> {
5265 let detail = serde_json::to_string(&evidence)?;
5266 let resume = "daemon: CI を確認して同じ run を再開する".to_owned();
5267 let hold = "operator: 保留して後で判断する".to_owned();
5268 let mut q = ask::Question::new(
5269 state.id.clone(),
5270 CI_HOLD_NODE.into(),
5271 "land".into(),
5272 "base でも再現する CI の失敗を検出したため、試行を消費せず保留しました".into(),
5273 format!(
5274 "必須チェックや承認は省略しません。再開時にも同じ workflow run は自動再実行しません。\n構造化された判定根拠:\n{detail}"
5275 ),
5276 vec![resume.clone(), hold.clone()],
5277 );
5278 q.actions.insert(
5279 resume,
5280 ask::ChoiceAction::Resume {
5281 run: state.id.clone(),
5282 },
5283 );
5284 q.actions.insert(hold, ask::ChoiceAction::Hold);
5285 ask::Questions::open().put_executable(&mut q)?;
5286 state.land_unrelated = Some(CiHold {
5287 head: evidence.head.clone(),
5288 question: q.id.clone(),
5289 evidence,
5290 });
5291 state.event("land", format!("[ci-unrelated] base reproduces every located failure after rerun; owner question {}: {detail}", q.id));
5292 state.end_pass_parked(crate::run::ParkCause::Question);
5293 state.parked = true;
5294 state.save()?;
5295 if let Err(e) = ask::notify(&state.config.notify, &q).await {
5296 tracing::warn!("could not notify about CI hold: {e:#}");
5297 }
5298 let body = stop_comment(
5300 &state.id,
5301 "CI failures independently reproduced on the base; awaiting an owner decision without charging the task attempt",
5302 );
5303 let (_, body) = crate::github_text::prepare(state, "", &body);
5304 gh(
5305 &state.repo,
5306 &[
5307 "pr".into(),
5308 "comment".into(),
5309 pr.number.to_string(),
5310 "--body".into(),
5311 body,
5312 ],
5313 )
5314 .await
5315 .ok();
5316 Ok(())
5317}
5318
5319#[derive(Clone)]
5323struct Seen {
5324 pr: PrState,
5325 title: String,
5326 failing_urls: Vec<(String, String)>,
5327 head: String,
5329 rollup_head: String,
5332 merge_state: String,
5335 contexts: Vec<CheckInfo>,
5337 base: String,
5339}
5340
5341#[derive(Clone)]
5343struct CheckInfo {
5344 label: String,
5345 verdict: Verdict,
5346 required: Option<bool>,
5347 run: Option<String>,
5349}
5350
5351#[derive(Debug, Clone, PartialEq, Eq)]
5353enum RerunStep {
5354 Rerun(Vec<String>),
5356 Grace,
5358 Fix,
5360}
5361
5362#[cfg(test)]
5366fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
5367 rerunnable_with(contexts, false)
5368}
5369
5370fn rerunnable_with(
5371 contexts: &[CheckInfo],
5372 include_optional: bool,
5373) -> Vec<(String, Option<String>)> {
5374 contexts
5375 .iter()
5376 .filter(|c| c.verdict == Verdict::Fail && (include_optional || c.required != Some(false)))
5377 .filter(|c| {
5379 c.run.as_ref().is_none_or(|r| {
5380 !contexts.iter().any(|o| {
5381 matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
5382 && o.run.as_ref() == Some(r)
5383 })
5384 })
5385 })
5386 .map(|c| (c.label.clone(), c.run.clone()))
5387 .collect()
5388}
5389
5390fn rerun_step(
5392 failing: &[(String, Option<String>)],
5393 record: Option<&LandReruns>,
5394 head: &str,
5395 now: i64,
5396) -> RerunStep {
5397 if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
5400 return RerunStep::Fix;
5401 }
5402 let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
5404 let mut fresh: Vec<String> = Vec::new();
5405 let mut recent = false;
5406 for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
5407 match record.and_then(|r| r.runs.get(run)) {
5408 None => {
5409 if !fresh.iter().any(|f| f == run) {
5410 fresh.push(run.to_owned());
5411 }
5412 }
5413 Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
5414 Some(_) => {}
5415 }
5416 }
5417 if !fresh.is_empty() {
5418 RerunStep::Rerun(fresh)
5419 } else if recent {
5420 RerunStep::Grace
5421 } else {
5422 RerunStep::Fix
5423 }
5424}
5425
5426async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
5430 let view = gh(
5431 repo,
5432 &[
5433 "pr".to_owned(),
5434 "view".to_owned(),
5435 pr_url.to_owned(),
5436 "--json".to_owned(),
5437 "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
5438 .to_owned(),
5439 ],
5440 )
5441 .await?;
5442 if !view.0 {
5443 bail!("gh pr view {pr_url}: {}", view.1);
5444 }
5445 let number = parse_pr(&view.1)?.number;
5446 let node = last_commit_node(repo, number).await;
5447 let mut seen = seen_from(&view.1, node.as_deref())?;
5448
5449 let inline = gh(
5450 repo,
5451 &[
5452 "api".to_owned(),
5453 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
5454 ],
5455 )
5456 .await?;
5457 if inline.0 {
5458 match parse_inline_comments(&inline.1) {
5459 Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
5460 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
5463 }
5464 } else {
5465 tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
5466 }
5467 Ok(seen)
5468}
5469
5470fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
5481 let mut pr = parse_pr(view_json)?;
5482 let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
5483
5484 let mut rollup_head = String::new();
5485 let mut failing_urls = Vec::new();
5486 let mut contexts = Vec::new();
5487 let mut checks = Checks::Unknown;
5488 let mut failing = Vec::new();
5489 if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
5490 (checks, failing) = rollup_verdict(&rollup);
5491 failing_urls = rollup
5492 .iter()
5493 .filter(|c| c.verdict() == Verdict::Fail)
5494 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
5495 .collect();
5496 contexts = rollup
5497 .iter()
5498 .map(|c| CheckInfo {
5499 label: c.label(),
5500 verdict: c.verdict(),
5501 required: c.is_required,
5502 run: c.url().and_then(run_of),
5503 })
5504 .collect();
5505 rollup_head = oid;
5506 }
5507 pr.checks = checks;
5508 pr.failing = failing;
5509
5510 Ok(Seen {
5511 pr,
5512 title: raw.title,
5513 failing_urls,
5514 head: raw.head_ref_oid,
5515 rollup_head,
5516 merge_state: raw.merge_state_status,
5517 contexts,
5518 base: raw.base_ref_name,
5519 })
5520}
5521
5522fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
5525 let v: serde_json::Value = serde_json::from_str(json).ok()?;
5526 if v.get("errors").is_some_and(|e| !e.is_null()) {
5527 return None;
5528 }
5529 let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
5530 let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
5531 let contexts = commit.pointer("/statusCheckRollup/contexts");
5532 let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
5533 return Some((oid.to_owned(), Vec::new()));
5535 };
5536 if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
5537 return None;
5538 }
5539 let nodes = contexts.get("nodes")?.as_array()?;
5540 let rollup = nodes
5541 .iter()
5542 .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
5543 .collect::<Result<Vec<_>, _>>()
5544 .ok()?;
5545 Some((oid.to_owned(), rollup))
5546}
5547
5548async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
5551 let out = gh(
5552 repo,
5553 &[
5554 "api".to_owned(),
5555 "graphql".to_owned(),
5556 "-F".to_owned(),
5557 "owner={owner}".to_owned(),
5558 "-F".to_owned(),
5559 "repo={repo}".to_owned(),
5560 "-F".to_owned(),
5561 format!("number={number}"),
5562 "-f".to_owned(),
5563 "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
5564 name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
5565 statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
5566 ... on CheckRun{name status conclusion detailsUrl \
5567 isRequired(pullRequestNumber:$number)} \
5568 ... on StatusContext{context state targetUrl \
5569 isRequired(pullRequestNumber:$number)}}}}}}}}}}"
5570 .to_owned(),
5571 ],
5572 )
5573 .await
5574 .ok()?;
5575 out.0.then_some(out.1)
5576}
5577
5578#[doc(hidden)]
5580#[derive(Debug, PartialEq)]
5581pub enum Fixed {
5582 Committed {
5584 head: String,
5586 },
5587 Declined,
5589 Failed(String),
5591}
5592
5593#[doc(hidden)]
5599pub async fn fix_round(
5600 state: &mut RunState,
5601 pr: &PrState,
5602 round: usize,
5603 budget: usize,
5604 reason: &str,
5605 logs: &str,
5606) -> Result<Fixed> {
5607 let winner = state
5608 .winner()
5609 .cloned()
5610 .context("landing needs a winning candidate; none is recorded on this run")?;
5611 let roles = state
5612 .config
5613 .resolve_roles()
5614 .context("resolve the roster for the fix round")?;
5615 let attempts = crate::fixer::attempts(state, &roles, &winner);
5619 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5620
5621 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
5622 let mut artifacts = agent::artifacts_dir(&state.dir());
5623 if let Some(request) = state
5624 .merge_repairs
5625 .last()
5626 .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
5627 {
5628 artifacts = artifacts.join(format!("merge-repair-{}", request.question));
5629 }
5630 let prompt = if state.config.cache_dir().is_some() {
5631 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
5632 } else {
5633 prompt
5634 };
5635 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5639 let mut last = None;
5643 for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
5644 if owner_requested_stop(state)? {
5645 return Ok(Fixed::Declined);
5646 }
5647 let mut seat = seat_of(state, &seat_key, &spec.id);
5648 let stem = if i == 0 {
5649 format!("land-{round}")
5650 } else {
5651 format!("land-{round}-{}", spec.id)
5652 };
5653 let out = agent::invoke(
5654 &spec,
5655 &mut seat,
5656 &Invocation {
5657 scope: crate::agent::InvocationScope::Repository,
5658 coordination: state.config.coordination.as_ref(),
5659 fetched_base: state.fetched_base.as_ref(),
5660 cwd: &winner.worktree,
5661 prompt: &prompt,
5662 timeout: Duration::from_secs(state.config.graph.timeout_fix),
5663 allow_write: true,
5664 unsandboxed: false,
5665 sessions: state.config.graph.sessions,
5666 artifacts: &artifacts,
5667 stem: &stem,
5668 run: &state.id,
5669 node: "land",
5670 cache_dir: state.config.cache_dir().as_deref(),
5671 attachments: &[],
5672 writable: &[],
5673 },
5674 )
5675 .await;
5676 state.seats.insert(seat.key.clone(), seat);
5677 if let Some(next) = ids.get(i + 1)
5678 && agent::chain_advances(&out)
5679 {
5680 let (class, why) = crate::fixer::failure_of(&out);
5681 crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
5682 continue;
5683 }
5684 last = Some(out);
5685 break;
5686 }
5687 let out = last.expect("the fixer chain always has an entry");
5688
5689 match out {
5690 Ok(o) if o.quota_exhausted() => {
5691 return Ok(Fixed::Failed(
5692 "rate limited (quota); the fixer could not run".to_owned(),
5693 ));
5694 }
5695 Ok(o) if !o.usable() => {
5696 return Ok(Fixed::Failed(format!(
5697 "the fixer produced nothing usable ({})",
5698 crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
5699 )));
5700 }
5701 Ok(_) => {}
5702 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
5703 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
5704 }
5705
5706 if owner_requested_stop(state)? {
5707 return Ok(Fixed::Declined);
5708 }
5709
5710 let wait = crate::repo_lock::WaitPolicy::new(
5713 Duration::from_secs(state.config.graph.timeout_resource_wait),
5714 || false,
5715 );
5716 let coordination = crate::repo_lock::SeatLease::operation_with(
5717 &winner.worktree,
5718 state.config.coordination.as_ref(),
5719 &wait,
5720 )
5721 .await?;
5722 match git::rescue_commit(
5723 &winner.worktree,
5724 &format!("magi: land round {round} fixes (uncommitted work)"),
5725 )
5726 .await
5727 {
5728 Ok(r) => state.note_withheld("land", &r.withheld),
5729 Err(e)
5730 if state
5731 .merge_repairs
5732 .last()
5733 .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
5734 {
5735 return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
5736 }
5737 Err(_) => {}
5738 }
5739 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5740 drop(coordination);
5741 if after == before {
5742 return Ok(Fixed::Declined);
5743 }
5744
5745 if owner_requested_stop(state)? {
5746 return Ok(Fixed::Declined);
5747 }
5748 crate::pr_head::ensure_owner(state)?;
5749 let remote = state.config.merge.remote.clone();
5750 let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
5751 let push = if state.pr_head.is_some() {
5752 let expected = state
5753 .pr_pushed_head
5754 .as_deref()
5755 .context("missing public head lease")?;
5756 git::push_as(
5757 &winner.worktree,
5758 &remote,
5759 &winner.branch,
5760 &public_head,
5761 expected,
5762 )
5763 .await?
5764 } else {
5765 git::push(&winner.worktree, &remote, &winner.branch).await?
5766 };
5767 if !push.ok() {
5768 return Ok(Fixed::Failed(format!(
5769 "pushing {} to {remote} failed: {}",
5770 public_head, push.stderr
5771 )));
5772 }
5773 state.event(
5774 "land",
5775 format!("round {round}: pushed a fix to {public_head}"),
5776 );
5777 state.pr_pushed_head = Some(after.clone());
5778 state.save()?;
5779 Ok(Fixed::Committed { head: after })
5780}
5781
5782pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
5784 crate::fixer::seat_for(state, key, agent)
5785}
5786
5787pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
5790 let seen = observe(&state.repo, url).await?;
5791 let winner = state.winner().context("no winning branch to repair")?;
5792 if seen.pr.state != PrLifecycle::Open || seen.head != head {
5793 anyhow::bail!(
5794 "PR is closed or its head changed; review the current head before requesting repair again"
5795 );
5796 }
5797 if git::rev_parse(&winner.worktree, "HEAD").await? != head
5798 || !git::is_clean(&winner.worktree).await?
5799 {
5800 anyhow::bail!(
5801 "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
5802 );
5803 }
5804 let view = gh(
5807 &state.repo,
5808 &[
5809 "pr".into(),
5810 "view".into(),
5811 url.into(),
5812 "--json".into(),
5813 "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
5814 ],
5815 )
5816 .await?;
5817 if !view.0 {
5818 bail!("cannot check auto-merge: {}", view.1);
5819 }
5820 let live: serde_json::Value = serde_json::from_str(&view.1)?;
5821 let public = state.pr_branch().unwrap_or(&winner.branch);
5822 if live["state"] != "OPEN"
5823 || live["headRefOid"] != head
5824 || live["headRefName"] != public
5825 || live["baseRefName"] != state.base_branch
5826 || live["isCrossRepository"] != false
5827 {
5828 bail!("PR changed during repair preflight; review its current head explicitly");
5829 }
5830 let auto_merge = live
5831 .get("autoMergeRequest")
5832 .context("cannot read the PR auto-merge reservation; repair held")?;
5833 if !auto_merge.is_null() {
5834 let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
5835 if !out.0 {
5836 bail!("cannot disable auto-merge; repair held: {}", out.1);
5837 }
5838 }
5839 Ok(seen.pr)
5840}
5841
5842fn fix_prompt(
5844 state: &RunState,
5845 pr: &PrState,
5846 round: usize,
5847 budget: usize,
5848 reason: &str,
5849 logs: &str,
5850) -> String {
5851 let mut s = format!(
5852 "Your patch is open as a pull request and it is not landing. Land round \
5853 {round} of {budget}.\n\n\
5854 Pull request: {}\n\n\
5855 What is holding it: {reason}\n\n\
5856 # The task\n\n{}\n",
5857 pr.url, state.instruction
5858 );
5859
5860 if pr.failing.is_empty() {
5861 s.push_str("\n# Failing checks\n\n(none)\n");
5862 } else {
5863 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
5864 if logs.trim().is_empty() {
5865 s.push_str(
5866 "\nNo log could be read; investigate the failure with necessary focused checks.\n",
5867 );
5868 } else {
5869 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
5870 }
5871 }
5872
5873 if pr.review_comments.is_empty() {
5874 s.push_str("\n# Review comments\n\n(none)\n");
5875 } else {
5876 s.push_str("\n# Review comments\n");
5877 for c in &pr.review_comments {
5878 let where_ = match (&c.path, c.line) {
5879 (Some(p), Some(l)) => format!(" ({p}:{l})"),
5880 (Some(p), None) => format!(" ({p})"),
5881 _ => String::new(),
5882 };
5883 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
5884 }
5885 }
5886
5887 s.push_str(
5888 "\n# Rules\n\n\
5889 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
5890 failing test; do not silence a lint with an allow attribute; do not \
5891 stretch a timeout to hide a race. If the check is right, the code is \
5892 wrong.\n\
5893 2. Change nothing the checks and the comments did not raise. A \
5894 drive-by refactor turns a one-line fix into a pull request that \
5895 needs reviewing again.\n\
5896 3. If a comment is wrong, say so with a checkable argument and change \
5897 nothing for it. A declined comment with a reason is a correct \
5898 outcome; a change made to appease a reviewer is not.\n\
5899 4. Commit in this worktree. magi pushes to the pull request's branch \
5900 for you; do not push, merge, or close anything yourself.\n\
5901 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
5902 # Output\n\n\
5903 Say what you changed and why, and what you declined and why.",
5904 );
5905
5906 let language = &state.config.graph.language;
5907 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
5908 let _ = write!(s, "\n\nWrite all prose in {language}.");
5909 }
5910 s.push_str(&crate::prompt::github_rule(
5912 language,
5913 &state.config.graph.github_language,
5914 ));
5915 if let Some(overlay) = state.config.prompts.overlay("fix") {
5916 let _ = write!(s, "\n\n{overlay}");
5917 }
5918 s.push('\n');
5919 s.push_str(crate::prompt::child_run_ownership());
5920 s
5921}
5922
5923async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
5926 let mut out = String::new();
5927 for (name, url) in failing.iter().take(MAX_LOGS) {
5928 let args = match (job_of(url), run_of(url)) {
5929 (Some(job), _) => vec![
5930 "run".to_owned(),
5931 "view".to_owned(),
5932 "--log-failed".to_owned(),
5933 "--job".to_owned(),
5934 job,
5935 ],
5936 (None, Some(run)) => vec![
5937 "run".to_owned(),
5938 "view".to_owned(),
5939 run,
5940 "--log-failed".to_owned(),
5941 ],
5942 (None, None) => continue,
5944 };
5945 let (ok, body) = match gh(repo, &args).await {
5946 Ok(v) => v,
5947 Err(e) => (false, format!("{e:#}")),
5948 };
5949 if !ok && body.trim().is_empty() {
5950 continue;
5951 }
5952 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
5953 }
5954 out
5955}
5956
5957fn job_of(details_url: &str) -> Option<String> {
5960 let after = details_url.split("/job/").nth(1)?;
5961 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5962 (!id.is_empty()).then_some(id)
5963}
5964
5965pub(crate) fn run_of(details_url: &str) -> Option<String> {
5967 let after = details_url.split("/actions/runs/").nth(1)?;
5968 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5969 (!id.is_empty()).then_some(id)
5970}
5971
5972fn stop_comment(run_id: &str, why: &str) -> String {
5976 format!(
5977 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
5978 The branch is untouched and the run is `{run_id}`. Nothing was merged."
5979 )
5980}
5981
5982async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
5987 let body = stop_comment(&state.id, why);
5988 let (_, mut body) = crate::github_text::prepare(state, "", &body);
5989 if !body.contains(MARKER) {
5990 body = format!("{MARKER}\n{body}");
5991 }
5992 let posted = gh(
5993 repo,
5994 &[
5995 "pr".to_owned(),
5996 "comment".to_owned(),
5997 pr.number.to_string(),
5998 "--body".to_owned(),
5999 body,
6000 ],
6001 )
6002 .await;
6003 match posted {
6004 Ok((true, _)) => {}
6005 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
6006 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
6007 }
6008 state.status = RunStatus::Blocked;
6009 state.merge = Some(MergeOutcome {
6010 mode: MergeMode::Pr,
6011 ok: false,
6012 detail: why.to_owned(),
6013 empty: false,
6014 });
6015 state.event("land", format!("stopped: {why}"));
6016 state.save()?;
6017 Ok(())
6018}
6019
6020pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
6036 let out = tokio::process::Command::new("gh")
6037 .args(args)
6038 .current_dir(cwd)
6039 .env_remove("GH_REPO")
6040 .quiet()
6041 .stdin(std::process::Stdio::null())
6042 .output()
6043 .await
6044 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
6045 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
6046 let err = String::from_utf8_lossy(&out.stderr);
6047 if body.trim().is_empty() {
6048 body = err.into_owned();
6049 } else if !err.trim().is_empty() {
6050 body.push_str(&err);
6051 }
6052 Ok((out.status.success(), body.trim().to_owned()))
6053}
6054
6055#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6057pub(crate) enum Verdict {
6058 Pass,
6059 Fail,
6060 Pending,
6061 Unknown,
6062}
6063
6064impl Verdict {
6065 pub(crate) fn label(self) -> &'static str {
6067 match self {
6068 Self::Pass => "passed",
6069 Self::Fail => "failed",
6070 Self::Pending => "pending",
6071 Self::Unknown => "unknown",
6072 }
6073 }
6074}
6075
6076#[derive(Debug, Deserialize)]
6077#[serde(rename_all = "camelCase")]
6078struct GhPr {
6079 #[serde(default)]
6080 url: String,
6081 #[serde(default)]
6082 number: u64,
6083 #[serde(default)]
6084 state: String,
6085 #[serde(default)]
6086 title: String,
6087 #[serde(default)]
6088 status_check_rollup: Vec<GhCheck>,
6089 #[serde(default)]
6096 merge_state_status: String,
6097 #[serde(default)]
6101 head_ref_oid: String,
6102 #[serde(default)]
6103 base_ref_name: String,
6104 #[serde(default)]
6105 reviews: Vec<GhReview>,
6106 #[serde(default)]
6107 comments: Vec<GhComment>,
6108}
6109
6110#[derive(Debug, Deserialize)]
6115#[serde(rename_all = "camelCase")]
6116struct GhCheck {
6117 #[serde(default)]
6118 name: Option<String>,
6119 #[serde(default)]
6120 context: Option<String>,
6121 #[serde(default)]
6122 status: Option<String>,
6123 #[serde(default)]
6124 conclusion: Option<String>,
6125 #[serde(default)]
6126 state: Option<String>,
6127 #[serde(default)]
6128 details_url: Option<String>,
6129 #[serde(default)]
6130 target_url: Option<String>,
6131 #[serde(default)]
6134 is_required: Option<bool>,
6135}
6136
6137impl GhCheck {
6138 fn label(&self) -> String {
6140 self.name
6141 .clone()
6142 .or_else(|| self.context.clone())
6143 .unwrap_or_else(|| "(unnamed check)".to_owned())
6144 }
6145
6146 fn url(&self) -> Option<&str> {
6148 self.details_url
6149 .as_deref()
6150 .or(self.target_url.as_deref())
6151 .filter(|u| !u.is_empty())
6152 }
6153
6154 fn verdict(&self) -> Verdict {
6162 if let Some(status) = self.status.as_deref() {
6163 if !status.eq_ignore_ascii_case("COMPLETED") {
6164 return Verdict::Pending;
6165 }
6166 }
6167 let outcome = self
6168 .conclusion
6169 .as_deref()
6170 .or(self.state.as_deref())
6171 .unwrap_or("");
6172 match outcome.to_ascii_uppercase().as_str() {
6173 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
6174 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
6175 | "ACTION_REQUIRED" => Verdict::Fail,
6176 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
6177 Verdict::Pending
6178 }
6179 _ => Verdict::Unknown,
6180 }
6181 }
6182}
6183
6184#[derive(Debug, Deserialize)]
6185struct GhAuthor {
6186 #[serde(default)]
6187 login: String,
6188}
6189
6190#[derive(Debug, Deserialize)]
6191struct GhReview {
6192 #[serde(default)]
6193 author: GhAuthor,
6194 #[serde(default)]
6195 body: String,
6196}
6197
6198#[derive(Debug, Deserialize)]
6199struct GhComment {
6200 #[serde(default)]
6201 author: GhAuthor,
6202 #[serde(default)]
6203 body: String,
6204}
6205
6206#[derive(Debug, Deserialize)]
6207struct GhUser {
6208 #[serde(default)]
6209 login: String,
6210}
6211
6212#[derive(Debug, Deserialize)]
6213struct GhInline {
6214 #[serde(default)]
6215 user: GhUser,
6216 #[serde(default)]
6217 path: Option<String>,
6218 #[serde(default)]
6219 line: Option<u64>,
6220 #[serde(default)]
6221 body: String,
6222}
6223
6224impl Default for GhAuthor {
6225 fn default() -> Self {
6226 Self {
6227 login: "(unknown)".to_owned(),
6228 }
6229 }
6230}
6231
6232impl Default for GhUser {
6233 fn default() -> Self {
6234 Self {
6235 login: "(unknown)".to_owned(),
6236 }
6237 }
6238}
6239
6240#[cfg(test)]
6241pub(crate) mod tests {
6242 use super::*;
6243 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
6244
6245 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
6246 format!(
6247 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
6248 )
6249 }
6250
6251 #[test]
6252 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
6253 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
6254 assert_eq!(
6255 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
6256 Ok(())
6257 );
6258 for (json, why) in [
6259 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
6260 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
6261 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
6262 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
6263 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
6264 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
6265 ] {
6266 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
6267 .unwrap_err()
6268 .why;
6269 assert!(err.contains(why), "{json}: {err}");
6270 }
6271 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
6273 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
6274 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
6275 assert!(
6276 !closable(
6277 &head_json("x", "main", "OPEN", false),
6278 "magi/27b2/A",
6279 "main",
6280 &[]
6281 )
6282 .unwrap_err()
6283 .retry
6284 );
6285 assert!(is_forge_url("https://github.com/o/r.git"));
6286 assert!(is_forge_url("git@github.com:o/r.git"));
6287 assert!(!is_forge_url("/tmp/origin.git"));
6288 assert!(!is_forge_url("C:\\work\\origin.git"));
6289 assert!(!is_forge_url("file:///tmp/origin.git"));
6290 assert!(forge_unavailable(
6291 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
6292 ));
6293 assert!(!forge_unavailable(
6294 "gh pr list failed: error connecting to api.github.com"
6295 ));
6296 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
6297 assert!(
6299 closable(
6300 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
6301 "b",
6302 "main",
6303 &["aaa".to_owned()]
6304 )
6305 .is_err()
6306 );
6307 }
6308
6309 #[test]
6310 fn the_close_comment_names_the_commit_on_the_base() {
6311 let e = crate::already::Evidence {
6312 proof: crate::already::Proof::PatchId,
6313 tip: "1234567890".to_owned(),
6314 commits: vec!["0e368de0000".to_owned()],
6315 };
6316 let c = superseded_comment("main", &e);
6317 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
6318 }
6319
6320 const GREEN_OPEN: &str = r####"{
6322 "url": "https://github.com/yukimemi/magi/pull/10",
6323 "number": 10,
6324 "state": "OPEN",
6325 "mergeStateStatus": "CLEAN",
6326 "statusCheckRollup": [
6327 {
6328 "__typename": "CheckRun",
6329 "conclusion": "SKIPPED",
6330 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
6331 "name": "review",
6332 "status": "COMPLETED",
6333 "workflowName": "claude-review"
6334 },
6335 {
6336 "__typename": "CheckRun",
6337 "conclusion": "SUCCESS",
6338 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
6339 "name": "check (ubuntu-latest)",
6340 "status": "COMPLETED",
6341 "workflowName": "CI"
6342 },
6343 {
6344 "__typename": "CheckRun",
6345 "conclusion": "SUCCESS",
6346 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
6347 "name": "rustfmt",
6348 "status": "COMPLETED",
6349 "workflowName": "CI"
6350 },
6351 {
6352 "__typename": "StatusContext",
6353 "context": "CodeRabbit",
6354 "state": "SUCCESS",
6355 "targetUrl": ""
6356 }
6357 ],
6358 "reviews": [],
6359 "comments": [
6360 {
6361 "author": {
6362 "login": "coderabbitai"
6363 },
6364 "authorAssociation": "NONE",
6365 "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"
6366 }
6367 ]
6368}"####;
6369
6370 const RED_OPEN: &str = r####"{
6372 "url": "https://github.com/yukimemi/magi/pull/9",
6373 "number": 9,
6374 "state": "OPEN",
6375 "mergeStateStatus": "UNSTABLE",
6376 "statusCheckRollup": [
6377 {
6378 "__typename": "CheckRun",
6379 "conclusion": "SUCCESS",
6380 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6381 "name": "check (ubuntu-latest)",
6382 "status": "COMPLETED",
6383 "workflowName": "CI"
6384 },
6385 {
6386 "__typename": "CheckRun",
6387 "conclusion": "SUCCESS",
6388 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6389 "name": "rustfmt",
6390 "status": "COMPLETED",
6391 "workflowName": "CI"
6392 },
6393 {
6394 "__typename": "CheckRun",
6395 "conclusion": "FAILURE",
6396 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6397 "name": "editorconfig",
6398 "status": "COMPLETED",
6399 "workflowName": "CI"
6400 },
6401 {
6402 "__typename": "StatusContext",
6403 "context": "CodeRabbit",
6404 "state": "SUCCESS",
6405 "targetUrl": ""
6406 }
6407 ],
6408 "reviews": [],
6409 "comments": [
6410 {
6411 "author": {
6412 "login": "coderabbitai"
6413 },
6414 "authorAssociation": "NONE",
6415 "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"
6416 }
6417 ]
6418}"####;
6419
6420 const PENDING_OPEN: &str = r####"{
6422 "url": "https://github.com/yukimemi/magi/pull/9",
6423 "number": 9,
6424 "state": "OPEN",
6425 "statusCheckRollup": [
6426 {
6427 "__typename": "CheckRun",
6428 "conclusion": "SUCCESS",
6429 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6430 "name": "check (ubuntu-latest)",
6431 "status": "COMPLETED",
6432 "workflowName": "CI"
6433 },
6434 {
6435 "__typename": "CheckRun",
6436 "conclusion": "SUCCESS",
6437 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6438 "name": "rustfmt",
6439 "status": "COMPLETED",
6440 "workflowName": "CI"
6441 },
6442 {
6443 "__typename": "CheckRun",
6444 "conclusion": null,
6445 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6446 "name": "editorconfig",
6447 "status": "IN_PROGRESS",
6448 "workflowName": "CI"
6449 },
6450 {
6451 "__typename": "StatusContext",
6452 "context": "CodeRabbit",
6453 "state": "SUCCESS",
6454 "targetUrl": ""
6455 }
6456 ],
6457 "reviews": [],
6458 "comments": []
6459}"####;
6460
6461 const MERGED: &str = r####"{
6463 "url": "https://github.com/yukimemi/magi/pull/16",
6464 "number": 16,
6465 "state": "MERGED",
6466 "statusCheckRollup": [
6467 {
6468 "__typename": "CheckRun",
6469 "conclusion": "SUCCESS",
6470 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
6471 "name": "check (ubuntu-latest)",
6472 "status": "COMPLETED",
6473 "workflowName": "CI"
6474 },
6475 {
6476 "__typename": "CheckRun",
6477 "conclusion": "SUCCESS",
6478 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
6479 "name": "review",
6480 "status": "COMPLETED",
6481 "workflowName": "claude-review"
6482 }
6483 ],
6484 "reviews": [],
6485 "comments": []
6486}"####;
6487
6488 const REVIEWED_OPEN: &str = r####"{
6490 "url": "https://github.com/yukimemi/magi/pull/12",
6491 "number": 12,
6492 "state": "OPEN",
6493 "statusCheckRollup": [
6494 {
6495 "__typename": "CheckRun",
6496 "conclusion": "SUCCESS",
6497 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
6498 "name": "check (ubuntu-latest)",
6499 "status": "COMPLETED",
6500 "workflowName": "CI"
6501 },
6502 {
6503 "__typename": "CheckRun",
6504 "conclusion": "SUCCESS",
6505 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
6506 "name": "review",
6507 "status": "COMPLETED",
6508 "workflowName": "claude-review"
6509 }
6510 ],
6511 "reviews": [
6512 {
6513 "author": {
6514 "login": "claude"
6515 },
6516 "state": "COMMENTED",
6517 "body": ""
6518 }
6519 ],
6520 "comments": [
6521 {
6522 "author": {
6523 "login": "coderabbitai"
6524 },
6525 "authorAssociation": "NONE",
6526 "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"
6527 },
6528 {
6529 "author": {
6530 "login": "claude"
6531 },
6532 "authorAssociation": "NONE",
6533 "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"
6534 }
6535 ]
6536}"####;
6537
6538 const INLINE: &str = r####"[
6540 {
6541 "user": {
6542 "login": "claude[bot]"
6543 },
6544 "path": "src/graph.rs",
6545 "line": 231,
6546 "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"
6547 }
6548]"####;
6549
6550 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
6552<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
6553
6554> [!IMPORTANT]
6555> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
6556>
6557> This repository does not receive automatic reviews because it has fewer than 10 stars.
6558>
6559> <details>
6560> <summary>⚙️ Run configuration</summary>
6561>
6562> **Configuration used**: defaults
6563>
6564> **Review profile**: CHILL
6565>
6566> **Plan**: Team
6567>
6568> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
6569>
6570> </details>
6571
6572<!-- end of auto-generated comment: skip review by coderabbit.ai -->
6573
6574<!-- tips_start -->
6575
6576---
6577
6578Thanks 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.
6579
6580<details>
6581<summary>❤️ Share</summary>
6582
6583- [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"####;
6584
6585 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
6587
6588---
6589### Reviewing PR #16
6590
6591- [x] Read AGENTS.md conventions
6592- [x] Review `src/daemon.rs` changes
6593- [x] Review `src/main.rs` changes (new `doctor` reporting)
6594- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
6595- [x] Check test coverage for new behavior
6596- [x] Run verification commands (blocked — see note)
6597- [x] Post findings"####;
6598
6599 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
6601
6602---
6603### Review: `magi review <branch>` — cheap-half-only graph
6604
6605Read 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.
6606
6607**Correctness**
6608
6609- 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"####;
6610
6611 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
6612 PrState {
6613 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
6614 number: 16,
6615 state: PrLifecycle::Open,
6616 checks,
6617 blocking: if matches!(checks, Checks::Red) {
6621 Blocking::Yes
6622 } else {
6623 Blocking::No
6624 },
6625 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
6626 review_comments: (0..comments)
6627 .map(|i| ReviewComment {
6628 author: "coderabbitai".to_owned(),
6629 path: Some("src/graph.rs".to_owned()),
6630 line: Some(231),
6631 body: format!("finding {i}"),
6632 })
6633 .collect(),
6634 }
6635 }
6636
6637 #[test]
6638 fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
6639 use CiExpectation::{Absent, Expected};
6640 for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
6641 let p = pr(checks, &["x"], 0);
6642 for waited in [Duration::ZERO, CHECKS_GRACE] {
6643 assert_eq!(
6644 decide_with(&p, 0, 4, waited, Expected),
6645 decide(&p, 0, 4, waited)
6646 );
6647 }
6648 }
6649 for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
6651 let p = pr(checks, &["x"], 0);
6652 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
6653 assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
6654 }
6655 for comments in [0, 1] {
6657 for round in [0, 4] {
6658 let absent = decide_with(
6659 &pr(Checks::Unknown, &[], comments),
6660 round,
6661 4,
6662 CHECKS_GRACE,
6663 Absent,
6664 );
6665 for checks in [Checks::Green, Checks::Red, Checks::Pending] {
6666 assert_eq!(
6667 decide_with(
6668 &pr(checks, &["third-party"], comments),
6669 round,
6670 4,
6671 Duration::ZERO,
6672 Absent
6673 ),
6674 absent
6675 );
6676 }
6677 if comments != 0 {
6678 assert!(matches!(absent, Step::Fix { .. } | Step::GiveUp { .. }));
6679 assert_eq!(matches!(absent, Step::GiveUp { .. }), round == 4);
6680 }
6681 }
6682 }
6683 let empty = pr(Checks::Unknown, &[], 0);
6684 for waited in [Duration::ZERO, CHECKS_GRACE + POLL] {
6685 assert_eq!(decide_with(&empty, 0, 4, waited, Absent), Step::Merge);
6686 }
6687 assert_eq!(
6688 decide_with(&empty, 0, 4, Duration::ZERO, Expected),
6689 Step::Wait
6690 );
6691 assert_eq!(
6692 decide_with(&empty, 0, 4, CHECKS_GRACE, Expected),
6693 Step::GiveUp { reason: "no check status is readable on the pull request after 3 minute(s); refusing to merge on a guess".to_owned() }
6694 );
6695 let mut p = pr(Checks::Unknown, &[], 0);
6697 p.blocking = Blocking::Conflict;
6698 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
6699 p.state = PrLifecycle::Merged;
6701 assert_eq!(
6702 decide_with(&p, 0, 4, Duration::ZERO, Absent),
6703 Step::Done { merged: true }
6704 );
6705 }
6706
6707 #[test]
6708 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
6709 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
6710 assert_eq!(state.number, 10);
6711 assert_eq!(state.state, PrLifecycle::Open);
6712 assert_eq!(state.checks, Checks::Green);
6713 assert!(state.failing.is_empty());
6714 assert!(
6715 state.review_comments.is_empty(),
6716 "the only comment is CodeRabbit's trigger notice: {:?}",
6717 state.review_comments
6718 );
6719 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
6720 }
6721
6722 #[test]
6723 fn a_failing_check_parses_as_red_and_is_named() {
6724 let state = parse_pr(RED_OPEN).expect("red fixture parses");
6725 assert_eq!(state.checks, Checks::Red);
6726 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
6727 let mut blocking = state.clone();
6734 blocking.blocking = Blocking::Yes;
6735 match decide(&blocking, 0, 4, Duration::ZERO) {
6736 Step::Fix { reason } => {
6737 assert!(reason.contains("editorconfig"), "reason: {reason}");
6738 assert!(reason.contains("failing"), "reason: {reason}");
6739 }
6740 other => panic!("expected a fix round, got {other:?}"),
6741 }
6742 }
6743
6744 #[test]
6745 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
6746 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
6747 assert_eq!(state.checks, Checks::Pending);
6748 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
6749 }
6750
6751 #[test]
6752 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
6753 let state = parse_pr(MERGED).expect("merged fixture parses");
6754 assert_eq!(state.state, PrLifecycle::Merged);
6755 assert_eq!(
6756 decide(&state, 0, 4, Duration::ZERO),
6757 Step::Done { merged: true }
6758 );
6759 }
6760
6761 #[test]
6762 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
6763 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
6764 assert_eq!(state.checks, Checks::Green);
6765 let authors: Vec<&str> = state
6766 .review_comments
6767 .iter()
6768 .map(|c| c.author.as_str())
6769 .collect();
6770 assert_eq!(
6771 authors,
6772 vec!["claude"],
6773 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
6774 );
6775 match decide(&state, 0, 4, Duration::ZERO) {
6776 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
6777 other => panic!("expected a fix round, got {other:?}"),
6778 }
6779 }
6780
6781 #[test]
6782 fn inline_review_comments_keep_their_file_and_line() {
6783 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
6784 assert_eq!(comments.len(), 1);
6785 assert_eq!(comments[0].author, "claude[bot]");
6786 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
6787 assert_eq!(comments[0].line, Some(231));
6788 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
6789 }
6790
6791 #[test]
6792 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
6793 assert!(
6794 is_noise(CODERABBIT_TRIGGER),
6795 "CodeRabbit's trigger notice declares itself not a review"
6796 );
6797 assert!(
6798 is_noise(CLAUDE_CHECKLIST),
6799 "a progress checklist asks for nothing"
6800 );
6801 assert!(
6802 !is_noise(CLAUDE_FINDING),
6803 "a review that names a bug is input, not noise"
6804 );
6805
6806 let mut clean = pr(Checks::Green, &[], 0);
6807 clean.review_comments.push(ReviewComment {
6808 author: "coderabbitai".to_owned(),
6809 path: None,
6810 line: None,
6811 body: CODERABBIT_TRIGGER.to_owned(),
6812 });
6813 clean.review_comments.retain(|c| !is_noise(&c.body));
6814 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
6815
6816 let mut found = pr(Checks::Green, &[], 0);
6817 found.review_comments.push(ReviewComment {
6818 author: "claude".to_owned(),
6819 path: None,
6820 line: None,
6821 body: CLAUDE_FINDING.to_owned(),
6822 });
6823 found.review_comments.retain(|c| !is_noise(&c.body));
6824 assert!(matches!(
6825 decide(&found, 0, 4, Duration::ZERO),
6826 Step::Fix { .. }
6827 ));
6828 }
6829
6830 #[test]
6831 fn the_policy_table_holds_for_every_combination_that_matters() {
6832 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
6833 (
6834 "pending checks are waited for, even on the last round",
6835 pr(Checks::Pending, &[], 0),
6836 4,
6837 4,
6838 Duration::ZERO,
6839 Step::Wait,
6840 ),
6841 (
6842 "red checks are fixed",
6843 pr(Checks::Red, &["editorconfig"], 0),
6844 0,
6845 4,
6846 Duration::ZERO,
6847 Step::Fix {
6848 reason: "1 check(s) failing: editorconfig".to_owned(),
6849 },
6850 ),
6851 (
6852 "green with comments is fixed, not merged",
6853 pr(Checks::Green, &[], 2),
6854 1,
6855 4,
6856 Duration::ZERO,
6857 Step::Fix {
6858 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
6859 .to_owned(),
6860 },
6861 ),
6862 (
6863 "green and clean merges",
6864 pr(Checks::Green, &[], 0),
6865 3,
6866 4,
6867 Duration::ZERO,
6868 Step::Merge,
6869 ),
6870 (
6871 "an unreadable rollup is waited on while the grace lasts",
6872 pr(Checks::Unknown, &[], 0),
6873 0,
6874 4,
6875 Duration::ZERO,
6876 Step::Wait,
6877 ),
6878 (
6879 "an unreadable rollup is never merged once the grace is spent",
6880 pr(Checks::Unknown, &[], 0),
6881 0,
6882 4,
6883 CHECKS_GRACE,
6884 Step::GiveUp {
6885 reason: "no check status is readable on the pull request after 3 minute(s); \
6886 refusing to merge on a guess"
6887 .to_owned(),
6888 },
6889 ),
6890 ];
6891 for (what, state, round, budget, waited, want) in cases {
6892 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
6893 }
6894 }
6895
6896 #[test]
6897 fn the_forge_verdict_survives_the_round_trip_from_gh() {
6898 let green = parse_pr(GREEN_OPEN).expect("parse");
6902 assert_eq!(green.blocking, Blocking::No);
6903 let red = parse_pr(RED_OPEN).expect("parse");
6904 assert_eq!(
6905 red.blocking,
6906 Blocking::No,
6907 "`UNSTABLE` is mergeable: the red check is one nobody requires"
6908 );
6909 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
6910 let quiet =
6912 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
6913 assert_eq!(quiet.blocking, Blocking::Unsaid);
6914 }
6915
6916 #[test]
6917 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
6918 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
6924 nonblocking.blocking = Blocking::No;
6925 assert_eq!(
6926 decide(&nonblocking, 0, 4, Duration::ZERO),
6927 Step::Merge,
6928 "the forge says nothing is in the way, so nothing is"
6929 );
6930
6931 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
6933 blocking.blocking = Blocking::Yes;
6934 assert!(matches!(
6935 decide(&blocking, 0, 4, Duration::ZERO),
6936 Step::Fix { .. }
6937 ));
6938
6939 let mut commented = pr(Checks::Red, &["coverage"], 1);
6942 commented.blocking = Blocking::No;
6943 assert!(matches!(
6944 decide(&commented, 0, 4, Duration::ZERO),
6945 Step::Fix { .. }
6946 ));
6947
6948 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
6950 unsaid.blocking = Blocking::Unsaid;
6951 assert_eq!(decide(&unsaid, 0, 4, Duration::ZERO), Step::Wait);
6952 assert!(matches!(
6953 decide(&unsaid, 0, 4, CHECKS_GRACE),
6954 Step::Fix { .. }
6955 ));
6956 }
6957
6958 #[test]
6959 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
6960 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
6961 red.blocking = Blocking::No;
6962 assert_eq!(
6963 decide(&red, 0, 4, Duration::ZERO),
6964 Step::Merge,
6965 "announcing must not change the decision"
6966 );
6967 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
6968 assert!(said.contains("yukimemi/magi"), "{said}");
6969 assert!(said.contains("#16"), "{said}");
6970 assert!(
6971 said.contains("https://github.com/yukimemi/magi/pull/16"),
6972 "{said}"
6973 );
6974 assert!(
6975 said.contains("test (windows-latest)") && said.contains("coverage"),
6976 "{said}"
6977 );
6978
6979 let green = pr(Checks::Green, &["stale"], 0);
6981 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
6982 }
6983
6984 #[test]
6985 fn red_merge_notices_omit_empty_check_details() {
6986 for checks in [vec![], vec!["", " "]] {
6987 let red = pr(Checks::Red, &checks, 0);
6988 let summary = red_merge_summary("owner/repo", &red).unwrap();
6989 let notice = crate::notices::merged_red("run-1", &summary);
6990 assert_eq!(
6991 notice.message,
6992 format!("Merged owner/repo PR #16 with red checks ({})", red.url)
6993 );
6994 assert_eq!(notice.severity, crate::notices::Severity::Warn);
6995 }
6996 let red = pr(Checks::Red, &["", " test ", " "], 0);
6997 let summary = red_merge_summary("owner/repo", &red).unwrap();
6998 assert!(summary.contains("with red checks: test ("), "{summary}");
6999 }
7000
7001 #[test]
7002 fn the_repo_label_comes_from_the_pull_request_url() {
7003 let p = Path::new("/tmp/checkout");
7004 assert_eq!(
7005 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
7006 "yukimemi/magi"
7007 );
7008 assert_eq!(repo_label(p, "not a url"), "checkout");
7009 }
7010
7011 #[test]
7012 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
7013 let mut conflicted = pr(Checks::Green, &[], 0);
7018 conflicted.blocking = Blocking::Conflict;
7019 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
7020
7021 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
7025 red.blocking = Blocking::Conflict;
7026 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
7027
7028 let mut merged = pr(Checks::Red, &[], 0);
7030 merged.blocking = Blocking::Conflict;
7031 merged.state = PrLifecycle::Merged;
7032 assert_eq!(
7033 decide(&merged, 0, 4, Duration::ZERO),
7034 Step::Done { merged: true }
7035 );
7036 }
7037
7038 #[test]
7039 fn the_forge_verdict_is_read_off_merge_state_status() {
7040 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
7043 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
7044 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
7045 }
7046 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
7047 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
7048 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
7049 for quiet in ["", "UNKNOWN"] {
7052 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
7053 assert!(Blocking::of(quiet).stops_a_merge());
7054 }
7055 }
7056
7057 #[test]
7058 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
7059 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
7060 let jj = "could not determine current branch: failed to run git: not on any branch";
7062
7063 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
7064 .expect("the forge says merged, so it merged");
7065 assert!(landed.ok);
7066 assert!(
7067 landed.detail.contains("but the pull request is merged"),
7068 "the record must not read as a clean success: {}",
7069 landed.detail
7070 );
7071 assert!(
7072 landed.detail.contains("not on any branch"),
7073 "and it must keep what the command actually said: {}",
7074 landed.detail
7075 );
7076
7077 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
7079 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
7080 assert!(merged_after_all(&argv, jj, None).is_none());
7082 }
7083
7084 #[test]
7085 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
7086 let mut state = pr(Checks::Red, &["editorconfig"], 3);
7087 state.state = PrLifecycle::Closed;
7088 assert_eq!(
7089 decide(&state, 0, 4, Duration::ZERO),
7090 Step::Done { merged: false },
7091 "a human closing the pull request ends the loop, whatever CI says"
7092 );
7093 }
7094
7095 #[test]
7096 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
7097 let red = decide(
7098 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
7099 4,
7100 4,
7101 Duration::ZERO,
7102 );
7103 match red {
7104 Step::GiveUp { reason } => {
7105 assert!(reason.contains("editorconfig"), "reason: {reason}");
7106 assert!(reason.contains("test (macos)"), "reason: {reason}");
7107 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
7108 }
7109 other => panic!("expected a give-up, got {other:?}"),
7110 }
7111
7112 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
7113 match commented {
7114 Step::GiveUp { reason } => {
7115 assert!(reason.contains("unresolved"), "reason: {reason}");
7116 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
7117 }
7118 other => panic!("expected a give-up, got {other:?}"),
7119 }
7120 }
7121
7122 #[test]
7123 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
7124 let candidate_commit = "magi: candidate A (uncommitted work)";
7125 let subject = merge_subject(candidate_commit, "add retries to the uploader");
7126 let argv = merge_argv(16, &subject);
7127
7128 assert!(argv.contains(&"--squash".to_owned()));
7129 assert!(argv.contains(&"--delete-branch".to_owned()));
7130 assert!(argv.contains(&"--subject".to_owned()));
7131 assert_eq!(
7132 argv.last().map(String::as_str),
7133 Some("add retries to the uploader"),
7134 "the subject must not be the candidate commit message"
7135 );
7136 assert_ne!(subject, candidate_commit);
7137 }
7138
7139 #[test]
7140 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
7141 assert_eq!(
7142 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
7143 "feat: a queue, an unattended loop, and a phone UI"
7144 );
7145 assert_eq!(
7146 merge_subject("", "# port the retry logic\n\ndetails"),
7147 "port the retry logic",
7148 "an empty title falls back to the task's first line, heading marks stripped"
7149 );
7150 }
7151
7152 #[test]
7153 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
7154 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
7155 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
7156 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
7157 assert_eq!(job_of("https://coderabbit.ai/status"), None);
7158 assert_eq!(run_of(""), None);
7159 }
7160
7161 #[test]
7162 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
7163 let mut out = Vec::new();
7164 push_if_outstanding(
7165 &mut out,
7166 ReviewComment {
7167 author: "yukimemi".to_owned(),
7168 path: None,
7169 line: None,
7170 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
7171 },
7172 );
7173 assert!(out.is_empty());
7174 }
7175
7176 fn run_state() -> RunState {
7180 let mut state = RunState::new(
7181 std::path::PathBuf::from("/repo/magi"),
7182 "main".to_owned(),
7183 "abcdef1234".to_owned(),
7184 "add retries to the uploader".to_owned(),
7185 crate::config::Config::default(),
7186 );
7187 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7192 let nanos = std::time::SystemTime::now()
7193 .duration_since(std::time::UNIX_EPOCH)
7194 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
7195 state.id = format!(
7196 "20261004-{nanos:06}-{:04x}",
7197 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
7198 );
7199 state
7200 }
7201
7202 fn green_pr() -> PrState {
7203 PrState {
7204 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
7205 number: 42,
7206 state: PrLifecycle::Open,
7207 checks: Checks::Green,
7208 blocking: Blocking::No,
7210 failing: Vec::new(),
7211 review_comments: vec![ReviewComment {
7212 author: "coderabbitai".to_owned(),
7213 path: Some("src/land.rs".to_owned()),
7214 line: Some(212),
7215 body: "this branch never checks the exit code".to_owned(),
7216 }],
7217 }
7218 }
7219
7220 #[test]
7221 fn land_fixer_defers_review_and_verification_without_a_cache() {
7222 let state = run_state();
7223 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7224 assert!(p.contains(crate::prompt::child_run_ownership()), "{p}");
7225 assert!(p.contains("Commit in this worktree"), "{p}");
7226 }
7227
7228 #[test]
7229 fn github_facing_land_text_is_english_whatever_the_language() {
7230 let mut state = run_state();
7231 state.config.graph.language = "ja".to_owned();
7232 let comment = stop_comment(&state.id, "checks are still red");
7233 assert!(crate::github_text::check("", &comment).is_empty());
7234 assert!(comment.is_ascii(), "{comment}");
7235 assert!(comment.starts_with(MARKER));
7236
7237 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7238 let ja_at = p.find("Write all prose in ja").unwrap();
7239 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
7240 assert!(ja_at < rule_at, "{p}");
7241 assert!(p.contains("stays in Japanese"), "{p}");
7242
7243 state.config.graph.language = "en".to_owned();
7244 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7245 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
7246 assert!(!p.contains("does not apply"), "{p}");
7247 }
7248
7249 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
7250
7251 fn panel() -> String {
7252 approval_panel(
7253 &run_state(),
7254 &green_pr(),
7255 NUMSTAT,
7256 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
7257 &[
7258 "land: ask before merging".to_owned(),
7259 "land: colour the diff".to_owned(),
7260 ],
7261 "feat: merge approval from the phone",
7262 )
7263 }
7264
7265 #[test]
7266 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
7267 let html = panel();
7268 for needle in [
7269 "42",
7270 "main",
7271 "src/land.rs",
7272 "src/web.rs",
7273 "assets/logo.png",
7274 "feat: merge approval from the phone",
7275 "land: ask before merging",
7276 "land: colour the diff",
7277 "coderabbitai",
7278 "this branch never checks the exit code",
7279 "green",
7280 ] {
7281 assert!(html.contains(needle), "the panel must state `{needle}`");
7282 }
7283 }
7284
7285 async fn landing_git_fixture() -> (tempfile::TempDir, RunState, String) {
7286 crate::run::pin_test_home();
7287 let tmp = tempfile::tempdir().unwrap();
7288 let repo = tmp.path().join("repo");
7289 let remote = tmp.path().join("remote.git");
7290 std::fs::create_dir(&repo).unwrap();
7291 git::git(tmp.path(), &["init", "--bare", remote.to_str().unwrap()])
7292 .await
7293 .unwrap();
7294 git::git(&repo, &["init", "-b", "main"]).await.unwrap();
7295 git::git(&repo, &["config", "user.name", "Test"])
7296 .await
7297 .unwrap();
7298 git::git(&repo, &["config", "user.email", "test@example.test"])
7299 .await
7300 .unwrap();
7301 std::fs::write(repo.join("base"), "base").unwrap();
7302 git::git(&repo, &["add", "."]).await.unwrap();
7303 git::git(&repo, &["commit", "-m", "base"]).await.unwrap();
7304 git::git(
7305 &repo,
7306 &["remote", "add", "origin", remote.to_str().unwrap()],
7307 )
7308 .await
7309 .unwrap();
7310 git::git(&repo, &["push", "origin", "main"]).await.unwrap();
7311 git::git(&repo, &["checkout", "-b", "winner"])
7312 .await
7313 .unwrap();
7314 std::fs::write(repo.join("change"), "change").unwrap();
7315 git::git(&repo, &["add", "."]).await.unwrap();
7316 git::git(&repo, &["commit", "-m", "change"]).await.unwrap();
7317 let head = git::rev_parse(&repo, "HEAD").await.unwrap();
7318 git::git(&repo, &["push", "origin", "HEAD:refs/heads/task-head"])
7319 .await
7320 .unwrap();
7321 let mut state = run_state();
7322 state.repo = repo.clone();
7323 state.base_commit = git::rev_parse(&repo, "origin/main").await.unwrap();
7324 state.config.graph.land = true;
7325 state.config.graph.land_approval = true;
7326 state.pr_head = Some("task-head".into());
7327 state.pr_pushed_head = Some(head.clone());
7328 state.candidates = vec![winning_candidate("change")];
7329 state.candidates[0].worktree = repo;
7330 state.candidates[0].branch = "winner".into();
7331 state.tally = Some(uncontested_tally());
7332 state.gate_ran = true;
7333 state.gate_head = Some(head.clone());
7334 state.land_sync_pending = true;
7335 state.save().unwrap();
7336 (tmp, state, head)
7337 }
7338
7339 async fn resume_observed_head(state: &mut RunState, head: &str) -> Result<String> {
7340 let forge = Scripted::new(vec![seen(head, Checks::Green, "CLEAN", false)], vec![]);
7341 resume_landing_head(state, head, &green_pr().url, &forge).await
7342 }
7343
7344 async fn advance_landing_base(state: &RunState) {
7345 git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7346 std::fs::write(state.repo.join("advance"), "advance").unwrap();
7347 git::git(&state.repo, &["add", "."]).await.unwrap();
7348 git::git(&state.repo, &["commit", "-m", "advance base"])
7349 .await
7350 .unwrap();
7351 git::git(&state.repo, &["push", "origin", "main"])
7352 .await
7353 .unwrap();
7354 git::git(&state.repo, &["checkout", "winner"])
7355 .await
7356 .unwrap();
7357 }
7358
7359 #[tokio::test]
7360 async fn landing_resume_gates_rebased_sha_and_publishes_it_to_the_existing_head() {
7361 let (_tmp, mut state, old) = landing_git_fixture().await;
7362 advance_landing_base(&state).await;
7363 state.config.verify.gate = vec!["test -f advance".into()];
7364 let head = resume_observed_head(&mut state, &old).await.unwrap();
7365 assert_ne!(head, old);
7366 assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7367 assert!(state.gate_allows_progress());
7368 assert_eq!(
7369 git::remote_head(&state.repo, "origin", "task-head")
7370 .await
7371 .unwrap(),
7372 Some(head.clone())
7373 );
7374 assert_eq!(state.pr_pushed_head, Some(head));
7375 assert!(!state.land_sync_pending);
7376 assert!(state.config.graph.land_approval);
7377 }
7378
7379 fn landing_gate_fixer(state: &mut RunState, script: &str) {
7380 state.config.disk.min_free_bytes = 0;
7381 state.config.graph.gate_fix_rounds = 1;
7382 state.config.agents = vec![crate::config::AgentSpec {
7383 id: "mock".into(),
7384 kind: crate::config::AgentKind::Command,
7385 model: None,
7386 command: vec!["sh".into(), "-c".into(), script.into()],
7387 extra_args: Vec::new(),
7388 env: Default::default(),
7389 prompt_delivery: None,
7390 max_turn_secs: None,
7391 }];
7392 state.candidates[0].agent = "mock".into();
7393 }
7394
7395 #[tokio::test]
7396 async fn landing_resume_revalidates_an_operator_published_winner_extension() {
7397 let (_tmp, mut state, old) = landing_git_fixture().await;
7398 std::fs::write(state.repo.join("change"), "operator update").unwrap();
7399 git::commit_all(&state.repo, "extend winner").await.unwrap();
7400 let updated = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7401 git::git(
7402 &state.repo,
7403 &["push", "origin", "HEAD:refs/heads/task-head"],
7404 )
7405 .await
7406 .unwrap();
7407 state.config.verify.gate = vec!["exit 1".into()];
7408 state.config.graph.gate_fix_rounds = 0;
7409 assert!(resume_observed_head(&mut state, &updated).await.is_err());
7410 assert_ne!(old, updated);
7411 assert_eq!(state.gate_head.as_ref(), Some(&updated));
7412 assert!(!state.gate_allows_progress());
7413 assert!(state.land_sync_pending);
7414 state.config.verify.gate = vec!["grep -q 'operator update' change".into()];
7415 let head = resume_observed_head(&mut state, &updated).await.unwrap();
7416 assert_eq!(head, updated);
7417 assert!(state.gate_allows_progress());
7418 assert!(!state.land_sync_pending);
7419 let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7420 land_with(&mut state, &green_pr().url, &forge)
7421 .await
7422 .unwrap();
7423 assert!(state.parked);
7424 assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7425 assert!(forge.argvs().is_empty());
7426 }
7427
7428 #[tokio::test]
7429 async fn landing_resume_refuses_remote_only_and_divergent_operator_updates() {
7430 for divergent in [false, true] {
7431 let (_tmp, mut state, old) = landing_git_fixture().await;
7432 if divergent {
7433 git::git(&state.repo, &["reset", "--hard", "origin/main"])
7434 .await
7435 .unwrap();
7436 }
7437 std::fs::write(state.repo.join("external"), "external").unwrap();
7438 git::commit_all(&state.repo, "external update")
7439 .await
7440 .unwrap();
7441 let external = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7442 git::git(
7443 &state.repo,
7444 &["push", "--force", "origin", "HEAD:refs/heads/task-head"],
7445 )
7446 .await
7447 .unwrap();
7448 if !divergent {
7449 git::git(&state.repo, &["reset", "--hard", &old])
7450 .await
7451 .unwrap();
7452 }
7453 let error = resume_observed_head(&mut state, &external)
7454 .await
7455 .unwrap_err();
7456 assert!(
7457 error
7458 .to_string()
7459 .contains("public head moved outside this run")
7460 );
7461 assert_eq!(state.pr_pushed_head.as_ref(), Some(&old));
7462 assert_eq!(
7463 git::remote_head(&state.repo, "origin", "task-head")
7464 .await
7465 .unwrap(),
7466 Some(external)
7467 );
7468 }
7469 }
7470
7471 #[tokio::test]
7472 async fn landing_resume_repairs_a_failed_rebased_gate_before_publication() {
7473 let (_tmp, mut state, old) = landing_git_fixture().await;
7474 advance_landing_base(&state).await;
7475 landing_gate_fixer(
7476 &mut state,
7477 "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7478 );
7479 state.config.verify.gate =
7480 vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7481 state.config.verify.e2e = vec!["test -f advance".into()];
7482 state.config.graph.gate_fix_rounds = 0;
7485 assert!(resume_observed_head(&mut state, &old).await.is_err());
7486 assert_eq!(
7487 git::remote_head(&state.repo, "origin", "task-head")
7488 .await
7489 .unwrap(),
7490 Some(old.clone())
7491 );
7492 state.config.graph.gate_fix_rounds = 1;
7493 let head = resume_observed_head(&mut state, &old).await.unwrap();
7494 assert_eq!(state.gate_fixes.len(), 1);
7495 assert!(state.gate_fixes[0].committed);
7496 assert!(state.gate_allows_progress());
7497 assert_eq!(state.gate_head.as_ref(), Some(&head));
7498 assert_eq!(
7499 git::remote_head(&state.repo, "origin", "task-head")
7500 .await
7501 .unwrap(),
7502 Some(head.clone())
7503 );
7504 let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7505 land_with(&mut state, &green_pr().url, &forge)
7506 .await
7507 .unwrap();
7508 assert!(state.parked);
7509 assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7510 assert!(forge.argvs().is_empty());
7511 }
7512
7513 #[tokio::test]
7514 async fn landing_gate_repairs_stop_at_the_saved_budget_without_publishing() {
7515 let (_tmp, mut state, old) = landing_git_fixture().await;
7516 advance_landing_base(&state).await;
7517 landing_gate_fixer(
7518 &mut state,
7519 "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7520 );
7521 state.config.verify.gate = vec!["echo still red; exit 1".into()];
7522 for _ in 0..2 {
7523 assert!(resume_observed_head(&mut state, &old).await.is_err());
7524 assert_eq!(state.gate_fixes.len(), 1);
7525 assert_eq!(
7526 git::remote_head(&state.repo, "origin", "task-head")
7527 .await
7528 .unwrap(),
7529 Some(old.clone())
7530 );
7531 }
7532 }
7533
7534 #[tokio::test]
7535 async fn landing_gate_repair_cannot_publish_failed_e2e_on_repeated_resumes() {
7536 let (_tmp, mut state, old) = landing_git_fixture().await;
7537 advance_landing_base(&state).await;
7538 landing_gate_fixer(
7539 &mut state,
7540 "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7541 );
7542 state.config.verify.gate =
7543 vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7544 state.config.verify.e2e = vec!["grep -q '^change$' change".into()];
7546 for resume in 0..3 {
7547 assert!(resume_observed_head(&mut state, &old).await.is_err());
7548 state = RunState::load(&state.id).unwrap();
7549 let evidence = state.gate_repair_e2e.as_ref().unwrap();
7550 let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7551 assert_eq!(evidence.head, head);
7552 assert!(!evidence.passed);
7553 assert!(evidence.outcomes.iter().any(|o| !o.ok()));
7554 assert_eq!(state.gate_fixes.len(), 1);
7555 assert_eq!(
7556 git::remote_head(&state.repo, "origin", "task-head")
7557 .await
7558 .unwrap(),
7559 Some(old.clone())
7560 );
7561 if resume > 0 {
7562 assert!(
7563 state.gate_allows_progress(),
7564 "a green gate must not bypass red e2e"
7565 );
7566 }
7567 }
7568 state.config.verify.e2e = vec!["grep -q fixed change".into()];
7571 let head = resume_observed_head(&mut state, &old).await.unwrap();
7572 assert!(state.gate_repair_e2e.as_ref().unwrap().passed);
7573 assert_eq!(state.gate_repair_e2e.as_ref().unwrap().head, head);
7574 assert_eq!(state.gate_fixes.len(), 1);
7575 let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7576 land_with(&mut state, &green_pr().url, &forge)
7577 .await
7578 .unwrap();
7579 assert!(state.parked);
7580 assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7581 assert!(forge.argvs().is_empty());
7582 }
7583
7584 #[tokio::test]
7585 async fn landing_resumes_recheck_pending_deferred_and_stale_repair_e2e() {
7586 for phase in 0..3 {
7587 let (_tmp, mut state, old) = landing_git_fixture().await;
7588 advance_landing_base(&state).await;
7589 let head = resume_observed_head(&mut state, &old).await.unwrap();
7590 state.land_sync_pending = true;
7593 state.config.verify.e2e = vec!["echo e2e is red; exit 1".into()];
7594 state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7595 head: if phase == 2 {
7596 old.clone()
7597 } else {
7598 head.clone()
7599 },
7600 passed: phase == 2,
7601 outcomes: if phase == 1 {
7602 vec![crate::run::CommandOutcome {
7603 command: state.config.verify.e2e[0].clone(),
7604 code: None,
7605 output_tail: "verification resource unavailable".into(),
7606 duration_ms: 0,
7607 resource_blocked: true,
7608 }]
7609 } else {
7610 Vec::new()
7611 },
7612 });
7613 state.save().unwrap();
7614 for _ in 0..2 {
7615 state = RunState::load(&state.id).unwrap();
7616 assert!(resume_observed_head(&mut state, &head).await.is_err());
7617 let evidence = state.gate_repair_e2e.as_ref().unwrap();
7618 assert_eq!(evidence.head, head);
7619 assert!(!evidence.passed);
7620 assert!(!evidence.outcomes.is_empty());
7621 assert_eq!(
7622 git::remote_head(&state.repo, "origin", "task-head")
7623 .await
7624 .unwrap(),
7625 Some(head.clone())
7626 );
7627 assert!(state.land_sync_pending);
7628 }
7629 }
7630 }
7631
7632 async fn interrupt_landing_rebase(state: &mut RunState) {
7633 state.land_rebase_from = Some(git::rev_parse(&state.repo, "HEAD").await.unwrap());
7634 state.save().unwrap();
7635 let scratch = state.dir().join("rebase");
7636 assert_eq!(
7637 crate::rebase::rebase_with_fixer(state, &scratch, "winner", "origin/main")
7638 .await
7639 .unwrap(),
7640 crate::rebase::Rebased::Applied
7641 );
7642 assert!(!git::is_clean(&state.repo).await.unwrap());
7643 }
7644
7645 async fn landing_setup(state: &mut RunState) {
7646 git::git(
7647 &state.repo,
7648 &["config", "extensions.worktreeConfig", "true"],
7649 )
7650 .await
7651 .unwrap();
7652 state.config.worktree.setup =
7653 vec![toml::from_str("run = \"printf secret > .env\"").unwrap()];
7654 crate::worktree_setup::prepare(&state.config, &state.repo, &state.repo)
7655 .await
7656 .unwrap();
7657 state.config.verify.gate = vec!["test -f advance && test -f .env".into()];
7658 }
7659
7660 #[tokio::test]
7661 async fn landing_resume_restores_setup_after_both_fresh_and_interrupted_rebases() {
7662 for phase in 0..3 {
7663 let (_tmp, mut state, old) = landing_git_fixture().await;
7664 advance_landing_base(&state).await;
7665 landing_setup(&mut state).await;
7666 if phase > 0 {
7667 interrupt_landing_rebase(&mut state).await;
7668 if phase == 2 {
7669 git::sync_to_head(&state.repo).await.unwrap();
7671 assert!(!state.repo.join(".env").exists());
7672 }
7673 state = RunState::load(&state.id).unwrap();
7674 }
7675 let head = resume_observed_head(&mut state, &old).await.unwrap();
7676 assert_ne!(head, old);
7677 assert!(state.gate_allows_progress());
7678 assert!(state.land_rebase_from.is_none());
7679 assert_eq!(
7680 std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7681 "secret"
7682 );
7683 assert!(git::is_clean(&state.repo).await.unwrap());
7684 assert!(
7685 !git::git(&state.repo, &["ls-tree", "--name-only", "HEAD"])
7686 .await
7687 .unwrap()
7688 .contains(".env")
7689 );
7690 }
7691 }
7692
7693 #[tokio::test]
7694 async fn landing_restoration_protects_ignored_files_outside_setup_products() {
7695 let (_tmp, mut state, _) = landing_git_fixture().await;
7696 advance_landing_base(&state).await;
7697 landing_setup(&mut state).await;
7698 interrupt_landing_rebase(&mut state).await;
7699 let locals = [
7702 "local notes",
7703 #[cfg(unix)]
7704 "local\nnotes",
7705 ];
7706 let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7707 .await
7708 .unwrap();
7709 std::fs::write(state.repo.join(excludes), "local*\n").unwrap();
7710 for local in locals {
7711 std::fs::write(state.repo.join(local), "keep these notes").unwrap();
7712 assert!(
7713 git::git_raw(&state.repo, &["check-ignore", "--quiet", local])
7714 .await
7715 .unwrap()
7716 .ok()
7717 );
7718 }
7719 restore_landing_worktree(&mut state).await.unwrap();
7720 for local in locals {
7721 assert_eq!(
7722 std::fs::read_to_string(state.repo.join(local)).unwrap(),
7723 "keep these notes"
7724 );
7725 }
7726 assert_eq!(
7727 std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7728 "secret"
7729 );
7730 assert!(git::is_clean(&state.repo).await.unwrap());
7731 assert!(state.land_rebase_from.is_none());
7732 }
7733
7734 #[tokio::test]
7735 async fn landing_restoration_rejects_ignored_paths_conflicting_with_the_new_tree() {
7736 for local in ["advance", "advance/notes", "new-directory"] {
7737 let (_tmp, mut state, _) = landing_git_fixture().await;
7738 advance_landing_base(&state).await;
7739 git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7741 std::fs::create_dir(state.repo.join("new-directory")).unwrap();
7742 std::fs::write(state.repo.join("new-directory/tracked"), "new").unwrap();
7743 git::git(&state.repo, &["add", "."]).await.unwrap();
7744 git::git(&state.repo, &["commit", "-m", "add directory"])
7745 .await
7746 .unwrap();
7747 git::git(&state.repo, &["push", "origin", "main"])
7748 .await
7749 .unwrap();
7750 git::git(&state.repo, &["checkout", "winner"])
7751 .await
7752 .unwrap();
7753 let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7754 .await
7755 .unwrap();
7756 std::fs::write(state.repo.join(excludes), "advance\nnew-directory\n").unwrap();
7757 let path = state.repo.join(local);
7758 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
7759 std::fs::write(&path, "local notes").unwrap();
7760 interrupt_landing_rebase(&mut state).await;
7761 let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7762 let checkpoint = state.land_rebase_from.clone();
7763 let error = restore_landing_worktree(&mut state).await.unwrap_err();
7764 assert!(
7765 error.to_string().contains("conflicting with HEAD"),
7766 "{error:#}"
7767 );
7768 assert_eq!(std::fs::read_to_string(&path).unwrap(), "local notes");
7769 assert_eq!(
7770 git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7771 before
7772 );
7773 assert_eq!(state.land_rebase_from, checkpoint);
7774 }
7775 }
7776
7777 #[tokio::test]
7778 async fn landing_resume_preserves_ignored_gate_artifacts_without_setup() {
7779 for interrupted in [false, true] {
7780 let (_tmp, mut state, old) = landing_git_fixture().await;
7781 assert!(state.config.worktree.setup.is_empty());
7782 let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7784 .await
7785 .unwrap();
7786 std::fs::write(state.repo.join(excludes), "target/\n").unwrap();
7787 state.config.verify.gate =
7788 vec!["mkdir -p target && printf artifact > target/probe".into()];
7789 state.gate_ran = false;
7790 resume_observed_head(&mut state, &old).await.unwrap();
7791 assert_eq!(
7792 std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7793 "artifact"
7794 );
7795 advance_landing_base(&state).await;
7796 if interrupted {
7797 interrupt_landing_rebase(&mut state).await;
7798 state = RunState::load(&state.id).unwrap();
7799 }
7800 state.config.verify.gate =
7801 vec!["test -f advance && test \"$(cat target/probe)\" = artifact".into()];
7802 let head = resume_observed_head(&mut state, &old).await.unwrap();
7803 assert_ne!(head, old);
7804 assert!(state.gate_allows_progress());
7805 assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7806 assert_eq!(
7807 std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7808 "artifact"
7809 );
7810 assert!(state.land_rebase_from.is_none());
7811 assert_eq!(
7812 git::remote_head(&state.repo, "origin", "task-head")
7813 .await
7814 .unwrap(),
7815 Some(head.clone())
7816 );
7817 let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7818 land_with(&mut state, &green_pr().url, &forge)
7819 .await
7820 .unwrap();
7821 assert!(state.parked);
7822 assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7823 assert!(forge.argvs().is_empty());
7824 }
7825 }
7826
7827 #[tokio::test]
7828 #[cfg(unix)]
7829 async fn landing_restoration_waits_for_external_coordination_before_changing_files() {
7830 use std::os::fd::AsRawFd as _;
7831
7832 for interrupted in [false, true] {
7833 let (_tmp, mut state, _) = landing_git_fixture().await;
7834 advance_landing_base(&state).await;
7835 landing_setup(&mut state).await;
7836 if interrupted {
7837 interrupt_landing_rebase(&mut state).await;
7838 }
7839 state.config.worktree.setup =
7840 vec![toml::from_str("run = \"printf restored > .env\"").unwrap()];
7841 let common = crate::repo_lock::common_dir(&state.repo)
7842 .await
7843 .unwrap()
7844 .unwrap();
7845 std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7846 state.config.coordination = crate::config::test_coordination();
7847 state.config.graph.timeout_resource_wait = 0;
7848 let external = std::fs::OpenOptions::new()
7849 .read(true)
7850 .write(true)
7851 .create(true)
7852 .truncate(false)
7853 .open(common.join("updater.lock"))
7854 .unwrap();
7855 let locked =
7857 unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
7858 assert_eq!(locked, 0);
7859 let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7860 let checkpoint = state.land_rebase_from.clone();
7861 let error = restore_landing_worktree(&mut state).await.unwrap_err();
7862 assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7863 assert_eq!(
7864 git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7865 before
7866 );
7867 assert_eq!(state.land_rebase_from, checkpoint);
7868 assert_eq!(
7869 std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7870 "secret"
7871 );
7872
7873 drop(external);
7874 state.config.graph.timeout_resource_wait = 2;
7875 restore_landing_worktree(&mut state).await.unwrap();
7876 assert!(git::is_clean(&state.repo).await.unwrap());
7877 assert!(state.land_rebase_from.is_none());
7878 assert_eq!(
7879 std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7880 "restored"
7881 );
7882 }
7883 }
7884
7885 #[tokio::test]
7886 #[cfg(unix)]
7887 async fn landing_restoration_inherits_cancellation_while_waiting_for_a_local_seat() {
7888 use std::sync::atomic::{AtomicBool, Ordering};
7889
7890 let (_tmp, mut state, _) = landing_git_fixture().await;
7891 advance_landing_base(&state).await;
7892 landing_setup(&mut state).await;
7893 interrupt_landing_rebase(&mut state).await;
7894 let common = crate::repo_lock::common_dir(&state.repo)
7895 .await
7896 .unwrap()
7897 .unwrap();
7898 std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7899 state.config.coordination = crate::config::test_coordination();
7900 let holder = crate::repo_lock::SeatLease::for_checkout(
7901 &state.repo,
7902 true,
7903 state.config.coordination.as_ref(),
7904 )
7905 .await
7906 .unwrap();
7907 assert!(holder.is_some());
7908 let cancelled = Arc::new(AtomicBool::new(false));
7909 let signal = cancelled.clone();
7910 let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7911 signal.load(Ordering::SeqCst)
7912 });
7913 let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7914 let checkpoint = state.land_rebase_from.clone();
7915 let (result, ()) = tokio::join!(
7916 tokio::time::timeout(
7917 Duration::from_secs(2),
7918 wait.scope(restore_landing_worktree(&mut state)),
7919 ),
7920 async {
7921 tokio::time::sleep(Duration::from_millis(100)).await;
7922 cancelled.store(true, Ordering::SeqCst);
7923 }
7924 );
7925 let error = result
7926 .expect("cancellation must release the waiting driver")
7927 .unwrap_err();
7928 assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7929 assert_eq!(
7930 git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7931 before
7932 );
7933 assert_eq!(state.land_rebase_from, checkpoint);
7934 assert_eq!(
7935 std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7936 "secret"
7937 );
7938 drop(holder);
7939 restore_landing_worktree(&mut state).await.unwrap();
7940 assert!(state.land_rebase_from.is_none());
7941 assert!(git::is_clean(&state.repo).await.unwrap());
7942 }
7943
7944 #[tokio::test]
7945 #[cfg(unix)]
7946 async fn landing_verification_inherits_cancellation_while_waiting_for_a_local_seat() {
7947 use std::sync::atomic::{AtomicBool, Ordering};
7948
7949 for e2e in [false, true] {
7950 let (_tmp, mut state, head) = landing_git_fixture().await;
7951 let common = crate::repo_lock::common_dir(&state.repo)
7952 .await
7953 .unwrap()
7954 .unwrap();
7955 std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7956 state.config.coordination = crate::config::test_coordination();
7957 if e2e {
7958 state.config.verify.e2e = vec!["true".into()];
7959 state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7960 head: head.clone(),
7961 passed: false,
7962 outcomes: Vec::new(),
7963 });
7964 } else {
7965 state.config.verify.gate = vec!["true".into()];
7966 state.gate_ran = false;
7967 }
7968 let holder = crate::repo_lock::SeatLease::for_checkout(
7969 &state.repo,
7970 true,
7971 state.config.coordination.as_ref(),
7972 )
7973 .await
7974 .unwrap();
7975 assert!(holder.is_some());
7976 let cancelled = Arc::new(AtomicBool::new(false));
7977 let signal = cancelled.clone();
7978 let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7979 signal.load(Ordering::SeqCst)
7980 });
7981 let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7982 let url = green_pr().url;
7983 let (result, ()) = tokio::join!(
7984 tokio::time::timeout(
7985 Duration::from_secs(2),
7986 wait.scope(publish_landing_head(&mut state, &head, &url, &forge)),
7987 ),
7988 async {
7989 tokio::time::sleep(Duration::from_millis(100)).await;
7990 cancelled.store(true, Ordering::SeqCst);
7991 }
7992 );
7993 let error = result
7994 .expect("cancellation must release the verification waiter")
7995 .unwrap_err();
7996 assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7997 assert!(forge.argvs().is_empty());
7998 assert_eq!(
7999 git::remote_head(&state.repo, "origin", "task-head")
8000 .await
8001 .unwrap(),
8002 Some(head.clone())
8003 );
8004 drop(holder);
8005 assert_eq!(
8007 publish_landing_head(&mut state, &head, &url, &forge)
8008 .await
8009 .unwrap(),
8010 head
8011 );
8012 }
8013 }
8014
8015 #[tokio::test]
8016 async fn landing_resume_preserves_user_edits_after_an_interrupted_rebase() {
8017 for staged in [false, true] {
8018 let (_tmp, mut state, old) = landing_git_fixture().await;
8019 advance_landing_base(&state).await;
8020 interrupt_landing_rebase(&mut state).await;
8021 std::fs::write(state.repo.join("change"), "user edit").unwrap();
8022 if staged {
8023 git::git(&state.repo, &["add", "change"]).await.unwrap();
8024 }
8025 assert!(resume_observed_head(&mut state, &old).await.is_err());
8026 assert_eq!(
8027 std::fs::read_to_string(state.repo.join("change")).unwrap(),
8028 "user edit"
8029 );
8030 assert_eq!(
8031 git::remote_head(&state.repo, "origin", "task-head")
8032 .await
8033 .unwrap(),
8034 Some(old)
8035 );
8036 }
8037 }
8038
8039 #[tokio::test]
8040 async fn landing_resume_returns_to_approval_wait_and_closed_pr_skips_sync() {
8041 let (_tmp, mut state, head) = landing_git_fixture().await;
8042 let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
8043 let id = state.id.clone();
8044 let pr = land_with(&mut state, &green_pr().url, &forge)
8045 .await
8046 .unwrap();
8047 assert_eq!(pr.state, PrLifecycle::Open);
8048 assert_eq!(state.id, id);
8049 assert!(state.parked);
8050 assert_eq!(state.status, RunStatus::Landing);
8051 assert_eq!(state.land_approval.as_ref().unwrap().head, head);
8052 assert!(forge.argvs().is_empty());
8053
8054 state.land_sync_pending = true;
8055 std::fs::write(state.repo.join("dirty"), "do not touch").unwrap();
8056 let mut closed = seen("foreign", Checks::Green, "CLEAN", false);
8057 closed.pr.state = PrLifecycle::Closed;
8058 let forge = Scripted::new(vec![closed], vec![]);
8059 assert_eq!(
8060 land_with(&mut state, &green_pr().url, &forge)
8061 .await
8062 .unwrap()
8063 .state,
8064 PrLifecycle::Closed
8065 );
8066 assert_eq!(
8067 std::fs::read_to_string(state.repo.join("dirty")).unwrap(),
8068 "do not touch"
8069 );
8070 assert!(forge.argvs().is_empty());
8071 }
8072
8073 #[tokio::test]
8074 async fn landing_resume_does_not_publish_a_failed_rebased_gate() {
8075 let (_tmp, mut state, old) = landing_git_fixture().await;
8076 advance_landing_base(&state).await;
8077 state.config.verify.gate = vec!["exit 1".into()];
8078 assert!(resume_observed_head(&mut state, &old).await.is_err());
8079 assert_ne!(state.gate_head.as_deref(), Some(old.as_str()));
8080 assert!(!state.gate_allows_progress());
8081 assert_eq!(
8082 git::remote_head(&state.repo, "origin", "task-head")
8083 .await
8084 .unwrap(),
8085 Some(old)
8086 );
8087 assert!(state.land_sync_pending);
8088 }
8089
8090 #[tokio::test]
8091 async fn landing_publication_recovers_a_push_before_save_and_refuses_a_lease_conflict() {
8092 let (_tmp, mut state, old) = landing_git_fixture().await;
8093 advance_landing_base(&state).await;
8094 let pushed = resume_observed_head(&mut state, &old).await.unwrap();
8095 state.pr_pushed_head = Some(old.clone());
8097 state.land_sync_pending = true;
8098 assert_eq!(
8099 resume_observed_head(&mut state, &pushed).await.unwrap(),
8100 pushed
8101 );
8102 git::git(
8105 &state.repo,
8106 &["push", "--force", "origin", "main:refs/heads/task-head"],
8107 )
8108 .await
8109 .unwrap();
8110 assert!(
8111 publish_landing_head(
8112 &mut state,
8113 &pushed,
8114 &green_pr().url,
8115 &Scripted::new(vec![seen(&pushed, Checks::Green, "CLEAN", false)], vec![])
8116 )
8117 .await
8118 .unwrap_err()
8119 .to_string()
8120 .contains("publishing gated")
8121 );
8122 assert!(
8123 resume_observed_head(&mut state, "foreign-head")
8124 .await
8125 .is_err()
8126 );
8127 }
8128 #[tokio::test]
8129 async fn landing_publication_refuses_a_pr_closed_during_gate() {
8130 let (_tmp, mut state, head) = landing_git_fixture().await;
8131 let mut closed = seen(&head, Checks::Green, "CLEAN", false);
8132 closed.pr.state = PrLifecycle::Closed;
8133 let forge = Scripted::new(vec![closed], vec![]);
8134 assert!(
8135 publish_landing_head(&mut state, &head, &green_pr().url, &forge)
8136 .await
8137 .unwrap_err()
8138 .to_string()
8139 .contains("PR closed or merged")
8140 );
8141 }
8142
8143 fn winning_candidate(summary: &str) -> Candidate {
8145 Candidate {
8146 index: 0,
8147 label: 'A',
8148 agent: "opus".to_owned(),
8149 branch: "magi/x/A".to_owned(),
8150 worktree: PathBuf::from("/wt/A"),
8151 summary: summary.to_owned(),
8152 stat: String::new(),
8153 files: 1,
8154 commits: 1,
8155 empty: false,
8156 failed: None,
8157 verified_noop: None,
8158 duration_ms: 0,
8159 folded: false,
8160 }
8161 }
8162
8163 fn uncontested_tally() -> Tally {
8164 Tally {
8165 first_choice: BTreeMap::from([('A', 1)]),
8166 borda: BTreeMap::new(),
8167 winner: 'A',
8168 rankings: 1,
8169 unanimous_initial: true,
8170 deliberated: false,
8171 changed_votes: 0,
8172 unanimous_final: true,
8173 tie_break: None,
8174 judges: 1,
8175 present: 1,
8176 quorum: 1,
8177 met_quorum: true,
8178 uncontested: None,
8179 }
8180 }
8181
8182 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
8183 ReviewRecord {
8184 attempts: 0,
8185 reviewer,
8186 agent: agent.to_owned(),
8187 summary: summary.to_owned(),
8188 findings: Vec::new(),
8189 vote: None,
8190 failed: None,
8191 duration_ms: 0,
8192 }
8193 }
8194
8195 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
8196 let answered = reviews.len();
8197 ReviewRound {
8198 round,
8199 head: "abc1234".to_owned(),
8200 verified_head: None,
8201 verified_at: None,
8202 reviews,
8203 e2e: Vec::new(),
8204 verify_retried: false,
8205 e2e_deferred: false,
8206 e2e_defer_reason: None,
8207 fix: None,
8208 blocking: 0,
8209 answered,
8210 expected: answered,
8211 clean: true,
8212 progressed: false,
8213 vote_split: false,
8214 reconsideration: Vec::new(),
8215 verdict: None,
8216 }
8217 }
8218
8219 #[test]
8220 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
8221 let en = panel();
8222 assert!(en.contains("Task"), "{en}");
8223 assert!(en.contains("add retries to the uploader"), "{en}");
8224
8225 let mut state = run_state();
8226 state.config.graph.language = "ja".to_owned();
8227 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8228 assert!(ja.contains("タスク"), "{ja}");
8229 assert!(
8230 ja.contains("add retries to the uploader"),
8231 "the task itself is not translated: {ja}"
8232 );
8233 }
8234
8235 #[test]
8236 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
8237 let html = panel();
8241 assert!(!html.contains("What changed"), "{html}");
8242 assert!(!html.contains("Review verdict"), "{html}");
8243 }
8244
8245 #[test]
8246 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
8247 let mut state = run_state();
8248 state.candidates = vec![winning_candidate("")];
8249 state.tally = Some(uncontested_tally());
8250 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8251 assert!(
8252 !html.contains("What changed"),
8253 "an empty summary must not render an empty box: {html}"
8254 );
8255 }
8256
8257 #[test]
8258 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
8259 let mut state = run_state();
8260 state.candidates = vec![winning_candidate(
8261 "Added a retry loop around the uploader PUT call.",
8262 )];
8263 state.tally = Some(uncontested_tally());
8264 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8265 assert!(en.contains("What changed"), "{en}");
8266 assert!(
8267 en.contains("Added a retry loop around the uploader PUT call."),
8268 "{en}"
8269 );
8270
8271 state.config.graph.language = "ja".to_owned();
8272 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8273 assert!(ja.contains("変更内容"), "{ja}");
8274 assert!(
8275 ja.contains("Added a retry loop around the uploader PUT call."),
8276 "{ja}"
8277 );
8278 }
8279
8280 #[test]
8281 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
8282 let mut state = run_state();
8283 state.reviews = vec![
8284 review_round(
8285 1,
8286 vec![review_record(1, "alpha", "found a race, sent back")],
8287 ),
8288 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
8289 ];
8290 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8291 assert!(en.contains("Review verdict"), "{en}");
8292 assert!(en.contains("race is fixed, clean"), "{en}");
8293 assert!(
8294 !en.contains("found a race, sent back"),
8295 "only the round that actually cleared the merge should show: {en}"
8296 );
8297
8298 state.config.graph.language = "ja".to_owned();
8299 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8300 assert!(ja.contains("レビューの結論"), "{ja}");
8301 assert!(ja.contains("レビュアー"), "{ja}");
8302 assert!(ja.contains("race is fixed, clean"), "{ja}");
8303 }
8304
8305 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
8311 ReviewRecord {
8312 attempts: 0,
8313 reviewer,
8314 agent: agent.to_owned(),
8315 summary: String::new(),
8316 findings: Vec::new(),
8317 vote: None,
8318 failed: Some(reason.to_owned()),
8319 duration_ms: 0,
8320 }
8321 }
8322
8323 #[test]
8324 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
8325 let mut state = run_state();
8326 state.reviews = vec![review_round(
8327 1,
8328 vec![
8329 review_record(1, "alpha", "clean, nothing to add"),
8330 unanswered_review_record(2, "beta", "timed out"),
8331 ],
8332 )];
8333 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8334 assert!(en.contains("clean, nothing to add"), "{en}");
8335 assert!(
8336 en.contains("produced no answer: timed out"),
8337 "a seat that never answered must say so, not render a blank box: {en}"
8338 );
8339 assert!(
8340 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
8341 "no reviewer box may be left empty: {en}"
8342 );
8343
8344 state.config.graph.language = "ja".to_owned();
8345 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8346 assert!(ja.contains("回答なし: timed out"), "{ja}");
8347 }
8348
8349 #[test]
8350 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
8351 let html = panel();
8352 assert!(!html.contains("<script"), "no script survives the csp");
8353 assert!(!html.contains("<form"), "form-action is 'none'");
8354 let pr = green_pr();
8355 assert_eq!(
8356 html.matches("http").count(),
8357 html.matches(pr.url.as_str()).count(),
8358 "the only http url in the panel is the pull request's own link"
8359 );
8360 assert!(
8361 html.contains("target=\"_blank\""),
8362 "the PR link must open in a new tab so it escapes the iframe"
8363 );
8364 assert!(
8365 html.contains("rel=\"noopener noreferrer\""),
8366 "external links opening in new tabs must carry noopener noreferrer"
8367 );
8368 }
8369
8370 #[test]
8371 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
8372 let html = panel();
8373 assert!(
8374 html.contains(">+</span>"),
8375 "an added line carries a `+` in the gutter, not only a background"
8376 );
8377 assert!(
8378 html.contains(">-</span>"),
8379 "a removed line carries a `-` in the gutter, not only a background"
8380 );
8381 assert!(
8382 html.contains(">new line</span>"),
8383 "the marker is moved to the gutter, so the body is printed once without it"
8384 );
8385 }
8386
8387 #[test]
8388 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
8389 let total = DIFF_MAX_LINES + 100;
8390 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
8391 let html = approval_panel(
8392 &run_state(),
8393 &green_pr(),
8394 NUMSTAT,
8395 &diff,
8396 &[],
8397 "feat: something long",
8398 );
8399 assert!(
8400 html.contains(&format!("100 of {total} diff lines omitted")),
8401 "the note must say exactly how much was cut"
8402 );
8403 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
8404 assert!(
8405 !html.contains(&format!("line {DIFF_MAX_LINES}")),
8406 "nothing past the threshold is rendered"
8407 );
8408 assert!(
8409 html.contains("/repo/magi"),
8410 "the note says where the rest is"
8411 );
8412 }
8413
8414 #[test]
8415 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
8416 let html = approval_panel(
8417 &run_state(),
8418 &green_pr(),
8419 "1\t2\tsrc/<b>&\"x\"'.rs",
8420 "",
8421 &[],
8422 "subject",
8423 );
8424 assert!(html.contains("src/<b>&"x"'.rs"));
8425 assert!(
8426 !html.contains("<b>"),
8427 "an agent-influenced path must never become markup"
8428 );
8429 }
8430
8431 #[tokio::test]
8432 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
8433 let a = std::path::PathBuf::from("/repo/a");
8434 let b = std::path::PathBuf::from("/repo/b");
8435
8436 let held = repo_merge_lock(&a).lock_owned().await;
8437
8438 assert!(
8441 repo_merge_lock(&a).try_lock().is_err(),
8442 "a second merge into the same repository must not proceed concurrently"
8443 );
8444
8445 assert!(
8449 repo_merge_lock(&b).try_lock().is_ok(),
8450 "a different repository's merge lock must be independent"
8451 );
8452
8453 drop(held);
8454 assert!(
8455 repo_merge_lock(&a).try_lock().is_ok(),
8456 "the lock is released once the holder is done"
8457 );
8458 }
8459
8460 pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
8461 let mut state = landing_state();
8462 let mut rec = review_record(1, "alpha", "precise rejection reason");
8463 rec.vote = Some(if reject {
8464 crate::verdict::ReviewVote::Reject
8465 } else {
8466 crate::verdict::ReviewVote::Approve
8467 });
8468 if major {
8469 rec.findings = contested().findings;
8470 }
8471 let mut round = review_round(1, vec![rec]);
8472 round.clean = false;
8473 round.blocking = usize::from(major);
8474 state.reviews.push(round);
8475 state.config.graph.review_rounds = 1;
8476 state
8477 }
8478
8479 #[test]
8480 fn approval_policy_controls_are_independent_and_keep_all_reasons() {
8481 for every in [false, true] {
8482 for contested in [false, true] {
8483 for major in [false, true] {
8484 for reject in [false, true] {
8485 let mut state = repair_state(major, reject);
8486 state.config.graph.land_approval = every;
8487 state.config.graph.hold_contested_merge = contested;
8488 let reasons = approval_reasons(&state);
8489 assert_eq!(reasons.contains(&ApprovalReason::EveryMerge), every);
8490 assert_eq!(
8491 reasons.contains(&ApprovalReason::OpenBlockingFindings),
8492 contested && major
8493 );
8494 assert_eq!(
8495 reasons.contains(&ApprovalReason::FinalReject),
8496 contested && reject
8497 );
8498 assert_eq!(
8499 reasons.len(),
8500 usize::from(every)
8501 + usize::from(contested && major)
8502 + usize::from(contested && reject)
8503 );
8504 }
8505 }
8506 }
8507 }
8508 }
8509
8510 #[test]
8511 fn approval_policy_and_rendering_follow_reconsideration_in_both_directions() {
8512 use crate::verdict::ReviewVote::{Approve, Reject};
8513 for (initial_reject, final_vote) in [(true, Approve), (false, Reject)] {
8514 let mut state = repair_state(false, initial_reject);
8515 state.reviews[0]
8516 .reconsideration
8517 .push(crate::run::ReviewRevoteRecord {
8518 reviewer: 1,
8519 agent: "alpha".into(),
8520 vote: Some(final_vote),
8521 reason: "changed after reading the panel <evidence>".into(),
8522 failed: None,
8523 });
8524 assert_eq!(
8525 approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8526 final_vote == Reject
8527 );
8528 for language in ["en", "ja"] {
8529 state.config.graph.language = language.into();
8530 let evidence = approval_evidence(&state);
8531 assert!(evidence.contains(&format!("→ {}", final_vote.label())));
8532 assert!(evidence.contains("changed after reading the panel <evidence>"));
8533 let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8534 assert!(html.contains("changed after reading the panel <evidence>"));
8535 assert!(!html.contains("<evidence>"));
8536 }
8537 state.reviews[0].reconsideration[0].vote = None;
8538 state.reviews[0].reconsideration[0].failed = Some("timed out".into());
8539 assert_eq!(
8540 approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8541 initial_reject
8542 );
8543 assert!(approval_evidence(&state).contains("timed out"));
8544 }
8545 }
8546
8547 #[tokio::test]
8548 async fn approval_merge_paths_apply_controls_to_major_and_reject_independently() {
8549 for every in [false, true] {
8550 for contested in [false, true] {
8551 for (major, reject) in [(false, false), (false, true), (true, false), (true, true)]
8552 {
8553 let mut state = repair_state(major, reject);
8554 state.config.graph.land_approval = every;
8555 state.config.graph.hold_contested_merge = contested;
8556 let forge = Scripted::new(
8557 vec![seen("abc", Checks::Green, "CLEAN", false)],
8558 vec![(true, "")],
8559 );
8560 land_with(&mut state, URL, &forge).await.unwrap();
8561 let expected = every || (contested && (major || reject));
8562 assert_eq!(state.land_approval.is_some(), expected);
8563 assert_eq!(forge.calls().contains(&"merge"), !expected);
8564 if expected {
8565 assert!(state.parked);
8566 }
8567 }
8568 }
8569 }
8570 }
8571
8572 #[tokio::test]
8573 async fn reviewed_branch_hold_is_read_before_landing_decision() {
8574 for (base, control) in [
8575 (false, "land_approval"),
8576 (true, "land_approval"),
8577 (false, "hold_contested_merge"),
8578 ] {
8579 let (_tmp, mut state, _) = landing_git_fixture().await;
8580 state.config.graph.land_approval = base;
8581 state.config.graph.hold_contested_merge = false;
8582 state.reviewed_commits = Some(Vec::new());
8583 if control == "hold_contested_merge" {
8584 state.reviews = repair_state(false, true).reviews;
8585 }
8586 std::fs::write(
8587 state.repo.join("magi.toml"),
8588 format!("[graph]\n{control} = {}\n", !base),
8589 )
8590 .unwrap();
8591 git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8592 git::git(&state.repo, &["commit", "-m", "approval policy"])
8593 .await
8594 .unwrap();
8595 let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8596 state.land_sync_pending = false;
8597 state.pr_pushed_head = Some(head.clone());
8598 state.gate_head = Some(head.clone());
8599 let forge = Scripted::new(
8600 vec![seen(&head, Checks::Green, "CLEAN", false)],
8601 vec![(true, "")],
8602 );
8603 land_with(&mut state, URL, &forge).await.unwrap();
8604 assert!(
8605 state.land_approval.is_some(),
8606 "base={base}, control={control}"
8607 );
8608 assert!(!forge.calls().contains(&"merge"));
8609 if !base {
8610 let question = ask::Questions::open()
8611 .get(&state.land_approval.as_ref().unwrap().question)
8612 .unwrap();
8613 assert!(question.detail.contains(&format!(
8614 "{control}=true from the reviewed branch's magi.toml"
8615 )));
8616 }
8617 }
8618 }
8619
8620 #[tokio::test]
8621 async fn reviewed_branch_hold_asks_and_survives_removal_and_resume() {
8622 let (_tmp, mut state, _) = landing_git_fixture().await;
8623 state.config.graph.land_approval = false;
8624 state.config.graph.hold_contested_merge = false;
8625 state.reviewed_commits = Some(Vec::new());
8626 std::fs::write(
8627 state.repo.join("magi.toml"),
8628 "[graph]\nland_approval = true\n[verify]\ngate = ['untrusted']\n",
8629 )
8630 .unwrap();
8631 git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8632 git::git(&state.repo, &["commit", "-m", "require approval"])
8633 .await
8634 .unwrap();
8635 let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8636 let gate = state.config.verify.gate.clone();
8637 state.observe_branch_owner_holds(&head).await;
8638 assert_eq!(state.config.verify.gate, gate);
8639 assert_eq!(approval_reasons(&state), [ApprovalReason::EveryMerge]);
8640 git::git(&state.repo, &["rm", "magi.toml"]).await.unwrap();
8641 git::git(&state.repo, &["commit", "-m", "remove config"])
8642 .await
8643 .unwrap();
8644 let later = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8645 state = serde_json::from_str(&serde_json::to_string(&state).unwrap()).unwrap();
8646 state.observe_branch_owner_holds(&later).await;
8647 assert_eq!(state.branch_owner_holds[0].head, head);
8648 state.land_sync_pending = false;
8649 state.pr_pushed_head = Some(later.clone());
8650 state.gate_head = Some(later.clone());
8651 let forge = Scripted::new(
8652 vec![seen(&later, Checks::Green, "CLEAN", false)],
8653 vec![(true, "")],
8654 );
8655 land_with(&mut state, URL, &forge).await.unwrap();
8656 assert!(!forge.calls().contains(&"merge"));
8657 let question = ask::Questions::open()
8658 .get(&state.land_approval.as_ref().unwrap().question)
8659 .unwrap();
8660 assert!(question.detail.contains("Approval required because:"));
8661 assert!(
8662 question
8663 .detail
8664 .contains("land_approval=true from the reviewed branch's magi.toml")
8665 );
8666 }
8667
8668 #[tokio::test]
8669 async fn approval_snapshot_exposes_minor_findings_and_survives_disabled_controls() {
8670 let mut state = repair_state(false, true);
8671 state.reviews[0].clean = true;
8672 let mut finding = contested().findings.remove(0);
8673 finding.severity = crate::verdict::Severity::Minor;
8674 state.reviews[0].reviews[0].findings.push(finding);
8675 assert!(state.open_findings().is_empty());
8676 let mut pr = green_pr();
8677 pr.review_comments.clear();
8678 for language in ["en", "ja"] {
8679 state.config.graph.language = language.into();
8680 let html = approval_panel(&state, &pr, "", "", &[], "subject");
8681 assert!(html.contains("[R3-1-1] minor"));
8682 assert!(html.contains(if language == "ja" {
8683 "内部指摘の解決を意味するものではありません"
8684 } else {
8685 "This does not mean internal findings are resolved"
8686 }));
8687 assert!(
8688 html.find(if language == "ja" {
8689 "承認が必要な理由:"
8690 } else {
8691 "Approval required because:"
8692 })
8693 .unwrap()
8694 < html.find("[R3-1-1]").unwrap()
8695 );
8696 }
8697 state.config.graph.language = "en".into();
8698 let observed = seen("abc", Checks::Green, "CLEAN", false);
8699 assert_eq!(
8700 approval_gate(&mut state, &observed.pr, "subject", None, "abc")
8701 .await
8702 .unwrap(),
8703 ApprovalGate::Pending
8704 );
8705 let id = state.land_approval.as_ref().unwrap().question.clone();
8706 let q = ask::Questions::open().get(&id).unwrap();
8707 assert!(q.detail.starts_with("Approval required because:"));
8708 assert!(q.detail.contains("final reviewer reject"));
8709 assert!(q.detail.contains("[R3-1-1] minor"));
8710 assert!(
8711 q.detail
8712 .contains("No adoption or resolution evidence recorded")
8713 );
8714 state.config.graph.land_approval = false;
8715 state.config.graph.hold_contested_merge = false;
8716 state.reviews.clear();
8717 assert_eq!(approval_reasons(&state), [ApprovalReason::ExistingDecision]);
8718 assert!(deputy_brief(&q, Some(&state)).contains("final reviewer reject"));
8719 assert!(deputy_brief(&q, None).contains("[R3-1-1] minor"));
8720 let forge = Scripted::new(vec![observed], vec![]);
8721 land_with(&mut state, URL, &forge).await.unwrap();
8722 assert!(!forge.calls().contains(&"merge"));
8723 assert_eq!(state.land_approval.as_ref().unwrap().question, id);
8724 assert_eq!(ask::Questions::open().get(&id).unwrap().detail, q.detail);
8725 }
8726
8727 #[tokio::test]
8728 async fn approval_evidence_keeps_all_findings_and_bounds_unicode_prose() {
8729 let mut state = repair_state(false, true);
8730 state.config.graph.land_approval = false;
8731 let long_reason = format!("{}REASON_TAIL", "拒否🦀".repeat(200));
8732 let long_detail = format!("{}DETAIL_TAIL", "詳細🦀".repeat(200));
8733 let long_title = format!("{}TITLE_TAIL", "題🦀".repeat(100));
8734 state.reviews[0].reviews[0].summary = long_reason.clone();
8735 for i in 1..=10 {
8736 let mut finding = contested().findings.remove(0);
8737 finding.id = format!("R1-1-{i}");
8738 finding.severity = if i == 9 {
8739 crate::verdict::Severity::Major
8740 } else {
8741 crate::verdict::Severity::Minor
8742 };
8743 finding.title = long_title.clone();
8744 finding.detail = long_detail.clone();
8745 state.reviews[0].reviews[0].findings.push(finding);
8746 }
8747 state.reviews[0].fix = Some(crate::run::FixRecord {
8748 agent: "fixer".into(),
8749 addressed: vec!["R1-1-9".into()],
8750 rejected: vec![crate::verdict::Rejection {
8751 id: "R1-1-10".into(),
8752 why: long_reason.clone(),
8753 }],
8754 notes: long_detail.clone(),
8755 committed: false,
8756 failed: None,
8757 duration_ms: 0,
8758 continuation: None,
8759 });
8760 assert_eq!(
8761 approval_reasons(&state),
8762 [
8763 ApprovalReason::OpenBlockingFindings,
8764 ApprovalReason::FinalReject
8765 ]
8766 );
8767 let excerpt = approval_excerpt(&long_reason, 300);
8768 assert_eq!(excerpt.chars().count(), 300);
8769 assert!(excerpt.ends_with('…'));
8770 let boundary = "拒".repeat(300);
8771 assert_eq!(approval_excerpt(&boundary, 300), boundary);
8772 for language in ["en", "ja"] {
8773 state.config.graph.language = language.into();
8774 for revote in [
8775 None,
8776 Some(crate::verdict::ReviewVote::Reject),
8777 Some(crate::verdict::ReviewVote::Approve),
8778 ] {
8779 state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8780 state.reviews[0].reconsideration.clear();
8781 if let Some(vote) = revote {
8782 state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Approve);
8783 state.reviews[0]
8784 .reconsideration
8785 .push(crate::run::ReviewRevoteRecord {
8786 reviewer: 1,
8787 agent: "alpha".into(),
8788 vote: Some(vote),
8789 reason: long_reason.clone(),
8790 failed: None,
8791 });
8792 }
8793 let evidence = approval_evidence(&state);
8794 let final_vote = revote.unwrap_or(crate::verdict::ReviewVote::Reject);
8795 assert!(evidence.contains(&format!("#1 (alpha): {}", final_vote.label())));
8796 assert!(approval_reasons(&state).contains(&ApprovalReason::OpenBlockingFindings));
8797 assert_eq!(
8798 approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8799 final_vote == crate::verdict::ReviewVote::Reject
8800 );
8801 assert!(evidence.contains(&excerpt));
8802 assert!(evidence.contains("[R1-1-8] minor"));
8803 assert!(evidence.contains("[R1-1-9] major"));
8804 assert!(evidence.contains("[R1-1-10] minor"));
8805 for tail in ["REASON_TAIL", "DETAIL_TAIL", "TITLE_TAIL"] {
8806 assert!(!evidence.contains(tail));
8807 }
8808 assert!(evidence.contains(if language == "ja" {
8809 "採用申告(解決未確認)"
8810 } else {
8811 "claimed addressed (resolution unconfirmed)"
8812 }));
8813 assert!(evidence.contains(if language == "ja" {
8814 "不採用理由"
8815 } else {
8816 "declined"
8817 }));
8818 let major_evidence = evidence
8819 .split("[R1-1-9] major")
8820 .nth(1)
8821 .unwrap()
8822 .split("[R1-1-10]")
8823 .next()
8824 .unwrap();
8825 assert!(major_evidence.contains(if language == "ja" {
8826 "採用申告(解決未確認)"
8827 } else {
8828 "claimed addressed (resolution unconfirmed)"
8829 }));
8830 let last_evidence = evidence.split("[R1-1-10] minor").nth(1).unwrap();
8831 assert!(last_evidence.contains(if language == "ja" {
8832 "不採用理由"
8833 } else {
8834 "declined"
8835 }));
8836 let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8837 assert!(html.contains(&esc(&evidence)));
8838 assert_eq!(
8839 approval_gate(&mut state, &green_pr(), "subject", None, "abc")
8840 .await
8841 .unwrap(),
8842 ApprovalGate::Pending
8843 );
8844 let id = &state.land_approval.take().unwrap().question;
8845 let q = ask::Questions::open().get(id).unwrap();
8846 assert!(q.detail.starts_with(&evidence));
8847 }
8848 state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8849 }
8850 }
8851
8852 #[test]
8853 fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
8854 for (major, reject) in [(true, false), (false, true), (true, true)] {
8855 let state = repair_state(major, reject);
8856 assert!(repair_available(&state));
8857 assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
8858 }
8859 assert!(!repair_available(&repair_state(false, false)));
8860 let mut clean = repair_state(true, false);
8861 clean.reviews[0].clean = true;
8862 assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
8863 assert_eq!(approval(Some(REPAIR)), Approval::Repair);
8864 }
8865
8866 #[tokio::test]
8867 async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
8868 let mut state = repair_state(true, false);
8869 let pr = green_pr();
8870 approval_gate(&mut state, &pr, "feat: x", None, "abc")
8871 .await
8872 .unwrap();
8873 let store = ask::Questions::open();
8874 let id = state.land_approval.as_ref().unwrap().question.clone();
8875 store
8877 .update(&id, |q| {
8878 q.choices = vec![APPROVE.into(), HOLD.into()];
8879 Ok(())
8880 })
8881 .unwrap();
8882 let upgraded = store.get(&id).unwrap();
8883 assert_eq!(upgraded.choices, approval_choices(&state));
8884 assert!(!upgraded.actions.contains_key(HOLD));
8885 assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
8886 assert!(
8887 store
8888 .list()
8889 .iter()
8890 .any(|q| q.id == id && q.choices == upgraded.choices)
8891 );
8892 assert!(
8893 crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
8894 );
8895 assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
8896 store
8897 .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
8898 .unwrap();
8899 store
8902 .update(&id, |q| {
8903 q.actions.clear();
8904 Ok(())
8905 })
8906 .unwrap();
8907 assert_eq!(
8908 store.get(&id).unwrap().chosen_action(),
8909 Some(&ask::ChoiceAction::Resume {
8910 run: state.id.clone()
8911 })
8912 );
8913 let binding = state.land_approval.clone();
8914 assert_eq!(
8915 approval_gate(&mut state, &pr, "feat: x", None, "abc")
8916 .await
8917 .unwrap(),
8918 ApprovalGate::Repair
8919 );
8920 assert!(state.land_approval.is_none());
8921 assert_eq!(state.merge_repairs.len(), 1);
8922 let request = &state.merge_repairs[0];
8923 assert_eq!(request.question, id);
8924 assert_eq!(request.head, "abc");
8925 assert_eq!(request.pr, pr.url);
8926 assert_eq!(
8927 request.reviews[0].reviews[0].summary,
8928 "precise rejection reason"
8929 );
8930 assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
8931 assert_eq!(
8932 request.reviews[0].reviews[0].findings[0].detail,
8933 state.reviews[0].reviews[0].findings[0].detail
8934 );
8935 state.land_approval = binding;
8937 assert_eq!(
8938 approval_gate(&mut state, &pr, "feat: x", None, "abc")
8939 .await
8940 .unwrap(),
8941 ApprovalGate::Repair
8942 );
8943 assert_eq!(state.merge_repairs.len(), 1);
8944 state.land_approval = None;
8945 state.config.graph.land_approval = false;
8946 state.config.graph.hold_contested_merge = false;
8947 state.reviews.clear();
8948 assert_eq!(approval_reasons(&state), [ApprovalReason::AfterRepair]);
8949 assert_eq!(
8950 approval_gate(&mut state, &pr, "feat: x", None, "abc")
8951 .await
8952 .unwrap(),
8953 ApprovalGate::Pending
8954 );
8955 let fresh_id = &state.land_approval.as_ref().unwrap().question;
8956 assert_ne!(fresh_id, &id);
8957 let fresh = store.get(fresh_id).unwrap();
8958 assert!(
8959 fresh
8960 .detail
8961 .contains("fresh owner approval after merge repair")
8962 );
8963 assert!(fresh.detail.contains("No review recorded"));
8964 assert!(!fresh.detail.contains("precise rejection reason"));
8965 }
8966
8967 #[tokio::test]
8968 async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
8969 for choice in [REPAIR, HOLD] {
8970 let mut state = repair_state(false, true);
8971 let observed = seen("abc", Checks::Green, "CLEAN", false);
8972 approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
8973 .await
8974 .unwrap();
8975 let id = state.land_approval.as_ref().unwrap().question.clone();
8976 ask::Questions::open()
8977 .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
8978 .unwrap();
8979 let forge = Scripted::new(vec![observed], vec![(true, "")]);
8980 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8981 .await
8982 .unwrap();
8983 assert!(
8984 forge
8985 .argvs()
8986 .iter()
8987 .all(|a| a.iter().any(|s| s == "--disable-auto"))
8988 );
8989 assert!(!forge.calls().contains(&"fix"));
8990 assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
8991 assert_ne!(state.status, RunStatus::Merged);
8992 }
8993 }
8994
8995 #[test]
8996 fn only_the_merge_choice_merges_and_silence_holds() {
8997 let table = [
8998 (None, Approval::Hold),
8999 (Some("merge"), Approval::Merge),
9000 (Some(" merge\n"), Approval::Merge),
9001 (Some("hold"), Approval::Hold),
9002 (Some(""), Approval::Hold),
9003 (Some("yes"), Approval::Hold),
9004 ];
9005 for (answer, want) in table {
9006 assert_eq!(
9007 approval(answer),
9008 want,
9009 "answer {answer:?} must resolve to {want:?}"
9010 );
9011 }
9012 }
9013
9014 #[tokio::test]
9015 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
9016 let mut state = landing_state();
9017 state.config.graph.land_approval = true;
9018 let pr = green_pr();
9019
9020 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
9021 .await
9022 .unwrap();
9023 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
9024 assert!(
9025 !state.parked,
9026 "approval_gate itself never sets `parked`; only its caller does"
9027 );
9028
9029 let store = ask::Questions::open();
9030 let filed: Vec<_> = store
9031 .list()
9032 .into_iter()
9033 .filter(|q| q.run == state.id)
9034 .collect();
9035 assert_eq!(filed.len(), 1, "exactly one question is filed");
9036 assert_eq!(filed[0].node, APPROVAL_NODE);
9037 assert_eq!(filed[0].choices, approval_choices(&state));
9038 assert!(filed[0].status.open());
9039
9040 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
9044 .await
9045 .unwrap();
9046 assert_eq!(again, ApprovalGate::Pending);
9047 let still_one = store
9048 .list()
9049 .into_iter()
9050 .filter(|q| q.run == state.id)
9051 .count();
9052 assert_eq!(
9053 still_one, 1,
9054 "asking twice must not double-file the question"
9055 );
9056 }
9057
9058 #[tokio::test]
9059 async fn approving_the_existing_question_is_read_back_as_approved() {
9060 crate::run::pin_test_home();
9061 let mut state = run_state();
9062 state.config.graph.land_approval = true;
9063 let pr = green_pr();
9064 assert_eq!(
9065 approval_gate(&mut state, &pr, "feat: x", None, "abc")
9066 .await
9067 .unwrap(),
9068 ApprovalGate::Pending
9069 );
9070
9071 let store = ask::Questions::open();
9072 let mut q = store
9073 .list()
9074 .into_iter()
9075 .find(|q| q.run == state.id)
9076 .expect("filed above");
9077 let talks = crate::talk::Talks::open();
9078 let cfg = crate::config::Config {
9079 agents: vec![crate::config::AgentSpec {
9080 id: "mock".into(),
9081 kind: crate::config::AgentKind::Command,
9082 model: None,
9083 command: vec!["true".into()],
9084 extra_args: Vec::new(),
9085 env: Default::default(),
9086 prompt_delivery: None,
9087 max_turn_secs: None,
9088 }],
9089 ..crate::config::Config::default()
9090 };
9091 let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
9092 crate::consult::begin(&store, &talks, &q, &talk).unwrap();
9093 crate::talk::drain(&mut talk, &talks).unwrap();
9094 assert_eq!(
9095 approval_gate(&mut state, &pr, "feat: x", None, "abc")
9096 .await
9097 .unwrap(),
9098 ApprovalGate::Pending,
9099 "consultation cannot approve or hold the merge"
9100 );
9101 q = store.get(&q.id).unwrap();
9102 assert!(q.answer.is_none());
9103 talk.turns.push(crate::talk::Turn {
9104 sender: None,
9105 breaks: Some(Vec::new()),
9106 messages: Vec::new(),
9107 posted_by: None,
9108 who: crate::talk::Who::Operator,
9109 body: "merge abc".into(),
9110 at: Timestamp::now(),
9111 attachments: Vec::new(),
9112 usage: None,
9113 });
9114 talks.put(&mut talk).unwrap();
9115 crate::consult::validate_answer(
9116 &q,
9117 &talks,
9118 &talk.id,
9119 crate::queue::CHAT_NODE,
9120 APPROVE,
9121 Some("merge abc"),
9122 )
9123 .unwrap();
9124 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
9125 store.put(&mut q).unwrap();
9126
9127 assert_eq!(
9128 approval_gate(&mut state, &pr, "feat: x", None, "abc")
9129 .await
9130 .unwrap(),
9131 ApprovalGate::Approved
9132 );
9133 }
9134
9135 #[tokio::test]
9136 async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
9137 crate::run::pin_test_home();
9138 let store = ask::Questions::open();
9139 for via_list in [false, true] {
9140 let mut state = repair_state(false, true);
9141 let pr = green_pr();
9142 approval_gate(&mut state, &pr, "feat: x", None, "abc")
9143 .await
9144 .unwrap();
9145 let id = state.land_approval.as_ref().unwrap().question.clone();
9146 store
9147 .update(&id, |q| {
9148 q.actions.insert(
9149 HOLD.to_owned(),
9150 ask::ChoiceAction::Resume {
9151 run: state.id.clone(),
9152 },
9153 );
9154 Ok(())
9155 })
9156 .unwrap();
9157 let q = if via_list {
9158 store.list().into_iter().find(|q| q.id == id).unwrap()
9159 } else {
9160 store.get(&id).unwrap()
9161 };
9162 assert!(!q.actions.contains_key(HOLD));
9163 store
9164 .update(&id, |q| {
9165 assert!(!q.actions.contains_key(HOLD));
9167 assert!(!upgrade_repair_choice(q, &state));
9168 q.answer(ask::Answer::Choice(HOLD.to_owned()))
9169 })
9170 .unwrap();
9171 assert!(store.get(&id).unwrap().chosen_action().is_none());
9172 assert_eq!(
9173 approval_gate(&mut state, &pr, "feat: x", None, "abc")
9174 .await
9175 .unwrap(),
9176 ApprovalGate::Held
9177 );
9178 }
9179 }
9180
9181 #[tokio::test]
9182 async fn explicit_hold_stays_held_but_expired_approval_is_reasked() {
9183 crate::run::pin_test_home();
9184 let store = ask::Questions::open();
9185
9186 let mut held_state = run_state();
9187 held_state.config.graph.land_approval = true;
9188 let pr = green_pr();
9189 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
9190 .await
9191 .unwrap();
9192 let mut q = store
9193 .list()
9194 .into_iter()
9195 .find(|q| q.run == held_state.id)
9196 .expect("filed above");
9197 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
9198 store.put(&mut q).unwrap();
9199 assert_eq!(
9200 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
9201 .await
9202 .unwrap(),
9203 ApprovalGate::Held
9204 );
9205
9206 let mut abandoned_state = run_state();
9207 abandoned_state.config.graph.land_approval = true;
9208 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
9209 .await
9210 .unwrap();
9211 let mut q = store
9212 .list()
9213 .into_iter()
9214 .find(|q| q.run == abandoned_state.id)
9215 .expect("filed above");
9216 q.abandon("no answer within the timeout");
9217 store.put(&mut q).unwrap();
9218 assert_eq!(
9219 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
9220 .await
9221 .unwrap(),
9222 ApprovalGate::Pending,
9223 "an expired approval must ask again without merging"
9224 );
9225 assert_eq!(
9226 store.get(&q.id).unwrap().status,
9227 ask::QuestionStatus::Abandoned
9228 );
9229 assert_ne!(
9230 abandoned_state.land_approval.as_ref().unwrap().question,
9231 q.id
9232 );
9233 }
9234
9235 fn contested() -> ContestedHandoff {
9236 let finding = |id: &str, n: u32| crate::verdict::Finding {
9237 id: id.to_owned(),
9238 severity: crate::verdict::Severity::Major,
9239 file: Some("src/a.rs".to_owned()),
9240 line: Some(n),
9241 title: format!("problem {id}"),
9242 detail: String::new(),
9243 };
9244 ContestedHandoff {
9245 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
9246 rejecters: vec![(1, "alpha".to_owned())],
9247 }
9248 }
9249
9250 #[test]
9251 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
9252 let mut state = run_state();
9253 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
9254 state.contested_handoff = Some(contested());
9255 assert!(contested_to_ask(&state).is_some());
9256 state.config.graph.hold_contested_merge = false;
9257 assert!(
9258 contested_to_ask(&state).is_none(),
9259 "the switch restores today"
9260 );
9261 }
9262
9263 #[test]
9264 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
9265 let q = ask::Question::new(
9266 "run-1".to_owned(),
9267 APPROVAL_NODE.to_owned(),
9268 "land".to_owned(),
9269 "Merge?".to_owned(),
9270 String::new(),
9271 vec![APPROVE.to_owned(), HOLD.to_owned()],
9272 );
9273 let none = deputy_brief(&q, None);
9274 assert!(none.contains("could not be read"), "{none}");
9275 assert!(none.contains("Silence is a hold"), "{none}");
9276
9277 let mut state = run_state();
9278 state.pr = Some(crate::run::PrRecord {
9279 url: "https://example.test/pull/7".to_owned(),
9280 number: 7,
9281 state: "open".to_owned(),
9282 checks: "green".to_owned(),
9283 round: 0,
9284 rounds: 3,
9285 red_at_merge: Vec::new(),
9286 });
9287 state.contested_handoff = Some(contested());
9288 let b = deputy_brief(&q, Some(&state));
9289 assert!(b.contains("https://example.test/pull/7"), "{b}");
9290 assert!(b.contains("not recorded in this legacy question"), "{b}");
9291 assert!(!b.contains("This question was filed although"));
9292 state.contested_handoff = None;
9293 assert!(deputy_brief(&q, Some(&state)).contains("not recorded in this legacy question"));
9294 }
9295
9296 #[test]
9297 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
9298 for lang in ["en", "ja"] {
9299 let mut cfg = crate::config::Config::default();
9300 cfg.graph.language = lang.to_owned();
9301 let w = words(&cfg.graph.language);
9302 let text = w.approval_detail(
9303 "https://github.com/yukimemi/magi/pull/42",
9304 "main",
9305 "feat: x",
9306 Some(&contested()),
9307 );
9308 assert!(text.contains("pull/42"), "{text}");
9309 assert!(
9310 text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
9311 "{text}"
9312 );
9313 assert!(text.contains("R3-1-5"), "{text}");
9314 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
9315 assert!(text.contains("2"), "the rest are counted: {text}");
9316 assert!(text.contains("#1 (alpha)"), "{text}");
9317 }
9318 let plain = words("en").approval_detail("u", "main", "s", None);
9319 assert!(!plain.contains("reject"), "{plain}");
9320 }
9321
9322 #[tokio::test]
9323 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
9324 crate::run::pin_test_home();
9325 let mut state = run_state();
9326 state.config.graph.land_approval = false;
9327 state.contested_handoff = Some(contested());
9328 let pr = green_pr();
9329 let c = contested_to_ask(&state);
9330 assert_eq!(
9331 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9332 .await
9333 .unwrap(),
9334 ApprovalGate::Pending,
9335 "silence is a hold"
9336 );
9337 let store = ask::Questions::open();
9338 let filed: Vec<_> = store
9339 .list()
9340 .into_iter()
9341 .filter(|q| q.run == state.id)
9342 .collect();
9343 assert_eq!(filed.len(), 1);
9344 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
9345
9346 assert_eq!(
9347 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9348 .await
9349 .unwrap(),
9350 ApprovalGate::Pending
9351 );
9352 let mut q = store
9353 .list()
9354 .into_iter()
9355 .find(|q| q.run == state.id)
9356 .unwrap();
9357 assert_eq!(q.id, filed[0].id, "the same question after a resume");
9358 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
9359 store.put(&mut q).unwrap();
9360 assert_eq!(
9361 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9362 .await
9363 .unwrap(),
9364 ApprovalGate::Approved
9365 );
9366 }
9367
9368 #[test]
9369 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
9370 let rows = parse_numstat(NUMSTAT);
9371 assert_eq!(
9372 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
9373 ["src/web.rs", "src/land.rs", "assets/logo.png"]
9374 );
9375 assert_eq!(rows[2].added, None, "a binary file has no line counts");
9376 }
9377 #[test]
9378 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
9379 let mut state = run_state();
9383 state.config.graph.language = "ja".to_owned();
9384 let pr = green_pr();
9385 let commits = ["c1".to_owned()];
9386
9387 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
9388 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
9389 assert!(ja.contains("squash されるコミット"), "{ja}");
9390 assert!(ja.contains("レビューコメント"), "{ja}");
9391 assert!(ja.contains("差分"), "{ja}");
9392 assert!(
9393 !ja.contains("Commits being squashed"),
9394 "no English left over"
9395 );
9396
9397 let w = words("ja");
9398 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
9399 assert!(
9400 w.approval_detail("http://x/1", "main", "feat: x", None)
9401 .contains("パネル")
9402 );
9403
9404 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
9406 assert!(ja.contains("feat: x"), "nor is the merge subject");
9407
9408 state.config.graph.language = "en".to_owned();
9411 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
9412 assert!(en.contains("Commits being squashed"), "{en}");
9413 assert_eq!(words("Klingon").html_lang, "en");
9414 }
9415
9416 #[test]
9420 fn pick_open_pr_classifies_by_count_and_base() {
9421 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
9422 assert_eq!(
9423 pick_open_pr(one, "main").unwrap(),
9424 OpenPr::One {
9425 url: "https://x/pull/58".into(),
9426 title: "t".into()
9427 }
9428 );
9429 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
9430 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
9431 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
9432 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
9433 assert_eq!(
9434 pick_open_pr(two, "main").unwrap(),
9435 OpenPr::Many(vec!["u1".into(), "u2".into()])
9436 );
9437 let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
9439 assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
9440 assert!(pick_open_pr("not json", "main").is_err());
9441 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
9443 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
9444 }
9445
9446 #[test]
9447 fn pick_merged_pr_picks_the_unique_match() {
9448 let json = r#"[
9449 {"url": "https://github.com/o/r/pull/42", "number": 42,
9450 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
9451 ]"#;
9452 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9453 let found = pick_merged_pr(json, "main", created_at)
9454 .expect("valid json")
9455 .expect("one unambiguous match");
9456 assert_eq!(found.url, "https://github.com/o/r/pull/42");
9457 assert_eq!(found.number, 42);
9458 }
9459
9460 #[test]
9464 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
9465 let json = r#"[
9466 {"url": "https://github.com/o/r/pull/42", "number": 42,
9467 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
9468 {"url": "https://github.com/o/r/pull/43", "number": 43,
9469 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
9470 ]"#;
9471 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9472 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9473 }
9474
9475 #[test]
9479 fn pick_merged_pr_ignores_a_different_base_branch() {
9480 let json = r#"[
9481 {"url": "https://github.com/o/r/pull/42", "number": 42,
9482 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
9483 ]"#;
9484 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9485 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9486 }
9487
9488 #[test]
9491 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
9492 let json = r#"[
9493 {"url": "https://github.com/o/r/pull/42", "number": 42,
9494 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
9495 ]"#;
9496 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9497 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9498 }
9499
9500 #[test]
9501 fn slug_of_pr_url_reads_host_owner_and_repo() {
9502 assert_eq!(
9503 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
9504 Some("github.com/yukimemi/shun")
9505 );
9506 }
9507
9508 #[test]
9509 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
9510 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
9511 assert_eq!(slug_of_pr_url("not a url at all"), None);
9512 assert_eq!(slug_of_pr_url("https://github.com"), None);
9513 }
9514
9515 #[test]
9516 fn slug_of_repo_url_reads_host_owner_and_repo() {
9517 assert_eq!(
9518 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
9519 Some("github.com/yukimemi/magi")
9520 );
9521 assert_eq!(slug_of_repo_url("https://github.com"), None);
9522 }
9523
9524 #[test]
9525 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
9526 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
9527 .expect("same repo, different case");
9528 }
9529
9530 #[test]
9535 fn ensure_same_repo_refuses_a_different_repo() {
9536 let err =
9537 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
9538 let msg = format!("{err:#}");
9539 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9540 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
9541 }
9542
9543 #[test]
9548 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
9549 let err = ensure_same_repo(
9550 "github.com/yukimemi/magi",
9551 "github.example.com/yukimemi/magi",
9552 )
9553 .unwrap_err();
9554 let msg = format!("{err:#}");
9555 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9556 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
9557 }
9558
9559 #[tokio::test]
9564 async fn find_external_merge_returns_none_without_a_winner() {
9565 let state = RunState::new(
9566 PathBuf::from("/no/such/repo"),
9567 "main".to_owned(),
9568 "0000000000000000000000000000000000000000".to_owned(),
9569 "irrelevant".to_owned(),
9570 crate::config::Config::default(),
9571 );
9572 assert_eq!(find_external_merge(&state).await.unwrap(), None);
9573 }
9574
9575 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
9576 let mut run = RunState::new(
9577 PathBuf::from(repo),
9578 "main".to_owned(),
9579 "abcdef1234".to_owned(),
9580 "x".to_owned(),
9581 crate::config::Config::default(),
9582 );
9583 run.id = id.to_owned();
9584 run.status = status;
9585 run.driver_exited = true;
9586 run.pr = Some(crate::run::PrRecord {
9587 number: url.rsplit('/').next().unwrap().parse().unwrap(),
9588 url: url.to_owned(),
9589 state: state.to_owned(),
9590 checks: "red".to_owned(),
9591 round: 0,
9592 rounds: 2,
9593 red_at_merge: Vec::new(),
9594 });
9595 run.save_under(home).unwrap();
9596 }
9597
9598 fn recorded(home: &Path, id: &str) -> String {
9599 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
9600 serde_json::from_str::<RunState>(&body)
9601 .unwrap()
9602 .pr
9603 .unwrap()
9604 .state
9605 }
9606
9607 const PR: &str = "https://github.com/o/r/pull/7";
9608
9609 #[test]
9610 fn write_through_updates_predecessors_and_siblings_only() {
9611 let tmp = tempfile::tempdir().unwrap();
9612 let h = tmp.path();
9613 pr_run(
9614 h,
9615 "20261004-100000-aaaa",
9616 "/repo/r",
9617 RunStatus::Superseded,
9618 PR,
9619 "open",
9620 );
9621 pr_run(
9622 h,
9623 "20261004-100100-bbbb",
9624 "/repo/r",
9625 RunStatus::Blocked,
9626 PR,
9627 "open",
9628 );
9629 pr_run(
9631 h,
9632 "20261004-100200-cccc",
9633 "/repo/r",
9634 RunStatus::Landing,
9635 PR,
9636 "open",
9637 );
9638 pr_run(
9640 h,
9641 "20261004-100300-dddd",
9642 "/repo/other",
9643 RunStatus::Blocked,
9644 "https://github.com/o/other/pull/7",
9645 "open",
9646 );
9647 pr_run(
9649 h,
9650 "20261004-100400-eeee",
9651 "/repo/r",
9652 RunStatus::Blocked,
9653 "https://github.com/o/r/pull/8",
9654 "open",
9655 );
9656 pr_run(
9657 h,
9658 "20261004-100500-ffff",
9659 "/repo/r",
9660 RunStatus::Merged,
9661 PR,
9662 "open",
9663 );
9664 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
9665
9666 assert_eq!(
9667 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9668 2
9669 );
9670 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9671 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
9672 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9673 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9674 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9675 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
9677 assert_eq!(
9679 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9680 0
9681 );
9682 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
9683 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
9684 }
9685
9686 #[test]
9687 fn lifecycle_updates_preserve_live_and_unknown_terminal_drivers() {
9688 let home = tempfile::tempdir().unwrap();
9689 let live_id = "20261004-100000-aaaa";
9690 let unknown_id = "20261004-100100-bbbb";
9691 for id in [live_id, unknown_id] {
9692 pr_run(home.path(), id, "repo", RunStatus::Ready, PR, "open");
9693 let mut s = RunState::load_under(id, home.path()).unwrap();
9694 s.driver_exited = false;
9695 s.driver_claimed = None;
9697 if id == live_id {
9698 s.driver_pid = Some(std::process::id());
9699 s.driver_started_at = crate::proc::process_started_at(std::process::id());
9700 }
9701 s.save_under(home.path()).unwrap();
9702 if id == unknown_id {
9703 assert_eq!(
9704 s.liveness_under(home.path(), false),
9705 crate::run::Liveness::Unknown
9706 );
9707 }
9708 }
9709 let known = BTreeMap::from([(PR.to_owned(), PrLifecycle::Merged)]);
9710 assert_eq!(apply_pr_states(home.path(), &known), 0);
9711 for id in [live_id, unknown_id] {
9712 assert_eq!(recorded(home.path(), id), "open");
9713 }
9714 }
9715
9716 #[test]
9717 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
9718 let tmp = tempfile::tempdir().unwrap();
9719 let h = tmp.path();
9720 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
9721 pr_run(
9722 h,
9723 "20261004-100000-aaaa",
9724 "/repo/r",
9725 RunStatus::Superseded,
9726 &url(1),
9727 "open",
9728 );
9729 pr_run(
9730 h,
9731 "20261004-100100-bbbb",
9732 "/repo/r",
9733 RunStatus::Blocked,
9734 &url(2),
9735 "open",
9736 );
9737 pr_run(
9738 h,
9739 "20261004-100200-cccc",
9740 "/repo/r",
9741 RunStatus::Ready,
9742 &url(3),
9743 "open",
9744 );
9745 pr_run(
9746 h,
9747 "20261004-100300-dddd",
9748 "/repo/r",
9749 RunStatus::Ready,
9750 &url(4),
9751 "open",
9752 );
9753 pr_run(
9754 h,
9755 "20261004-100400-eeee",
9756 "/repo/r",
9757 RunStatus::Implementing,
9758 &url(1),
9759 "open",
9760 );
9761 assert_eq!(stale_open_prs(h).len(), 4);
9762
9763 let mut known = BTreeMap::new();
9764 known.insert(url(1), PrLifecycle::Merged);
9765 known.insert(url(2), PrLifecycle::Closed);
9766 known.insert(url(3), PrLifecycle::Open);
9767 assert_eq!(apply_pr_states(h, &known), 2);
9769 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9770 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
9771 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9772 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9773 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9774 assert_eq!(apply_pr_states(h, &known), 0);
9775 }
9776
9777 use std::collections::VecDeque;
9780 use std::sync::Mutex;
9781
9782 struct Scripted {
9785 evidence: Mutex<Option<CiEvidence>>,
9786 views: Mutex<VecDeque<Seen>>,
9787 merges: Mutex<VecDeque<(bool, String)>>,
9788 fix: Mutex<Option<Fixed>>,
9789 log: Mutex<Vec<&'static str>>,
9790 argvs: Mutex<Vec<Vec<String>>>,
9791 required: Mutex<Option<BTreeSet<String>>>,
9792 merged: Mutex<bool>,
9795 queued: Mutex<bool>,
9796 unreadable_after_merge: Mutex<bool>,
9798 reruns: Mutex<Vec<String>>,
9800 rerun_fails: Mutex<bool>,
9801 clock: Mutex<i64>,
9803 }
9804
9805 impl Scripted {
9806 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
9807 Self {
9808 evidence: Mutex::new(None),
9809 views: Mutex::new(views.into()),
9810 merges: Mutex::new(
9811 merges
9812 .into_iter()
9813 .map(|(ok, m)| (ok, m.to_owned()))
9814 .collect(),
9815 ),
9816 fix: Mutex::new(None),
9817 log: Mutex::new(Vec::new()),
9818 argvs: Mutex::new(Vec::new()),
9819 required: Mutex::new(None),
9820 merged: Mutex::new(false),
9821 queued: Mutex::new(false),
9822 unreadable_after_merge: Mutex::new(false),
9823 reruns: Mutex::new(Vec::new()),
9824 rerun_fails: Mutex::new(false),
9825 clock: Mutex::new(1_000),
9826 }
9827 }
9828 fn argvs(&self) -> Vec<Vec<String>> {
9829 self.argvs.lock().unwrap().clone()
9830 }
9831 fn calls(&self) -> Vec<&'static str> {
9832 self.log.lock().unwrap().clone()
9833 }
9834 }
9835
9836 impl Forge for Scripted {
9837 async fn attribution(&self, _state: &RunState, _seen: &Seen) -> Option<CiEvidence> {
9838 self.evidence.lock().unwrap().clone()
9839 }
9840 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
9841 self.log.lock().unwrap().push("view");
9842 if *self.unreadable_after_merge.lock().unwrap()
9843 && !self.argvs.lock().unwrap().is_empty()
9844 {
9845 anyhow::bail!("forge unreachable");
9846 }
9847 let mut v = self.views.lock().unwrap();
9848 let mut seen = if v.len() > 1 {
9849 v.pop_front().unwrap()
9850 } else {
9851 v[0].clone()
9852 };
9853 if *self.merged.lock().unwrap() {
9854 seen.pr.state = PrLifecycle::Merged;
9855 }
9856 Ok(seen)
9857 }
9858 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
9859 self.log.lock().unwrap().push("merge");
9860 self.argvs.lock().unwrap().push(argv.to_vec());
9861 let out = self
9862 .merges
9863 .lock()
9864 .unwrap()
9865 .pop_front()
9866 .expect("unscripted merge");
9867 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
9868 {
9869 *self.merged.lock().unwrap() = true;
9870 }
9871 Ok(out)
9872 }
9873 async fn poll(&self) {
9874 self.log.lock().unwrap().push("poll");
9875 *self.clock.lock().unwrap() += POLL.as_secs() as i64;
9876 }
9877 async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
9878 self.log.lock().unwrap().push("rerun");
9879 self.reruns.lock().unwrap().push(run.to_owned());
9880 if *self.rerun_fails.lock().unwrap() {
9881 anyhow::bail!("gh run rerun: no");
9882 }
9883 Ok(())
9884 }
9885 fn now(&self) -> i64 {
9886 *self.clock.lock().unwrap()
9887 }
9888 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
9889 self.required.lock().unwrap().clone()
9890 }
9891 async fn fix(
9892 &self,
9893 _state: &mut RunState,
9894 _pr: &PrState,
9895 _round: usize,
9896 _budget: usize,
9897 _reason: &str,
9898 _logs: &str,
9899 ) -> Result<Fixed> {
9900 self.log.lock().unwrap().push("fix");
9901 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
9902 }
9903 }
9904
9905 const REFUSED: &str =
9906 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
9907
9908 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
9909 let mut pr = green_pr();
9910 pr.checks = checks;
9911 pr.blocking = Blocking::of(merge_state);
9912 if !comments {
9913 pr.review_comments.clear();
9914 }
9915 Seen {
9916 pr,
9917 title: "feat: x".to_owned(),
9918 failing_urls: Vec::new(),
9919 head: head.to_owned(),
9920 rollup_head: head.to_owned(),
9921 merge_state: merge_state.to_owned(),
9922 contexts: Vec::new(),
9923 base: "main".to_owned(),
9924 }
9925 }
9926
9927 fn landing_state() -> RunState {
9928 crate::run::pin_test_home();
9929 let mut state = run_state();
9930 state.config.graph.land_approval = false;
9931 state
9932 }
9933
9934 #[test]
9935 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
9936 assert!(!awaiting_new_head(None, "aaa"));
9937 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
9938 assert!(awaiting_new_head(Some("abc123"), "def456"));
9939 assert!(awaiting_new_head(Some("abc123"), ""));
9940 }
9941
9942 #[test]
9943 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
9944 let open = |c, m: &str| seen("a", c, m, false);
9945 let table = [
9946 (None, false, Refused::Pending),
9947 (
9948 Some(open(Checks::Pending, "BLOCKED")),
9949 false,
9950 Refused::Pending,
9951 ),
9952 (
9953 Some(open(Checks::Unknown, "BLOCKED")),
9954 false,
9955 Refused::Pending,
9956 ),
9957 (
9958 Some(open(Checks::Green, "UNKNOWN")),
9959 false,
9960 Refused::Pending,
9961 ),
9962 (Some(open(Checks::Green, "")), false, Refused::Pending),
9963 (
9964 Some(open(Checks::Green, "BLOCKED")),
9965 false,
9966 Refused::Recheck,
9967 ),
9968 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
9969 ];
9970 for (after, rechecked, want) in table {
9971 assert_eq!(
9972 classify_refusal(after.as_ref(), rechecked, "a", CiExpectation::Expected),
9973 want
9974 );
9975 }
9976 let mut closed = open(Checks::Green, "CLEAN");
9977 closed.pr.state = PrLifecycle::Closed;
9978 assert_eq!(
9979 classify_refusal(Some(&closed), false, "a", CiExpectation::Expected),
9980 Refused::Final
9981 );
9982 }
9983
9984 #[tokio::test]
9985 async fn a_normal_landing_merges_on_the_first_look() {
9986 let mut state = landing_state();
9987 let forge = Scripted::new(
9988 vec![seen("a", Checks::Green, "CLEAN", false)],
9989 vec![(true, "")],
9990 );
9991 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9992 .await
9993 .unwrap();
9994 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9996 assert_eq!(state.status, RunStatus::Merged);
9997 }
9998
9999 #[tokio::test]
10000 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
10001 let mut state = landing_state();
10002 let forge = Scripted::new(
10003 vec![seen("a", Checks::Green, "CLEAN", false)],
10004 std::iter::repeat_n((true, ""), 100).collect(),
10005 );
10006 *forge.queued.lock().unwrap() = true;
10007 let task = async {
10008 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10009 .await
10010 .unwrap();
10011 };
10012 task.await;
10015 assert_ne!(state.status, RunStatus::Merged);
10016 }
10017
10018 #[tokio::test]
10019 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
10020 let mut state = landing_state();
10021 let forge = Scripted::new(
10022 vec![seen("a", Checks::Green, "CLEAN", false)],
10023 std::iter::repeat_n((true, ""), 100).collect(),
10024 );
10025 *forge.unreadable_after_merge.lock().unwrap() = true;
10026 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10027 .await
10028 .ok();
10029 assert_ne!(state.status, RunStatus::Merged);
10030 }
10031
10032 #[tokio::test]
10033 async fn a_task_public_head_is_awaited_on_land_entry() {
10034 let mut state = landing_state();
10035 state.pr_head = Some("magi/task-abcd-1234".into());
10036 state.pr_pushed_head = Some("new".into());
10037 let forge = Scripted::new(
10038 vec![
10039 seen("old", Checks::Green, "CLEAN", false),
10040 seen("new", Checks::Green, "CLEAN", false),
10041 ],
10042 vec![(true, "")],
10043 );
10044 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10045 .await
10046 .unwrap();
10047 assert_eq!(
10048 forge.calls(),
10049 ["view", "poll", "view", "view", "merge", "view"]
10050 );
10051 assert_eq!(state.status, RunStatus::Merged);
10052 }
10053
10054 #[tokio::test]
10055 async fn a_superseded_public_head_owner_cannot_reenter_land() {
10056 let mut state = landing_state();
10057 state.pr_taken_over_by = Some("next".into());
10058 let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
10059 assert!(
10060 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10061 .await
10062 .is_err()
10063 );
10064 assert!(forge.calls().is_empty());
10065 }
10066
10067 #[tokio::test]
10068 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
10069 let mut state = landing_state();
10070 let forge = Scripted::new(
10071 vec![
10072 seen("old", Checks::Green, "CLEAN", true),
10073 seen("old", Checks::Green, "CLEAN", true),
10075 seen("new", Checks::Pending, "BLOCKED", true),
10076 seen("new", Checks::Green, "CLEAN", true),
10077 ],
10078 vec![(true, "")],
10079 );
10080 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10081 head: "NEW".to_owned(),
10082 });
10083 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10084 .await
10085 .unwrap();
10086 assert_eq!(
10087 forge.calls(),
10088 [
10089 "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
10090 "view"
10091 ]
10092 );
10093 assert_eq!(state.status, RunStatus::Merged);
10094 }
10095
10096 #[tokio::test]
10097 async fn a_head_that_never_arrives_stops_naming_both_commits() {
10098 let mut state = landing_state();
10099 let forge = Scripted::new(
10100 vec![
10101 seen("old", Checks::Green, "CLEAN", true),
10102 seen("someone-elses", Checks::Green, "CLEAN", true),
10103 ],
10104 vec![],
10105 );
10106 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10107 head: "mine".to_owned(),
10108 });
10109 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10110 .await
10111 .unwrap();
10112 assert!(!forge.calls().contains(&"merge"));
10113 let why = state.merge.as_ref().unwrap().detail.clone();
10114 assert!(
10115 why.contains("mine") && why.contains("someone-elses"),
10116 "{why}"
10117 );
10118 assert_eq!(state.status, RunStatus::Blocked);
10119 }
10120
10121 #[tokio::test]
10122 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
10123 let mut state = landing_state();
10124 let forge = Scripted::new(
10125 vec![
10126 seen("a", Checks::Green, "CLEAN", false),
10127 seen("a", Checks::Green, "CLEAN", false),
10128 seen("a", Checks::Pending, "BLOCKED", false),
10129 seen("a", Checks::Pending, "BLOCKED", false),
10130 seen("a", Checks::Green, "CLEAN", false),
10131 ],
10132 vec![(false, REFUSED), (true, "")],
10133 );
10134 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10135 .await
10136 .unwrap();
10137 assert_eq!(
10138 forge.calls(),
10139 [
10140 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
10141 "view"
10142 ]
10143 );
10144 assert_eq!(state.status, RunStatus::Merged);
10145 }
10146
10147 #[tokio::test]
10148 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
10149 let mut state = landing_state();
10150 let forge = Scripted::new(
10151 vec![
10152 seen("a", Checks::Green, "CLEAN", false),
10153 seen("a", Checks::Green, "BLOCKED", false),
10154 ],
10155 vec![(false, REFUSED), (false, REFUSED)],
10156 );
10157 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10158 .await
10159 .unwrap();
10160 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
10162 let why = state.merge.as_ref().unwrap().detail.clone();
10163 assert!(
10164 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
10165 "{why}"
10166 );
10167 assert_eq!(state.status, RunStatus::Blocked);
10168 }
10169
10170 #[test]
10171 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
10172 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
10173 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
10174 assert_eq!(bound_head("old", "old", Some("new")), None);
10176 assert_eq!(bound_head("new", "old", Some("new")), None);
10178 assert_eq!(bound_head("new", "old", None), None);
10179 assert_eq!(bound_head("", "", None), None);
10181 assert_eq!(bound_head("", "", Some("new")), None);
10182 assert_eq!(bound_head("abc", "", None), None);
10183 }
10184
10185 fn view_json(head: &str) -> String {
10186 format!(
10187 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
10188 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
10189 "reviews":[],"comments":[]}}"#
10190 )
10191 }
10192
10193 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
10194 format!(
10195 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
10196 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
10197 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
10198 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
10199 )
10200 }
10201
10202 #[test]
10203 fn rollup_is_bound_to_the_commit_in_the_same_node() {
10204 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
10207 assert_eq!(s.rollup_head, "old");
10208 assert_eq!(s.pr.checks, Checks::Red);
10209 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
10210 assert_eq!(s.failing_urls.len(), 1);
10211 }
10212
10213 #[test]
10214 fn checks_come_from_the_node_not_the_view() {
10215 let view = view_json("new").replace(
10216 r#""reviews""#,
10217 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
10218 );
10219 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
10220 assert_eq!(s.pr.checks, Checks::Green);
10221 assert!(s.pr.failing.is_empty());
10222 assert_eq!(
10223 bound_head(&s.head, &s.rollup_head, Some("new")),
10224 Some("new")
10225 );
10226 }
10227
10228 #[test]
10229 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
10230 for node in [
10231 None,
10232 Some("not json".to_owned()),
10233 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
10234 Some(node_json("new", "SUCCESS", true)),
10235 ] {
10236 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
10237 assert!(s.rollup_head.is_empty());
10238 assert_eq!(s.pr.checks, Checks::Unknown);
10239 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
10240 }
10241 }
10242
10243 #[test]
10244 fn the_merge_command_is_pinned_to_the_observed_head() {
10245 let argv = merge_argv_at(7, "feat: x", "deadbeef");
10246 let at = argv
10247 .iter()
10248 .position(|a| a == "--match-head-commit")
10249 .unwrap();
10250 assert_eq!(argv[at + 1], "deadbeef");
10251 }
10252
10253 #[tokio::test]
10254 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
10255 let mut state = landing_state();
10256 let mut stale = seen("new", Checks::Red, "CLEAN", false);
10259 stale.rollup_head = "old".to_owned();
10260 let forge = Scripted::new(
10261 vec![seen("old", Checks::Green, "CLEAN", true), stale],
10262 vec![],
10263 );
10264 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10265 head: "new".to_owned(),
10266 });
10267 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10268 .await
10269 .unwrap();
10270 assert!(!forge.calls().contains(&"merge"));
10271 assert_eq!(state.status, RunStatus::Blocked);
10272 let why = state.merge.as_ref().unwrap().detail.clone();
10273 assert!(why.contains("new") && why.contains("old"), "{why}");
10274 }
10275
10276 #[test]
10277 fn a_refusal_read_against_another_commits_checks_is_pending() {
10278 let mut after = seen("a", Checks::Green, "BLOCKED", false);
10279 after.rollup_head = "old".to_owned();
10280 assert_eq!(
10281 classify_refusal(Some(&after), true, "a", CiExpectation::Expected),
10282 Refused::Pending
10283 );
10284 }
10285
10286 #[tokio::test]
10287 async fn absent_ci_empty_rollup_reaches_approval_then_merges_on_the_approved_head() {
10288 let mut state = landing_state();
10289 state.config.merge.ci = crate::config::MergeCi::None;
10290 state.config.graph.land_approval = true;
10291 let forge = Scripted::new(
10292 vec![seen("a", Checks::Unknown, "CLEAN", false)],
10293 vec![(true, "")],
10294 );
10295 land_with(&mut state, URL, &forge).await.unwrap();
10296 assert!(state.parked);
10297 assert_eq!(forge.calls(), ["view"]);
10298 let store = ask::Questions::open();
10299 let mut q = store
10300 .get(&state.land_approval.as_ref().unwrap().question)
10301 .unwrap();
10302 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
10303 store.put(&mut q).unwrap();
10304
10305 land_with(&mut state, URL, &forge).await.unwrap();
10306 assert_eq!(forge.calls(), ["view", "view", "view", "merge", "view"]);
10307 assert_eq!(state.status, RunStatus::Merged);
10308 assert_eq!(
10309 value_of(&forge.argvs()[0], "--match-head-commit"),
10310 Some("a")
10311 );
10312 }
10313
10314 #[tokio::test]
10315 async fn absent_ci_ignores_failed_workflow_runs_without_rerunning_them() {
10316 let mut state = landing_state();
10317 state.config.merge.ci = crate::config::MergeCi::None;
10318 let forge = Scripted::new(vec![red("a", Some("7"))], vec![(true, "")]);
10319 land_with(&mut state, URL, &forge).await.unwrap();
10320 assert!(forge.reruns.lock().unwrap().is_empty());
10321 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10322 assert_eq!(state.status, RunStatus::Merged);
10323 }
10324
10325 #[test]
10326 fn absent_ci_still_requires_a_readable_matching_head_and_resolved_comments() {
10327 let shown = BTreeSet::new();
10328 let guard = |s: Option<&Seen>| {
10329 direct_merge_is_safe(s, "a", &shown, 0, 4, Duration::ZERO, CiExpectation::Absent)
10330 };
10331 let mut fresh = seen("a", Checks::Unknown, "CLEAN", false);
10332 assert!(guard(Some(&fresh)));
10333 assert!(!guard(None));
10334 fresh.rollup_head = "old".to_owned();
10335 assert!(!guard(Some(&fresh)));
10336 fresh.rollup_head = "a".to_owned();
10337 fresh.head = "b".to_owned();
10338 assert!(!guard(Some(&fresh)));
10339 assert!(!guard(Some(&seen("a", Checks::Unknown, "CLEAN", true))));
10340 assert!(!guard(Some(&seen("a", Checks::Unknown, "DIRTY", false))));
10341 }
10342
10343 #[test]
10344 fn absent_ci_refusal_does_not_wait_for_third_party_checks() {
10345 for checks in [Checks::Unknown, Checks::Pending, Checks::Red, Checks::Green] {
10346 let after = seen("a", checks, "BLOCKED", false);
10347 assert_eq!(
10348 classify_refusal(Some(&after), false, "a", CiExpectation::Absent),
10349 Refused::Recheck
10350 );
10351 assert_eq!(
10352 classify_refusal(Some(&after), true, "a", CiExpectation::Absent),
10353 Refused::Final
10354 );
10355 }
10356 }
10357
10358 #[tokio::test]
10359 async fn a_matching_head_with_red_non_required_checks_still_merges() {
10360 let mut state = landing_state();
10361 let forge = Scripted::new(
10362 vec![seen("a", Checks::Red, "CLEAN", false)],
10363 vec![(true, "")],
10364 );
10365 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10366 .await
10367 .unwrap();
10368 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10369 assert_eq!(state.status, RunStatus::Merged);
10370 }
10371
10372 #[tokio::test]
10373 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
10374 let mut state = landing_state();
10375 let forge = Scripted::new(
10376 vec![
10377 seen("a", Checks::Green, "CLEAN", false),
10378 seen("a", Checks::Green, "CLEAN", false),
10379 seen("b", Checks::Green, "BLOCKED", false),
10381 seen("b", Checks::Green, "CLEAN", false),
10382 ],
10383 vec![(false, REFUSED), (true, "")],
10384 );
10385 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10386 .await
10387 .unwrap();
10388 assert_eq!(
10389 forge.calls(),
10390 [
10391 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
10392 ]
10393 );
10394 assert_eq!(state.status, RunStatus::Merged);
10395 }
10396
10397 fn merged_view(head: &str) -> Seen {
10398 let mut m = seen(head, Checks::Green, "CLEAN", false);
10399 m.pr.state = PrLifecycle::Merged;
10400 m
10401 }
10402
10403 fn has(argv: &[String], flag: &str) -> bool {
10404 argv.iter().any(|a| a == flag)
10405 }
10406
10407 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
10408 let at = argv.iter().position(|a| a == flag)?;
10409 argv.get(at + 1).map(String::as_str)
10410 }
10411
10412 const URL: &str = "https://github.com/o/r/pull/42";
10413
10414 #[tokio::test]
10415 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
10416 let mut state = landing_state();
10417 let forge = Scripted::new(
10418 vec![
10419 seen("abc", Checks::Green, "CLEAN", false),
10420 seen("abc", Checks::Green, "CLEAN", false),
10421 ],
10422 vec![(true, "")],
10423 );
10424 land_with(&mut state, URL, &forge).await.unwrap();
10425 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10426 let argv = &forge.argvs()[0];
10427 assert!(has(argv, "--squash") && has(argv, "--subject"));
10428 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
10429 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
10430 assert_eq!(state.status, RunStatus::Merged);
10431 assert!(state.land_armed_head.is_none());
10432 }
10433
10434 #[tokio::test]
10435 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
10436 let mut state = landing_state();
10437 state.land_armed_head = Some("a".to_owned());
10438 let forge = Scripted::new(
10439 vec![seen("a", Checks::Green, "CLEAN", false)],
10440 vec![(true, ""), (true, "")],
10441 );
10442 land_with(&mut state, URL, &forge).await.unwrap();
10443 let argvs = forge.argvs();
10444 assert!(has(&argvs[0], "--disable-auto"));
10445 assert!(!has(&argvs[1], "--auto"));
10446 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
10447 assert_eq!(state.status, RunStatus::Merged);
10448 assert!(state.land_armed_head.is_none());
10449 }
10450
10451 #[tokio::test]
10452 async fn an_approval_never_carries_over_to_a_new_head() {
10453 crate::run::pin_test_home();
10454 let mut state = run_state();
10455 state.config.graph.land_approval = true;
10456 let pr = green_pr();
10457 let store = ask::Questions::open();
10458
10459 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
10460 .await
10461 .unwrap();
10462 let mut q = store
10463 .list()
10464 .into_iter()
10465 .find(|q| q.run == state.id)
10466 .unwrap();
10467 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
10468 store.put(&mut q).unwrap();
10469 assert_eq!(
10470 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
10471 .await
10472 .unwrap(),
10473 ApprovalGate::Approved,
10474 "the same head keeps its approval"
10475 );
10476
10477 assert_eq!(
10479 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
10480 .await
10481 .unwrap(),
10482 ApprovalGate::Pending
10483 );
10484 let all: Vec<_> = store
10485 .list()
10486 .into_iter()
10487 .filter(|q| q.run == state.id)
10488 .collect();
10489 assert_eq!(all.len(), 2);
10490
10491 state.land_approval = None;
10493 assert_eq!(
10494 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
10495 .await
10496 .unwrap(),
10497 ApprovalGate::Pending
10498 );
10499 let open = store
10500 .list()
10501 .into_iter()
10502 .filter(|q| q.run == state.id && q.status.open())
10503 .count();
10504 assert_eq!(open, 1, "the superseded question was retired");
10505 }
10506
10507 #[tokio::test]
10508 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
10509 let mut state = landing_state();
10510 let forge = Scripted::new(
10511 vec![
10512 seen("a", Checks::Green, "CLEAN", false),
10513 seen("b", Checks::Green, "CLEAN", false),
10515 ],
10516 vec![(true, "")],
10517 );
10518 land_with(&mut state, URL, &forge).await.unwrap();
10519 let argvs = forge.argvs();
10520 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
10521 assert!(!has(&argvs[0], "--auto"));
10522 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
10523 assert_eq!(state.status, RunStatus::Merged);
10524 }
10525
10526 fn passing(label: &str) -> CheckInfo {
10527 CheckInfo {
10528 label: label.to_owned(),
10529 verdict: Verdict::Pass,
10530 required: Some(false),
10531 run: None,
10532 }
10533 }
10534
10535 fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
10536 (label.to_owned(), run.map(str::to_owned))
10537 }
10538
10539 fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
10540 LandReruns {
10541 head: head.to_owned(),
10542 runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
10543 }
10544 }
10545
10546 #[test]
10547 fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
10548 let f = [failing("test (macos)", Some("7"))];
10549 assert_eq!(
10550 rerun_step(&f, None, "h", 100),
10551 RerunStep::Rerun(vec!["7".to_owned()])
10552 );
10553 let rec = reran("h", &[("7", 100)]);
10554 let grace = crate::release_watch::RERUN_GRACE;
10555 assert_eq!(
10556 rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
10557 RerunStep::Grace
10558 );
10559 assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
10560 }
10561
10562 #[test]
10563 fn a_failure_without_a_run_id_goes_to_the_fixer() {
10564 let f = [failing("a", Some("7")), failing("ci/external", None)];
10565 assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
10566 assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
10567 }
10568
10569 #[test]
10570 fn a_moved_head_forgets_the_reruns() {
10571 let f = [failing("a", Some("7"))];
10572 let rec = reran("old", &[("7", 100)]);
10573 assert_eq!(
10574 rerun_step(&f, Some(&rec), "new", 101),
10575 RerunStep::Rerun(vec!["7".to_owned()])
10576 );
10577 }
10578
10579 #[test]
10580 fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
10581 let f = [
10582 failing("a", Some("7")),
10583 failing("b", Some("7")),
10584 failing("c", Some("8")),
10585 ];
10586 assert_eq!(
10587 rerun_step(&f, None, "h", 0),
10588 RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
10589 );
10590 let rec = reran("h", &[("7", 0)]);
10591 assert_eq!(
10592 rerun_step(&f, Some(&rec), "h", 1),
10593 RerunStep::Rerun(vec!["8".to_owned()])
10594 );
10595 }
10596
10597 #[test]
10598 fn only_blocking_failures_are_candidates_for_a_rerun() {
10599 let check = |label: &str, verdict, required, run: &str| CheckInfo {
10600 label: label.to_owned(),
10601 verdict,
10602 required,
10603 run: Some(run.to_owned()),
10604 };
10605 let got = rerunnable(&[
10606 check("passed", Verdict::Pass, Some(true), "1"),
10607 check("optional", Verdict::Fail, Some(false), "2"),
10608 check("required", Verdict::Fail, Some(true), "3"),
10609 check("unknown", Verdict::Fail, None, "4"),
10610 ]);
10611 let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
10612 assert_eq!(labels, ["required", "unknown"]);
10613 }
10614
10615 fn unrelated_ci_fixture() -> CiEvidence {
10616 let failure = CiFailure {
10617 path: "tests/web_render.rs".into(),
10618 start_line: 42,
10619 end_line: 42,
10620 title: "question_identity_disclosure_copy_and_live_updates".into(),
10621 message: "assertion failed".into(),
10622 };
10623 CiEvidence {
10624 head: "a".into(),
10625 base: "base-sha".into(),
10626 changed: vec![
10627 "AGENTS.md".into(),
10628 "src/talk.rs".into(),
10629 "tests/consult_investigation.rs".into(),
10630 ],
10631 checks: vec![CiCheckEvidence {
10632 label: "coverage".into(),
10633 head_check: 70,
10634 base_check: 60,
10635 app: 1,
10636 run: "7".into(),
10637 base_run: "6".into(),
10638 head_failures: vec![failure.clone()],
10639 base_failures: vec![failure],
10640 rerun_exhausted: true,
10641 }],
10642 }
10643 }
10644
10645 #[test]
10646 fn unrelated_ci_requires_complete_structural_evidence_for_every_failure() {
10647 let good = unrelated_ci_fixture();
10648 let red = vec!["coverage".into()];
10649 assert!(good.unrelated("a", &red));
10650 assert!(!good.unrelated("another-head", &red));
10651 assert!(!good.unrelated("a", &["coverage".into(), "test".into()]));
10652 assert!(!good.unrelated("a", &[]));
10653 for mutation in 0..8 {
10654 let mut bad = good.clone();
10655 match mutation {
10656 0 => bad.checks[0].rerun_exhausted = false,
10657 1 => bad.checks[0].base_failures.clear(),
10658 2 => bad.checks[0].head_failures.clear(),
10659 3 => bad.changed.push("tests/web_render.rs".into()),
10660 4 => bad.changed.push(".github/workflows/ci.yml".into()),
10661 5 => bad.checks[0].base_failures[0].message = "different failure".into(),
10662 6 => bad.checks.push(bad.checks[0].clone()),
10663 _ => bad.base.clear(),
10664 }
10665 assert!(!bad.unrelated("a", &red), "mutation {mutation}");
10666 }
10667 }
10668
10669 #[test]
10670 fn red_unknown_merge_state_waits_before_repair_but_never_implies_merge() {
10671 let pr = seen("a", Checks::Red, "UNKNOWN", false).pr;
10672 assert_eq!(decide(&pr, 0, 2, Duration::ZERO), Step::Wait);
10673 assert!(matches!(decide(&pr, 0, 2, CHECKS_GRACE), Step::Fix { .. }));
10674 assert!(matches!(
10675 decide(&pr, 2, 2, CHECKS_GRACE),
10676 Step::GiveUp { .. }
10677 ));
10678 let optional = CheckInfo {
10679 label: "coverage".into(),
10680 verdict: Verdict::Fail,
10681 required: Some(false),
10682 run: Some("7".into()),
10683 };
10684 assert!(rerunnable_with(std::slice::from_ref(&optional), false).is_empty());
10685 assert_eq!(
10686 rerunnable_with(&[optional], true),
10687 vec![("coverage".into(), Some("7".into()))]
10688 );
10689 }
10690
10691 #[tokio::test]
10692 async fn unrelated_ci_after_a_comment_round_does_not_charge_a_no_commit_stop() {
10693 let mut state = landing_state();
10694 let mut red = coverage_unknown();
10695 red.pr = seen("a", Checks::Red, "BLOCKED", true).pr;
10696 red.pr.failing = vec!["coverage".into()];
10697 let forge = Scripted::new(vec![red], vec![]);
10698 *forge.evidence.lock().unwrap() = Some(unrelated_ci_fixture());
10699 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10700 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10701 assert!(forge.calls().contains(&"fix"));
10702 assert!(state.land_unrelated.is_some());
10703 assert!(state.parked);
10704 assert!(
10705 !state
10706 .events
10707 .iter()
10708 .any(|e| e.message.contains("fixer produced no commit"))
10709 );
10710 }
10711
10712 fn coverage_unknown() -> Seen {
10713 let mut s = red("a", Some("7"));
10714 s.pr.blocking = Blocking::Unsaid;
10715 s.merge_state = "UNKNOWN".into();
10716 s.pr.failing = vec!["coverage".into()];
10717 s.contexts[0].label = "coverage".into();
10718 s.contexts[0].required = Some(false);
10719 s
10720 }
10721
10722 #[tokio::test]
10723 async fn run_196c_unknown_then_unstable_does_not_spend_a_fix_round() {
10724 let mut state = landing_state();
10725 let unknown = coverage_unknown();
10726 let mut unstable = unknown.clone();
10727 unstable.pr.blocking = Blocking::of("UNSTABLE");
10728 unstable.merge_state = "UNSTABLE".into();
10729 let forge = Scripted::new(vec![unknown, unstable], vec![(true, "")]);
10730 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10731 assert!(!forge.calls().contains(&"fix"));
10732 assert!(!forge.calls().contains(&"rerun"));
10733 assert_eq!(state.status, RunStatus::Merged);
10734 assert_eq!(state.pr.unwrap().round, 0);
10735 }
10736
10737 #[tokio::test]
10738 async fn run_196c_persistent_unknown_reruns_optional_once_then_parks_with_evidence() {
10739 let mut state = landing_state();
10740 let forge = Scripted::new(vec![coverage_unknown()], vec![]);
10741 *forge.evidence.lock().unwrap() = Some(unrelated_ci_fixture());
10742 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10743 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10744 assert!(!forge.calls().contains(&"fix"));
10745 assert!(state.parked);
10746 assert_eq!(state.status, RunStatus::Landing);
10747 assert_eq!(state.pr.as_ref().unwrap().round, 0);
10748 let hold = state.land_unrelated.as_ref().unwrap();
10749 let q = ask::Questions::open().get(&hold.question).unwrap();
10750 assert!(
10751 q.actions
10752 .values()
10753 .any(|a| matches!(a, ask::ChoiceAction::Resume { .. }))
10754 );
10755 assert!(
10756 state
10757 .events
10758 .iter()
10759 .any(|e| e.message.contains("[ci-unrelated]") && e.message.contains("base-sha"))
10760 );
10761 let first = state.land_unsaid.clone();
10762 state.parked = false;
10764 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10765 assert_eq!(state.land_unsaid, first);
10766 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10767 }
10768
10769 fn red(head: &str, run: Option<&str>) -> Seen {
10770 let mut s = seen(head, Checks::Red, "BLOCKED", false);
10771 s.pr.failing = vec!["test (macos)".to_owned()];
10772 s.contexts = vec![CheckInfo {
10773 label: "test (macos)".to_owned(),
10774 verdict: Verdict::Fail,
10775 required: Some(true),
10776 run: run.map(str::to_owned),
10777 }];
10778 s
10779 }
10780
10781 const RERUN_PR: &str = "https://github.com/o/r/pull/42";
10782
10783 #[tokio::test]
10784 async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
10785 let mut state = landing_state();
10786 let forge = Scripted::new(
10787 vec![
10788 red("a", Some("7")),
10789 seen("a", Checks::Pending, "BLOCKED", false),
10790 seen("a", Checks::Green, "CLEAN", false),
10791 ],
10792 vec![(true, "")],
10793 );
10794 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10795 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10796 assert!(!forge.calls().contains(&"fix"));
10797 assert_eq!(state.status, RunStatus::Merged);
10798 assert_eq!(state.pr.as_ref().unwrap().round, 0);
10799 let argvs = forge.argvs();
10800 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
10801 assert!(
10802 state
10803 .events
10804 .iter()
10805 .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
10806 );
10807 }
10808
10809 #[tokio::test]
10810 async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
10811 let mut state = landing_state();
10812 let forge = Scripted::new(
10813 vec![
10814 red("a", Some("7")),
10815 seen("a", Checks::Green, "CLEAN", false),
10816 ],
10817 vec![(true, "")],
10818 );
10819 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10820 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10822 assert_eq!(forge.reruns.lock().unwrap().len(), 1);
10823 let calls = forge.calls();
10824 let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
10825 let fix_at = calls.iter().position(|c| *c == "fix");
10826 assert!(fix_at.is_none_or(|f| f > rerun_at));
10827 }
10828
10829 #[tokio::test]
10830 async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
10831 let mut state = landing_state();
10832 let mut v = red("a", Some("7"));
10833 v.pr.checks = Checks::Pending;
10834 v.contexts.push(CheckInfo {
10835 label: "slow".to_owned(),
10836 verdict: Verdict::Pending,
10837 required: Some(true),
10838 run: Some("8".to_owned()),
10839 });
10840 let forge = Scripted::new(
10841 vec![v, seen("a", Checks::Green, "CLEAN", false)],
10842 vec![(true, "")],
10843 );
10844 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10845 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10846 assert_eq!(state.status, RunStatus::Merged);
10847 }
10848
10849 #[test]
10850 fn a_run_with_a_job_still_going_is_not_rerun_yet() {
10851 let mut v = red("a", Some("7"));
10852 v.contexts.push(CheckInfo {
10853 label: "same run, still going".to_owned(),
10854 verdict: Verdict::Pending,
10855 required: Some(true),
10856 run: Some("7".to_owned()),
10857 });
10858 assert!(rerunnable(&v.contexts).is_empty());
10859 }
10860
10861 #[tokio::test]
10862 async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
10863 let mut state = landing_state();
10864 state.config.graph.land_rounds = 0;
10865 let forge = Scripted::new(
10866 vec![
10867 red("a", Some("7")),
10868 seen("a", Checks::Green, "CLEAN", false),
10869 ],
10870 vec![(true, "")],
10871 );
10872 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10873 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10874 assert_eq!(state.status, RunStatus::Merged);
10875 assert!(!forge.calls().contains(&"fix"));
10876 assert_eq!(state.pr.as_ref().unwrap().round, 0);
10877 }
10878
10879 #[tokio::test]
10880 async fn the_last_fix_rounds_new_head_gets_its_rerun() {
10881 let mut state = landing_state();
10882 state.config.graph.land_rounds = 1;
10883 let forge = Scripted::new(
10884 vec![
10885 seen("a", Checks::Green, "CLEAN", true),
10886 red("b", Some("7")),
10887 seen("b", Checks::Green, "CLEAN", false),
10888 ],
10889 vec![(true, "")],
10890 );
10891 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10892 head: "b".to_owned(),
10893 });
10894 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10895 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10896 assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
10897 assert_eq!(state.pr.as_ref().unwrap().round, 1);
10898 assert_eq!(state.status, RunStatus::Merged);
10899 assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
10900 assert_eq!(
10901 value_of(&forge.argvs()[0], "--match-head-commit"),
10902 Some("b")
10903 );
10904 }
10905
10906 #[tokio::test]
10907 async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
10908 let mut state = landing_state();
10909 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10910 *forge.rerun_fails.lock().unwrap() = true;
10911 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10912 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10913 assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
10914 assert_eq!(
10915 forge.calls().iter().filter(|c| **c == "fix").count(),
10916 1,
10917 "{:?}",
10918 forge.calls()
10919 );
10920 }
10921
10922 #[tokio::test]
10923 async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
10924 let mut state = landing_state();
10925 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10926 state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
10928 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10929 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10930 assert!(forge.reruns.lock().unwrap().is_empty());
10931 }
10932
10933 #[tokio::test]
10934 async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
10935 let mut state = landing_state();
10936 state.config.graph.rerun_flaky_checks = false;
10937 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10938 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10939 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10940 assert!(forge.reruns.lock().unwrap().is_empty());
10941 assert!(forge.calls().contains(&"fix"));
10942
10943 let mut state = landing_state();
10944 let forge = Scripted::new(vec![red("a", None)], vec![]);
10945 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10946 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10947 assert!(forge.reruns.lock().unwrap().is_empty());
10948 assert!(forge.calls().contains(&"fix"));
10949 }
10950
10951 fn names(xs: &[&str]) -> BTreeSet<String> {
10952 xs.iter().map(|x| (*x).to_owned()).collect()
10953 }
10954
10955 #[test]
10956 fn a_required_check_the_rollup_never_listed_is_named() {
10957 let req = names(&["build"]);
10958 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
10959 assert!(why.contains("never reported: build"), "{why}");
10960 assert!(!why.contains("probably waiting for a review"), "{why}");
10961 }
10962
10963 #[test]
10964 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
10965 let why = waiting_on("BLOCKED", &[passing("review")], None);
10966 assert!(why.contains("could not be read"), "{why}");
10967 assert!(!why.contains("probably waiting for a review"), "{why}");
10968 }
10969
10970 #[test]
10971 fn all_required_reported_keeps_the_review_guess() {
10972 let req = names(&["build"]);
10973 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10974 assert!(why.contains("probably waiting for a review"), "{why}");
10975 assert!(!why.contains("never reported"), "{why}");
10976 }
10977
10978 #[test]
10979 fn required_names_match_the_rollup_ignoring_case_only() {
10980 let req = names(&["Build"]);
10981 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10982 assert!(!why.contains("never reported"), "{why}");
10983 }
10984
10985 #[test]
10986 fn required_contexts_are_read_from_protection_and_rulesets() {
10987 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
10988 assert_eq!(
10989 parse_classic_required(classic),
10990 Some(names(&["build", "lint"]))
10991 );
10992 let rules = r#"[{"type":"pull_request","parameters":{}},
10993 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
10994 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
10995 assert_eq!(parse_ruleset_required("nope"), None);
10996 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
10997 }
10998
10999 #[test]
11000 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
11001 let shown = BTreeSet::new();
11002 let ok = seen("a", Checks::Green, "CLEAN", false);
11003 let guard = |s: Option<&Seen>| {
11004 direct_merge_is_safe(
11005 s,
11006 "A",
11007 &shown,
11008 0,
11009 4,
11010 Duration::ZERO,
11011 CiExpectation::Expected,
11012 )
11013 };
11014 assert!(guard(Some(&ok)));
11015 assert!(!guard(None));
11016 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
11017 let mut stale = ok.clone();
11018 stale.rollup_head = "old".to_owned();
11019 assert!(!guard(Some(&stale)));
11020 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
11021 assert!(!guard(Some(&merged_view("a"))));
11022 }
11023
11024 #[test]
11025 fn the_rollup_node_carries_whether_each_check_is_required() {
11026 let node = node_json("new", "SUCCESS", false)
11027 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
11028 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
11029 assert_eq!(s.contexts.len(), 1);
11030 assert_eq!(s.contexts[0].required, Some(true));
11031 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
11032 assert_eq!(s.contexts[0].required, None);
11033 }
11034
11035 #[tokio::test]
11036 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
11037 let mut state = landing_state();
11038 state.land_armed_head = Some("a".to_owned());
11039 let forge = Scripted::new(
11040 vec![seen("a", Checks::Green, "CLEAN", true)],
11041 vec![(false, "disable exploded")],
11042 );
11043 land_with(&mut state, URL, &forge).await.unwrap();
11044 assert!(!forge.calls().contains(&"fix"));
11045 assert_eq!(state.status, RunStatus::Blocked);
11046 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
11047 let why = state.merge.as_ref().unwrap().detail.clone();
11048 assert!(why.contains("disable exploded"), "{why}");
11049 }
11050}