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