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