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