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