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