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