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
3835 .resource_block
3836 .get_or_insert_with(|| crate::run::ResourceBlock {
3837 cause: crate::run::ResourceBlockCause::Other,
3838 node: node.into(),
3839 kind: "shared build cache".into(),
3840 detail: detail.clone(),
3841 at: jiff::Timestamp::now(),
3842 });
3843 state.event("resource", &detail);
3844 state.save()?;
3845 bail!("landing verification could not start: {detail}");
3846 }
3847 Ok(())
3848}
3849
3850async fn publish_landing_head<F: Forge>(
3853 state: &mut RunState,
3854 observed: &str,
3855 pr_url: &str,
3856 forge: &F,
3857) -> Result<String> {
3858 crate::pr_head::ensure_owner(state)?;
3859 let winner = state.winner().cloned().context("landing has no winner")?;
3860 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3861 let expected = state.pr_pushed_head.as_deref().unwrap_or(observed);
3862 anyhow::ensure!(
3863 observed == expected
3864 || (observed == head
3865 && state.gate_head.as_deref() == Some(&head)
3866 && state.gate_allows_progress()),
3867 "public head moved outside this run; refusing to overwrite it"
3868 );
3869 anyhow::ensure!(
3870 git::is_clean(&winner.worktree).await?,
3871 "landing worktree is dirty"
3872 );
3873 if state.gate_head.as_deref() != Some(&head) || !state.gate_ran || !state.gate_allows_progress()
3874 {
3875 state.gate.clear();
3876 state.gate_head = None;
3877 state.gate_ran = false;
3878 state.pre_gate.clear();
3879 state.pre_gate_commit = None;
3880 state.save()?;
3881 let inherited = crate::repo_lock::WaitPolicy::current();
3882 let wait = crate::repo_lock::WaitPolicy::new(
3883 Duration::from_secs(state.config.graph.timeout_resource_wait),
3884 move || inherited.cancelled(),
3885 );
3886 let mut outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3887 loop {
3888 if outcomes.iter().any(|outcome| outcome.resource_blocked) {
3889 state.gate = outcomes.clone();
3890 check_landing_resources(state, "gate", &outcomes)?;
3891 }
3892 if outcomes.iter().all(|o| o.ok()) {
3893 break;
3894 }
3895 match Box::pin(crate::graph::repair_landing_gate(state, &winner, &outcomes)).await? {
3896 crate::graph::GateFix::Retry => {
3897 outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3898 }
3899 crate::graph::GateFix::Stop => break,
3900 crate::graph::GateFix::Defer => {
3901 if let Some(e2e) = &state.gate_repair_e2e {
3905 let outcomes = e2e.outcomes.clone();
3906 check_landing_resources(state, "e2e", &outcomes)?;
3907 }
3908 bail!("landing gate repair deferred");
3909 }
3910 }
3911 }
3912 state.gate = outcomes;
3913 state.gate_ran = true;
3914 state.gate_head = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
3915 state.save()?;
3916 }
3917 anyhow::ensure!(
3918 state.gate_allows_progress(),
3919 "landing gate failed; nothing published"
3920 );
3921 let checked = state
3922 .gate_head
3923 .clone()
3924 .context("landing gate has no checked SHA")?;
3925 anyhow::ensure!(
3926 git::rev_parse(&winner.worktree, "HEAD").await? == checked
3927 && git::is_clean(&winner.worktree).await?,
3928 "landing tree changed after gate"
3929 );
3930 let inherited = crate::repo_lock::WaitPolicy::current();
3931 let wait = crate::repo_lock::WaitPolicy::new(
3932 Duration::from_secs(state.config.graph.timeout_resource_wait),
3933 move || inherited.cancelled(),
3934 );
3935 let e2e = crate::graph::check_gate_repair_e2e(state, &winner, &wait).await?;
3936 if matches!(e2e, crate::graph::GateFix::Defer) {
3937 let outcomes = state
3938 .gate_repair_e2e
3939 .as_ref()
3940 .context("deferred landing e2e has no outcomes")?
3941 .outcomes
3942 .clone();
3943 check_landing_resources(state, "e2e", &outcomes)?;
3944 }
3945 anyhow::ensure!(
3946 matches!(e2e, crate::graph::GateFix::Retry),
3947 "post-repair e2e has not passed on the landing head; nothing published"
3948 );
3949 anyhow::ensure!(
3950 git::rev_parse(&winner.worktree, "HEAD").await? == checked
3951 && git::is_clean(&winner.worktree).await?,
3952 "landing tree changed during post-repair e2e"
3953 );
3954 crate::pr_head::ensure_owner(state)?;
3955 if owner_requested_stop(state)? {
3956 bail!("owner requested stop before publication");
3957 }
3958 let current = forge.view(&state.repo, pr_url).await?;
3959 anyhow::ensure!(
3960 current.pr.state == PrLifecycle::Open,
3961 "PR closed or merged while gating; nothing published"
3962 );
3963 anyhow::ensure!(
3964 current.head == observed || current.head == checked,
3965 "PR head changed while gating; nothing published"
3966 );
3967 let public = state.pr_branch().unwrap_or(&winner.branch).to_owned();
3968 let lease = format!("--force-with-lease=refs/heads/{public}:{observed}");
3969 let refspec = format!("{checked}:refs/heads/{public}");
3970 let lock = repo_merge_lock(&state.repo);
3971 let _slot = lock.lock().await;
3972 let pushed = git::git_raw(
3973 &winner.worktree,
3974 &["push", &lease, &state.config.merge.remote, &refspec],
3975 )
3976 .await?;
3977 anyhow::ensure!(
3978 pushed.ok(),
3979 "publishing gated landing head failed: {}",
3980 pushed.stderr
3981 );
3982 state.pr_pushed_head = Some(checked.clone());
3983 state.land_sync_pending = false;
3984 state.save()?;
3985 Ok(checked)
3986}
3987
3988async fn restore_landing_worktree(state: &mut RunState) -> Result<()> {
3991 let winner = state.winner().cloned().context("landing has no winner")?;
3992 let inherited = crate::repo_lock::WaitPolicy::current();
3993 let wait = crate::repo_lock::WaitPolicy::new(
3994 Duration::from_secs(state.config.graph.timeout_resource_wait),
3995 move || inherited.cancelled(),
3996 );
3997 let _coordination = crate::repo_lock::SeatLease::operation_with(
3998 &winner.worktree,
3999 state.config.coordination.as_ref(),
4000 &wait,
4001 )
4002 .await?;
4003 if !git::is_clean(&winner.worktree).await? {
4004 let from = state
4005 .land_rebase_from
4006 .as_deref()
4007 .context("landing worktree is dirty")?;
4008 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4009 anyhow::ensure!(head != from, "landing worktree is dirty");
4010 for args in [
4011 vec!["diff", "--cached", "--quiet", from],
4012 vec!["diff", "--quiet"],
4013 ] {
4014 anyhow::ensure!(
4015 git::git_raw(&winner.worktree, &args).await?.ok(),
4016 "landing worktree has edits beyond the interrupted rebase"
4017 );
4018 }
4019 let untracked = git::git(&winner.worktree, &["ls-files", "--others", "-z"]).await?;
4023 let tracked = git::git(
4024 &winner.worktree,
4025 &["ls-tree", "-r", "--name-only", "-z", "HEAD"],
4026 )
4027 .await?;
4028 for local in untracked.split('\0').filter(|p| !p.is_empty()) {
4029 anyhow::ensure!(
4030 !tracked.split('\0').filter(|p| !p.is_empty()).any(|path| {
4031 path == local
4032 || path
4033 .strip_prefix(local)
4034 .is_some_and(|rest| rest.starts_with('/'))
4035 || local
4036 .strip_prefix(path)
4037 .is_some_and(|rest| rest.starts_with('/'))
4038 }),
4039 "landing worktree has an untracked or ignored path conflicting with HEAD: {local}"
4040 );
4041 }
4042 git::git(&winner.worktree, &["read-tree", "-m", "-u", "HEAD"]).await?;
4043 }
4044 crate::worktree_setup::prepare(&state.config, &state.repo, &winner.worktree).await?;
4045 state.land_rebase_from = None;
4046 state.save()?;
4047 Ok(())
4048}
4049
4050async fn rebase_landing_worktree(
4051 state: &mut RunState,
4052 onto: &str,
4053) -> Result<crate::rebase::Rebased> {
4054 restore_landing_worktree(state).await?;
4055 let winner = state.winner().cloned().context("landing has no winner")?;
4056 state.land_sync_pending = true;
4057 state.land_rebase_from = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
4058 state.save()?;
4059 let scratch = state.dir().join("rebase");
4060 let result = crate::rebase::rebase_with_fixer(state, &scratch, &winner.branch, onto).await?;
4061 if result == crate::rebase::Rebased::Applied {
4062 restore_landing_worktree(state).await?;
4063 }
4064 Ok(result)
4065}
4066
4067async fn resume_landing_head<F: Forge>(
4070 state: &mut RunState,
4071 observed: &str,
4072 pr_url: &str,
4073 forge: &F,
4074) -> Result<String> {
4075 crate::pr_head::ensure_owner(state)?;
4076 let winner = state.winner().cloned().context("landing has no winner")?;
4077 let local = git::rev_parse(&winner.worktree, "HEAD").await?;
4078 let expected = state
4079 .pr_pushed_head
4080 .as_deref()
4081 .or(state.gate_head.as_deref())
4082 .context("missing public head lease")?;
4083 anyhow::ensure!(
4084 observed == expected
4085 || (observed == local
4086 && ((state.gate_head.as_deref() == Some(&local) && state.gate_allows_progress())
4087 || git::is_ancestor(&winner.worktree, expected, &local).await)),
4088 "public head moved outside this run; refusing to sync"
4089 );
4090 state.pr_pushed_head = Some(observed.to_owned());
4097 state.save()?;
4098 restore_landing_worktree(state).await?;
4099 let remote = state.config.merge.remote.clone();
4100 let base = state.base_branch.clone();
4101 let fetched = git::fetch(&state.repo, &remote, &base).await?;
4102 anyhow::ensure!(
4103 fetched.ok(),
4104 "cannot fetch landing base: {}",
4105 fetched.stderr
4106 );
4107 let onto = format!("{remote}/{base}");
4108 let tip = git::rev_parse(&state.repo, &onto).await?;
4109 if !git::is_ancestor(&state.repo, &tip, &local).await {
4110 match rebase_landing_worktree(state, &onto).await? {
4111 crate::rebase::Rebased::Applied => {}
4112 crate::rebase::Rebased::Stopped(why) => bail!("landing rebase stopped: {why}"),
4113 }
4114 }
4115 publish_landing_head(state, observed, pr_url, forge).await
4116}
4117
4118async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
4119 crate::pr_head::ensure_owner(state)?;
4120 let reopening_hold = crate::merge_decision::resumable_hold(state);
4121 let repo = state.repo.clone();
4122 let budget = state.config.graph.land_rounds;
4123 let mut round = 0usize;
4124 let mut rebases = 0usize;
4127 let mut waited = Duration::ZERO;
4128 let mut shown: BTreeSet<String> = BTreeSet::new();
4133 let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
4135 let mut rechecked = false;
4137
4138 state.status = RunStatus::Landing;
4146 state.event("land", format!("watching {pr_url}"));
4147 state.save()?;
4148
4149 let mut resumed_armed = state.land_armed_head.is_some();
4153
4154 loop {
4155 let seen = forge.view(&repo, pr_url).await?;
4156 let mut pr = seen.pr.clone();
4157 pr.review_comments.retain(|c| !shown.contains(&c.body));
4158 state.pr = Some(crate::run::PrRecord {
4159 url: pr.url.clone(),
4160 number: pr.number,
4161 state: pr.state.as_str().to_owned(),
4162 checks: pr.checks.as_str().to_owned(),
4163 round,
4164 rounds: budget,
4165 red_at_merge: Vec::new(),
4166 });
4167 state.save()?;
4168
4169 if pr.state == PrLifecycle::Open {
4170 state.observe_branch_owner_holds(&seen.head).await;
4171 state.save()?;
4172 }
4173 crate::pr_head::ensure_owner(state)?;
4174 if reopening_hold {
4175 anyhow::ensure!(
4176 pr.state == PrLifecycle::Open,
4177 "held PR is no longer open; cannot request a fresh decision"
4178 );
4179 disarm(forge, state, &repo, pr.number)
4182 .await
4183 .map_err(anyhow::Error::msg)?;
4184 let previous = state.merge_decision.take();
4185 let binding = state.land_approval.take();
4186 state.config.graph.land_approval = true;
4187 let subject = merge_subject(
4188 crate::graph::landing_title(state, &seen.title),
4189 &crate::graph::landing_subject_source(state),
4190 );
4191 let contested = contested_to_ask(state);
4192 if let Err(error) =
4193 approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
4194 {
4195 state.merge_decision = previous;
4196 state.land_approval = binding;
4197 return Err(error);
4198 }
4199 state.end_pass_parked(crate::run::ParkCause::Question);
4200 state.parked = true;
4201 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()));
4202 state.save()?;
4203 return Ok(pr);
4204 }
4205 if owner_stop(state, &mut pr, forge).await? {
4206 return Ok(pr);
4207 }
4208
4209 if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
4210 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4215 let why = format!(
4216 "a previous pass may have armed auto-merge and it could not be disabled \
4217 on resume: {e}"
4218 );
4219 stop(state, &repo, &pr, &why).await?;
4220 return Ok(pr);
4221 }
4222 }
4223
4224 if pr.state == PrLifecycle::Open
4229 && !seen.head.is_empty()
4230 && state
4231 .land_armed_head
4232 .as_deref()
4233 .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
4234 {
4235 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4236 let why = format!(
4237 "the head moved while auto-merge was armed and it could not be disabled: {e}"
4238 );
4239 stop(state, &repo, &pr, &why).await?;
4240 return Ok(pr);
4241 }
4242 }
4243
4244 if state.land_sync_pending && pr.state == PrLifecycle::Open {
4245 if owner_stop(state, &mut pr, forge).await? {
4248 return Ok(pr);
4249 }
4250 let repair_answer = state.land_approval.as_ref().is_some_and(|binding| {
4251 binding.head.eq_ignore_ascii_case(&seen.head)
4252 && ask::Questions::open()
4253 .get(&binding.question)
4254 .is_ok_and(|q| {
4255 q.run == state.id
4256 && q.node == APPROVAL_NODE
4257 && q.status == ask::QuestionStatus::Answered
4258 && matches!(approval(q.resolution().as_deref()), Approval::Repair)
4259 })
4260 });
4261 if repair_answer {
4262 let subject = merge_subject(
4263 crate::graph::landing_title(state, &seen.title),
4264 &crate::graph::landing_subject_source(state),
4265 );
4266 let contested = contested_to_ask(state);
4267 if matches!(
4268 approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await?,
4269 ApprovalGate::Repair
4270 ) {
4271 if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4272 state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4273 state.save()?;
4274 }
4275 return Ok(pr);
4276 }
4277 }
4278 let synced = resume_landing_head(state, &seen.head, pr_url, forge).await;
4279 if owner_stop(state, &mut pr, forge).await? {
4282 return Ok(pr);
4283 }
4284 let head = synced?;
4285 awaiting_head = Some(head);
4286 waited = Duration::ZERO;
4287 rechecked = false;
4288 continue;
4290 }
4291
4292 if pr.state == PrLifecycle::Open {
4293 if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
4294 if waited >= WAIT_CEILING {
4295 let want = awaiting_head.as_deref().unwrap_or_default();
4296 let why = format!(
4297 "the pull request's checks were still not about one readable head after \
4298 {} minutes (expected {}, pull request points at {}, checks are for {}); \
4299 someone may have pushed over it",
4300 WAIT_CEILING.as_secs() / 60,
4301 if want.is_empty() { "any" } else { want },
4302 if seen.head.is_empty() {
4303 "nothing readable"
4304 } else {
4305 &seen.head
4306 },
4307 if seen.rollup_head.is_empty() {
4308 "nothing readable"
4309 } else {
4310 &seen.rollup_head
4311 },
4312 );
4313 stop_disarmed(forge, state, &repo, &pr, &why).await?;
4314 return Ok(pr);
4315 }
4316 waited += POLL;
4317 forge.poll().await;
4318 continue;
4319 }
4320 if awaiting_head.take().is_some() {
4323 waited = Duration::ZERO;
4326 }
4327 }
4328
4329 if pr.state == PrLifecycle::Open && state.config.graph.rerun_flaky_checks {
4334 let failing = rerunnable(&seen.contexts);
4335 let now = forge.now();
4336 match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
4337 RerunStep::Rerun(runs) => {
4338 let rec = state.land_reruns.get_or_insert_with(Default::default);
4339 if !rec.head.eq_ignore_ascii_case(&seen.head) {
4340 *rec = LandReruns {
4341 head: seen.head.clone(),
4342 runs: BTreeMap::new(),
4343 };
4344 }
4345 for run in &runs {
4346 rec.runs.insert(run.clone(), now);
4347 }
4348 state.save()?;
4352 let mut any = false;
4353 for run in &runs {
4354 let names = failing
4355 .iter()
4356 .filter(|(_, r)| r.as_deref() == Some(run))
4357 .map(|(l, _)| l.as_str())
4358 .collect::<Vec<_>>()
4359 .join(", ");
4360 match forge.rerun(&repo, run).await {
4361 Ok(()) => {
4362 any = true;
4363 state.event(
4364 "land",
4365 format!("reran failed check(s) {names} (run {run})"),
4366 );
4367 }
4368 Err(e) => state.event(
4370 "land",
4371 format!("could not rerun check(s) {names} (run {run}): {e:#}"),
4372 ),
4373 }
4374 }
4375 state.save()?;
4376 if any {
4377 waited = Duration::ZERO;
4378 forge.poll().await;
4379 continue;
4380 }
4381 }
4382 RerunStep::Grace => {
4383 if waited >= WAIT_CEILING {
4384 let why = format!(
4385 "checks were still failing after a rerun and {} minutes",
4386 WAIT_CEILING.as_secs() / 60
4387 );
4388 stop(state, &repo, &pr, &why).await?;
4389 return Ok(pr);
4390 }
4391 waited += POLL;
4392 forge.poll().await;
4393 continue;
4394 }
4395 RerunStep::Fix => {}
4396 }
4397 }
4398
4399 let step = decide(&pr, round, budget, waited);
4400 let armed_here = state
4405 .land_armed_head
4406 .as_deref()
4407 .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
4408 if armed_here && matches!(step, Step::Merge | Step::Wait) {
4409 if waited >= WAIT_CEILING {
4410 let required = if seen.base.is_empty() {
4411 None
4412 } else {
4413 forge.required_contexts(&repo, &seen.base).await
4414 };
4415 let why = format!(
4416 "auto-merge was armed on {} but the pull request did not merge within {} \
4417 minutes ({})",
4418 seen.head,
4419 WAIT_CEILING.as_secs() / 60,
4420 waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
4421 );
4422 stop_disarmed(forge, state, &repo, &pr, &why).await?;
4423 return Ok(pr);
4424 }
4425 waited += POLL;
4426 forge.poll().await;
4427 continue;
4428 }
4429 if armed_here && !matches!(step, Step::Done { .. }) {
4430 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4433 let why = format!("auto-merge could not be disabled: {e}");
4434 stop(state, &repo, &pr, &why).await?;
4435 return Ok(pr);
4436 }
4437 }
4438 match step {
4439 Step::Wait => {
4440 if waited >= WAIT_CEILING {
4441 let why = format!(
4442 "checks were still running after {} minutes",
4443 WAIT_CEILING.as_secs() / 60
4444 );
4445 stop(state, &repo, &pr, &why).await?;
4446 return Ok(pr);
4447 }
4448 waited += POLL;
4449 forge.poll().await;
4450 }
4451 Step::Done { merged } => {
4452 if merged {
4453 state.pr_merged_head = (!seen.head.is_empty()).then(|| seen.head.clone());
4454 }
4455 if let Some(armed) = state.land_armed_head.take() {
4460 if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
4461 let msg = format!(
4462 "{} merged on {} but the owner approved {armed}; review what landed",
4463 pr.url, seen.head
4464 );
4465 tracing::warn!("{msg}");
4466 state.event("land", msg);
4467 crate::notices::raise_with(
4471 crate::notices::Notice::warn(
4472 &format!("merged-unapproved-head:{}", state.id),
4473 "A pull request merged on a commit the owner did not approve; \
4474 review what landed",
4475 )
4476 .link(crate::notices::Link::Run {
4477 id: state.id.clone(),
4478 }),
4479 &state.config.notify,
4480 );
4481 }
4482 }
4483 state.status = if merged {
4484 RunStatus::Merged
4485 } else {
4486 RunStatus::Ready
4487 };
4488 let detail = if merged {
4489 format!("{} was merged", pr.url)
4490 } else {
4491 format!("{} was closed without merging", pr.url)
4492 };
4493 state.merge = Some(MergeOutcome {
4494 mode: MergeMode::Pr,
4495 ok: merged,
4496 detail: detail.clone(),
4497 empty: false,
4498 });
4499 state.event("land", detail);
4500 state.save()?;
4501 write_pr_state_through(state, pr.state);
4502 return Ok(pr);
4503 }
4504 Step::Merge => {
4505 state.check_delivery_evidence(&seen.head)?;
4506 let subject = merge_subject(
4507 crate::graph::landing_title(state, &seen.title),
4508 &crate::graph::landing_subject_source(state),
4509 );
4510 let contested = contested_to_ask(state);
4516 if !approval_reasons(state).is_empty() {
4517 match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
4518 .await?
4519 {
4520 ApprovalGate::Approved => {}
4521 ApprovalGate::Repair => {
4522 if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4525 state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4526 state.save()?;
4527 }
4528 return Ok(pr);
4529 }
4530 ApprovalGate::Held => {
4531 if owner_stop(state, &mut pr, forge).await? {
4534 return Ok(pr);
4535 }
4536 stop(
4537 state,
4538 &repo,
4539 &pr,
4540 "the owner did not approve the merge (held or unanswered)",
4541 )
4542 .await?;
4543 return Ok(pr);
4544 }
4545 ApprovalGate::Pending => {
4553 state.end_pass_parked(crate::run::ParkCause::Question);
4554 state.parked = true;
4555 state.event(
4556 "land",
4557 "parked awaiting merge approval - resumes once answered",
4558 );
4559 state.save()?;
4560 return Ok(pr);
4561 }
4562 }
4563 }
4564 let observed_head = seen.head.clone();
4575 {
4578 let fresh = forge.view(&repo, pr_url).await.ok();
4579 if !direct_merge_is_safe(
4580 fresh.as_ref(),
4581 &observed_head,
4582 &shown,
4583 round,
4584 budget,
4585 waited,
4586 ) {
4587 if waited >= WAIT_CEILING {
4588 let why = "the pull request did not settle on the approved head \
4589 before it could be merged";
4590 stop(state, &repo, &pr, why).await?;
4591 return Ok(pr);
4592 }
4593 state.event(
4594 "land",
4595 "the pull request changed before merging; looking again",
4596 );
4597 waited += POLL;
4598 forge.poll().await;
4599 continue;
4600 }
4601 }
4602 let argv = merge_argv_at(pr.number, &subject, &observed_head);
4603 let out = {
4604 let merge_lock = repo_merge_lock(&repo);
4605 let _merge_slot = merge_lock.lock().await;
4606 crate::pr_head::ensure_owner(state)?;
4607 if let Some(task_id) = state
4608 .origin
4609 .as_ref()
4610 .and_then(|origin| origin.task.as_ref())
4611 {
4612 let task = crate::queue::Queue::open()
4613 .get(task_id)
4614 .context("cannot verify the task owner immediately before merge")?;
4615 if task.runs.last() != Some(&state.id)
4616 || task.operator_held()
4617 || task.status == crate::queue::TaskStatus::Done
4618 {
4619 bail!(
4620 "task ownership or an operator hold superseded this merge approval"
4621 );
4622 }
4623 }
4624 if let Some(binding) = &state.land_approval {
4625 let current = ask::Questions::open().get(&binding.question)?;
4626 if merge_consent_superseded(state, ¤t)?
4627 || current.run != state.id
4628 || !binding.head.eq_ignore_ascii_case(&observed_head)
4629 || approval(current.resolution().as_deref()) != Approval::Merge
4630 || current.merge_target.as_ref().is_some_and(|t| {
4631 t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4632 })
4633 {
4634 bail!(
4635 "merge decision is stale or no longer authorizes the reviewed head"
4636 );
4637 }
4638 }
4639 forge.merge(&repo, &argv).await?
4640 };
4641 if out.0 {
4642 let confirmed = forge.view(&repo, pr_url).await.is_ok_and(|c| {
4648 if c.pr.state == PrLifecycle::Merged {
4649 state.pr_merged_head = (!c.head.is_empty()).then_some(c.head);
4650 true
4651 } else {
4652 false
4653 }
4654 });
4655 if !confirmed {
4656 if waited >= WAIT_CEILING {
4657 let why = "the merge request succeeded but the pull request \
4658 could not be confirmed merged after waiting";
4659 stop(state, &repo, &pr, why).await?;
4660 return Ok(pr);
4661 }
4662 state.event(
4663 "land",
4664 "merge accepted but the pull request is not confirmed merged yet; waiting",
4665 );
4666 state.save()?;
4667 waited += POLL;
4668 forge.poll().await;
4669 continue;
4670 }
4671 pr.state = PrLifecycle::Merged;
4672 state.status = RunStatus::Merged;
4673 state.merge = Some(MergeOutcome {
4674 mode: MergeMode::Pr,
4675 ok: true,
4676 detail: format!("gh {}", argv.join(" ")),
4677 empty: false,
4678 });
4679 if let Some(pr_record) = state.pr.as_mut() {
4684 pr_record.state = pr.state.as_str().to_owned();
4685 }
4686 state.event("land", format!("merged {} as `{subject}`", pr.url));
4687 announce_red_merge(state, &pr).await;
4688 state.save()?;
4689 write_pr_state_through(state, pr.state);
4690 return Ok(pr);
4691 }
4692 let after_seen = forge.view(&repo, pr_url).await.ok();
4693 let after = after_seen.as_ref().map(|s| s.pr.state);
4694 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4695 state.pr_merged_head = after_seen
4696 .as_ref()
4697 .map(|s| s.head.clone())
4698 .filter(|h| !h.is_empty());
4699 pr.state = PrLifecycle::Merged;
4700 state.status = RunStatus::Merged;
4701 state.merge = Some(outcome);
4702 if let Some(pr_record) = state.pr.as_mut() {
4703 pr_record.state = pr.state.as_str().to_owned();
4704 }
4705 state.event("land", format!("merged {} as `{subject}`", pr.url));
4706 announce_red_merge(state, &pr).await;
4707 state.save()?;
4708 write_pr_state_through(state, pr.state);
4709 return Ok(pr);
4710 }
4711 let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
4712 match verdict {
4713 Refused::Final => {
4714 let merge_state = after_seen
4715 .as_ref()
4716 .map(|s| s.merge_state.as_str())
4717 .filter(|m| !m.is_empty())
4718 .unwrap_or("unknown");
4719 let why = format!(
4723 "the merge was refused: {} (merge state: {merge_state})",
4724 out.1
4725 );
4726 stop(state, &repo, &pr, &why).await?;
4727 return Ok(pr);
4728 }
4729 verdict => {
4730 if waited >= WAIT_CEILING {
4735 let why = format!(
4736 "the merge was still refused after {} minutes: {}",
4737 WAIT_CEILING.as_secs() / 60,
4738 out.1
4739 );
4740 stop(state, &repo, &pr, &why).await?;
4741 return Ok(pr);
4742 }
4743 if verdict == Refused::Recheck {
4744 rechecked = true;
4745 }
4746 state.event(
4747 "land",
4748 "merge refused while the branch policy is not satisfied yet; waiting",
4749 );
4750 state.save()?;
4751 waited += POLL;
4752 forge.poll().await;
4753 }
4754 }
4755 }
4756 Step::Rebase => {
4757 if rebases >= budget {
4763 let why = format!(
4764 "the base moved under this branch {budget} time(s) and it still does \
4765 not merge; rebasing again would only race it"
4766 );
4767 stop(state, &repo, &pr, &why).await?;
4768 return Ok(pr);
4769 }
4770 rebases += 1;
4771 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4772 stop(
4773 state,
4774 &repo,
4775 &pr,
4776 "the pull request conflicts and this run has no winning branch to rebase",
4777 )
4778 .await?;
4779 return Ok(pr);
4780 };
4781 let base = state.base_branch.clone();
4782 state.event(
4783 "land",
4784 format!("{} no longer merges; rebasing onto {base}", pr.url),
4785 );
4786 state.save()?;
4787
4788 let remote = state.config.merge.remote.clone();
4792 git::fetch(&repo, &remote, &base).await.ok();
4793 if owner_stop(state, &mut pr, forge).await? {
4794 return Ok(pr);
4795 }
4796 let onto = format!("{remote}/{base}");
4797 let rebased = match rebase_landing_worktree(state, &onto).await {
4798 Ok(crate::rebase::Rebased::Applied) => Ok(None),
4799 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4800 Err(e) => Err(e),
4801 };
4802 if owner_stop(state, &mut pr, forge).await? {
4803 return Ok(pr);
4804 }
4805 match rebased {
4806 Ok(None) => {
4807 let published =
4808 publish_landing_head(state, &seen.head, pr_url, forge).await;
4809 if owner_stop(state, &mut pr, forge).await? {
4810 return Ok(pr);
4811 }
4812 let head = published?;
4813 crate::graph::refresh_reviewed_commits(state, &branch).await;
4814 state.pr_pushed_head = Some(head.clone());
4815 awaiting_head = Some(head);
4816 rechecked = false;
4817 state.event("land", format!("rebased {branch} onto {base}"));
4818 state.save()?;
4819 waited = Duration::ZERO;
4822 tokio::time::sleep(POLL).await;
4823 }
4824 Ok(Some(conflict)) => {
4827 let why = format!(
4828 "{} conflicts with {base} and the rebase did not apply: {}",
4829 pr.url,
4830 conflict.chars().take(600).collect::<String>()
4831 );
4832 stop(state, &repo, &pr, &why).await?;
4833 return Ok(pr);
4834 }
4835 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4836 Err(e) => {
4837 let why = format!("could not rebase {branch} onto {base}: {e:#}");
4838 stop(state, &repo, &pr, &why).await?;
4839 return Ok(pr);
4840 }
4841 }
4842 }
4843 Step::GiveUp { reason } => {
4844 stop(state, &repo, &pr, &reason).await?;
4845 return Ok(pr);
4846 }
4847 Step::Fix { reason } => {
4848 round += 1;
4849 waited = Duration::ZERO;
4850 for c in &pr.review_comments {
4851 shown.insert(c.body.clone());
4852 }
4853 state.event("land", format!("round {round}: {reason}"));
4854 state.save()?;
4855
4856 let logs = failing_logs(&repo, &seen.failing_urls).await;
4857 let was_red = pr.checks == Checks::Red;
4858 if owner_stop(state, &mut pr, forge).await? {
4859 return Ok(pr);
4860 }
4861 let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4862 if owner_stop(state, &mut pr, forge).await? {
4863 return Ok(pr);
4864 }
4865 match fixed {
4866 Fixed::Committed { head } => {
4867 state.pr_pushed_head = Some(head.clone());
4870 awaiting_head = Some(head);
4871 rechecked = false;
4872 waited = Duration::ZERO;
4873 forge.poll().await;
4874 }
4875 Fixed::Declined if was_red => {
4876 let why = format!(
4877 "the fixer produced no commit while {} check(s) were failing \
4878 ({}); stopping instead of looping on an unchanged tree",
4879 pr.failing.len(),
4880 pr.failing.join(", ")
4881 );
4882 stop(state, &repo, &pr, &why).await?;
4883 return Ok(pr);
4884 }
4885 Fixed::Declined => state.event(
4890 "land",
4891 format!("round {round}: fixer declined the comments, nothing committed"),
4892 ),
4893 Fixed::Failed(why) => {
4894 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
4895 return Ok(pr);
4896 }
4897 }
4898 state.save()?;
4899 }
4900 }
4901 }
4902}
4903
4904#[derive(Clone)]
4908struct Seen {
4909 pr: PrState,
4910 title: String,
4911 failing_urls: Vec<(String, String)>,
4912 head: String,
4914 rollup_head: String,
4917 merge_state: String,
4920 contexts: Vec<CheckInfo>,
4922 base: String,
4924}
4925
4926#[derive(Clone)]
4928struct CheckInfo {
4929 label: String,
4930 verdict: Verdict,
4931 required: Option<bool>,
4932 run: Option<String>,
4934}
4935
4936#[derive(Debug, Clone, PartialEq, Eq)]
4938enum RerunStep {
4939 Rerun(Vec<String>),
4941 Grace,
4943 Fix,
4945}
4946
4947fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
4951 contexts
4952 .iter()
4953 .filter(|c| c.verdict == Verdict::Fail && c.required != Some(false))
4954 .filter(|c| {
4956 c.run.as_ref().is_none_or(|r| {
4957 !contexts.iter().any(|o| {
4958 matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
4959 && o.run.as_ref() == Some(r)
4960 })
4961 })
4962 })
4963 .map(|c| (c.label.clone(), c.run.clone()))
4964 .collect()
4965}
4966
4967fn rerun_step(
4969 failing: &[(String, Option<String>)],
4970 record: Option<&LandReruns>,
4971 head: &str,
4972 now: i64,
4973) -> RerunStep {
4974 if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
4977 return RerunStep::Fix;
4978 }
4979 let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
4981 let mut fresh: Vec<String> = Vec::new();
4982 let mut recent = false;
4983 for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
4984 match record.and_then(|r| r.runs.get(run)) {
4985 None => {
4986 if !fresh.iter().any(|f| f == run) {
4987 fresh.push(run.to_owned());
4988 }
4989 }
4990 Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
4991 Some(_) => {}
4992 }
4993 }
4994 if !fresh.is_empty() {
4995 RerunStep::Rerun(fresh)
4996 } else if recent {
4997 RerunStep::Grace
4998 } else {
4999 RerunStep::Fix
5000 }
5001}
5002
5003async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
5007 let view = gh(
5008 repo,
5009 &[
5010 "pr".to_owned(),
5011 "view".to_owned(),
5012 pr_url.to_owned(),
5013 "--json".to_owned(),
5014 "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
5015 .to_owned(),
5016 ],
5017 )
5018 .await?;
5019 if !view.0 {
5020 bail!("gh pr view {pr_url}: {}", view.1);
5021 }
5022 let number = parse_pr(&view.1)?.number;
5023 let node = last_commit_node(repo, number).await;
5024 let mut seen = seen_from(&view.1, node.as_deref())?;
5025
5026 let inline = gh(
5027 repo,
5028 &[
5029 "api".to_owned(),
5030 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
5031 ],
5032 )
5033 .await?;
5034 if inline.0 {
5035 match parse_inline_comments(&inline.1) {
5036 Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
5037 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
5040 }
5041 } else {
5042 tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
5043 }
5044 Ok(seen)
5045}
5046
5047fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
5058 let mut pr = parse_pr(view_json)?;
5059 let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
5060
5061 let mut rollup_head = String::new();
5062 let mut failing_urls = Vec::new();
5063 let mut contexts = Vec::new();
5064 let mut checks = Checks::Unknown;
5065 let mut failing = Vec::new();
5066 if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
5067 (checks, failing) = rollup_verdict(&rollup);
5068 failing_urls = rollup
5069 .iter()
5070 .filter(|c| c.verdict() == Verdict::Fail)
5071 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
5072 .collect();
5073 contexts = rollup
5074 .iter()
5075 .map(|c| CheckInfo {
5076 label: c.label(),
5077 verdict: c.verdict(),
5078 required: c.is_required,
5079 run: c.url().and_then(run_of),
5080 })
5081 .collect();
5082 rollup_head = oid;
5083 }
5084 pr.checks = checks;
5085 pr.failing = failing;
5086
5087 Ok(Seen {
5088 pr,
5089 title: raw.title,
5090 failing_urls,
5091 head: raw.head_ref_oid,
5092 rollup_head,
5093 merge_state: raw.merge_state_status,
5094 contexts,
5095 base: raw.base_ref_name,
5096 })
5097}
5098
5099fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
5102 let v: serde_json::Value = serde_json::from_str(json).ok()?;
5103 if v.get("errors").is_some_and(|e| !e.is_null()) {
5104 return None;
5105 }
5106 let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
5107 let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
5108 let contexts = commit.pointer("/statusCheckRollup/contexts");
5109 let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
5110 return Some((oid.to_owned(), Vec::new()));
5112 };
5113 if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
5114 return None;
5115 }
5116 let nodes = contexts.get("nodes")?.as_array()?;
5117 let rollup = nodes
5118 .iter()
5119 .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
5120 .collect::<Result<Vec<_>, _>>()
5121 .ok()?;
5122 Some((oid.to_owned(), rollup))
5123}
5124
5125async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
5128 let out = gh(
5129 repo,
5130 &[
5131 "api".to_owned(),
5132 "graphql".to_owned(),
5133 "-F".to_owned(),
5134 "owner={owner}".to_owned(),
5135 "-F".to_owned(),
5136 "repo={repo}".to_owned(),
5137 "-F".to_owned(),
5138 format!("number={number}"),
5139 "-f".to_owned(),
5140 "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
5141 name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
5142 statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
5143 ... on CheckRun{name status conclusion detailsUrl \
5144 isRequired(pullRequestNumber:$number)} \
5145 ... on StatusContext{context state targetUrl \
5146 isRequired(pullRequestNumber:$number)}}}}}}}}}}"
5147 .to_owned(),
5148 ],
5149 )
5150 .await
5151 .ok()?;
5152 out.0.then_some(out.1)
5153}
5154
5155#[doc(hidden)]
5157#[derive(Debug, PartialEq)]
5158pub enum Fixed {
5159 Committed {
5161 head: String,
5163 },
5164 Declined,
5166 Failed(String),
5168}
5169
5170#[doc(hidden)]
5176pub async fn fix_round(
5177 state: &mut RunState,
5178 pr: &PrState,
5179 round: usize,
5180 budget: usize,
5181 reason: &str,
5182 logs: &str,
5183) -> Result<Fixed> {
5184 let winner = state
5185 .winner()
5186 .cloned()
5187 .context("landing needs a winning candidate; none is recorded on this run")?;
5188 let roles = state
5189 .config
5190 .resolve_roles()
5191 .context("resolve the roster for the fix round")?;
5192 let attempts = crate::fixer::attempts(state, &roles, &winner);
5196 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5197
5198 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
5199 let mut artifacts = agent::artifacts_dir(&state.dir());
5200 if let Some(request) = state
5201 .merge_repairs
5202 .last()
5203 .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
5204 {
5205 artifacts = artifacts.join(format!("merge-repair-{}", request.question));
5206 }
5207 let prompt = if state.config.cache_dir().is_some() {
5208 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
5209 } else {
5210 prompt
5211 };
5212 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5216 let mut last = None;
5220 for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
5221 if owner_requested_stop(state)? {
5222 return Ok(Fixed::Declined);
5223 }
5224 let mut seat = seat_of(state, &seat_key, &spec.id);
5225 let stem = if i == 0 {
5226 format!("land-{round}")
5227 } else {
5228 format!("land-{round}-{}", spec.id)
5229 };
5230 let out = agent::invoke(
5231 &spec,
5232 &mut seat,
5233 &Invocation {
5234 scope: crate::agent::InvocationScope::Repository,
5235 coordination: state.config.coordination.as_ref(),
5236 fetched_base: state.fetched_base.as_ref(),
5237 cwd: &winner.worktree,
5238 prompt: &prompt,
5239 timeout: Duration::from_secs(state.config.graph.timeout_fix),
5240 allow_write: true,
5241 unsandboxed: false,
5242 sessions: state.config.graph.sessions,
5243 artifacts: &artifacts,
5244 stem: &stem,
5245 run: &state.id,
5246 node: "land",
5247 cache_dir: state.config.cache_dir().as_deref(),
5248 attachments: &[],
5249 writable: &[],
5250 },
5251 )
5252 .await;
5253 state.seats.insert(seat.key.clone(), seat);
5254 if let Some(next) = ids.get(i + 1)
5255 && agent::chain_advances(&out)
5256 {
5257 let (class, why) = crate::fixer::failure_of(&out);
5258 crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
5259 continue;
5260 }
5261 last = Some(out);
5262 break;
5263 }
5264 let out = last.expect("the fixer chain always has an entry");
5265
5266 match out {
5267 Ok(o) if o.quota_exhausted() => {
5268 return Ok(Fixed::Failed(
5269 "rate limited (quota); the fixer could not run".to_owned(),
5270 ));
5271 }
5272 Ok(o) if !o.usable() => {
5273 return Ok(Fixed::Failed(format!(
5274 "the fixer produced nothing usable ({})",
5275 crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
5276 )));
5277 }
5278 Ok(_) => {}
5279 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
5280 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
5281 }
5282
5283 if owner_requested_stop(state)? {
5284 return Ok(Fixed::Declined);
5285 }
5286
5287 let wait = crate::repo_lock::WaitPolicy::new(
5290 Duration::from_secs(state.config.graph.timeout_resource_wait),
5291 || false,
5292 );
5293 let coordination = crate::repo_lock::SeatLease::operation_with(
5294 &winner.worktree,
5295 state.config.coordination.as_ref(),
5296 &wait,
5297 )
5298 .await?;
5299 match git::rescue_commit(
5300 &winner.worktree,
5301 &format!("magi: land round {round} fixes (uncommitted work)"),
5302 )
5303 .await
5304 {
5305 Ok(r) => state.note_withheld("land", &r.withheld),
5306 Err(e)
5307 if state
5308 .merge_repairs
5309 .last()
5310 .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
5311 {
5312 return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
5313 }
5314 Err(_) => {}
5315 }
5316 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5317 drop(coordination);
5318 if after == before {
5319 return Ok(Fixed::Declined);
5320 }
5321
5322 if owner_requested_stop(state)? {
5323 return Ok(Fixed::Declined);
5324 }
5325 crate::pr_head::ensure_owner(state)?;
5326 let remote = state.config.merge.remote.clone();
5327 let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
5328 let push = if state.pr_head.is_some() {
5329 let expected = state
5330 .pr_pushed_head
5331 .as_deref()
5332 .context("missing public head lease")?;
5333 git::push_as(
5334 &winner.worktree,
5335 &remote,
5336 &winner.branch,
5337 &public_head,
5338 expected,
5339 )
5340 .await?
5341 } else {
5342 git::push(&winner.worktree, &remote, &winner.branch).await?
5343 };
5344 if !push.ok() {
5345 return Ok(Fixed::Failed(format!(
5346 "pushing {} to {remote} failed: {}",
5347 public_head, push.stderr
5348 )));
5349 }
5350 state.event(
5351 "land",
5352 format!("round {round}: pushed a fix to {public_head}"),
5353 );
5354 state.pr_pushed_head = Some(after.clone());
5355 state.save()?;
5356 Ok(Fixed::Committed { head: after })
5357}
5358
5359pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
5361 crate::fixer::seat_for(state, key, agent)
5362}
5363
5364pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
5367 let seen = observe(&state.repo, url).await?;
5368 let winner = state.winner().context("no winning branch to repair")?;
5369 if seen.pr.state != PrLifecycle::Open || seen.head != head {
5370 anyhow::bail!(
5371 "PR is closed or its head changed; review the current head before requesting repair again"
5372 );
5373 }
5374 if git::rev_parse(&winner.worktree, "HEAD").await? != head
5375 || !git::is_clean(&winner.worktree).await?
5376 {
5377 anyhow::bail!(
5378 "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
5379 );
5380 }
5381 let view = gh(
5384 &state.repo,
5385 &[
5386 "pr".into(),
5387 "view".into(),
5388 url.into(),
5389 "--json".into(),
5390 "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
5391 ],
5392 )
5393 .await?;
5394 if !view.0 {
5395 bail!("cannot check auto-merge: {}", view.1);
5396 }
5397 let live: serde_json::Value = serde_json::from_str(&view.1)?;
5398 let public = state.pr_branch().unwrap_or(&winner.branch);
5399 if live["state"] != "OPEN"
5400 || live["headRefOid"] != head
5401 || live["headRefName"] != public
5402 || live["baseRefName"] != state.base_branch
5403 || live["isCrossRepository"] != false
5404 {
5405 bail!("PR changed during repair preflight; review its current head explicitly");
5406 }
5407 let auto_merge = live
5408 .get("autoMergeRequest")
5409 .context("cannot read the PR auto-merge reservation; repair held")?;
5410 if !auto_merge.is_null() {
5411 let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
5412 if !out.0 {
5413 bail!("cannot disable auto-merge; repair held: {}", out.1);
5414 }
5415 }
5416 Ok(seen.pr)
5417}
5418
5419fn fix_prompt(
5421 state: &RunState,
5422 pr: &PrState,
5423 round: usize,
5424 budget: usize,
5425 reason: &str,
5426 logs: &str,
5427) -> String {
5428 let mut s = format!(
5429 "Your patch is open as a pull request and it is not landing. Land round \
5430 {round} of {budget}.\n\n\
5431 Pull request: {}\n\n\
5432 What is holding it: {reason}\n\n\
5433 # The task\n\n{}\n",
5434 pr.url, state.instruction
5435 );
5436
5437 if pr.failing.is_empty() {
5438 s.push_str("\n# Failing checks\n\n(none)\n");
5439 } else {
5440 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
5441 if logs.trim().is_empty() {
5442 s.push_str(
5443 "\nNo log could be read; investigate the failure with necessary focused checks.\n",
5444 );
5445 } else {
5446 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
5447 }
5448 }
5449
5450 if pr.review_comments.is_empty() {
5451 s.push_str("\n# Review comments\n\n(none)\n");
5452 } else {
5453 s.push_str("\n# Review comments\n");
5454 for c in &pr.review_comments {
5455 let where_ = match (&c.path, c.line) {
5456 (Some(p), Some(l)) => format!(" ({p}:{l})"),
5457 (Some(p), None) => format!(" ({p})"),
5458 _ => String::new(),
5459 };
5460 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
5461 }
5462 }
5463
5464 s.push_str(
5465 "\n# Rules\n\n\
5466 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
5467 failing test; do not silence a lint with an allow attribute; do not \
5468 stretch a timeout to hide a race. If the check is right, the code is \
5469 wrong.\n\
5470 2. Change nothing the checks and the comments did not raise. A \
5471 drive-by refactor turns a one-line fix into a pull request that \
5472 needs reviewing again.\n\
5473 3. If a comment is wrong, say so with a checkable argument and change \
5474 nothing for it. A declined comment with a reason is a correct \
5475 outcome; a change made to appease a reviewer is not.\n\
5476 4. Commit in this worktree. magi pushes to the pull request's branch \
5477 for you; do not push, merge, or close anything yourself.\n\
5478 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
5479 # Output\n\n\
5480 Say what you changed and why, and what you declined and why.",
5481 );
5482
5483 let language = &state.config.graph.language;
5484 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
5485 let _ = write!(s, "\n\nWrite all prose in {language}.");
5486 }
5487 s.push_str(&crate::prompt::github_rule(
5489 language,
5490 &state.config.graph.github_language,
5491 ));
5492 if let Some(overlay) = state.config.prompts.overlay("fix") {
5493 let _ = write!(s, "\n\n{overlay}");
5494 }
5495 s.push('\n');
5496 s.push_str(crate::prompt::child_run_ownership());
5497 s
5498}
5499
5500async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
5503 let mut out = String::new();
5504 for (name, url) in failing.iter().take(MAX_LOGS) {
5505 let args = match (job_of(url), run_of(url)) {
5506 (Some(job), _) => vec![
5507 "run".to_owned(),
5508 "view".to_owned(),
5509 "--log-failed".to_owned(),
5510 "--job".to_owned(),
5511 job,
5512 ],
5513 (None, Some(run)) => vec![
5514 "run".to_owned(),
5515 "view".to_owned(),
5516 run,
5517 "--log-failed".to_owned(),
5518 ],
5519 (None, None) => continue,
5521 };
5522 let (ok, body) = match gh(repo, &args).await {
5523 Ok(v) => v,
5524 Err(e) => (false, format!("{e:#}")),
5525 };
5526 if !ok && body.trim().is_empty() {
5527 continue;
5528 }
5529 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
5530 }
5531 out
5532}
5533
5534fn job_of(details_url: &str) -> Option<String> {
5537 let after = details_url.split("/job/").nth(1)?;
5538 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5539 (!id.is_empty()).then_some(id)
5540}
5541
5542pub(crate) fn run_of(details_url: &str) -> Option<String> {
5544 let after = details_url.split("/actions/runs/").nth(1)?;
5545 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5546 (!id.is_empty()).then_some(id)
5547}
5548
5549fn stop_comment(run_id: &str, why: &str) -> String {
5553 format!(
5554 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
5555 The branch is untouched and the run is `{run_id}`. Nothing was merged."
5556 )
5557}
5558
5559async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
5564 let body = stop_comment(&state.id, why);
5565 let (_, mut body) = crate::github_text::prepare(state, "", &body);
5566 if !body.contains(MARKER) {
5567 body = format!("{MARKER}\n{body}");
5568 }
5569 let posted = gh(
5570 repo,
5571 &[
5572 "pr".to_owned(),
5573 "comment".to_owned(),
5574 pr.number.to_string(),
5575 "--body".to_owned(),
5576 body,
5577 ],
5578 )
5579 .await;
5580 match posted {
5581 Ok((true, _)) => {}
5582 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
5583 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
5584 }
5585 state.status = RunStatus::Blocked;
5586 state.merge = Some(MergeOutcome {
5587 mode: MergeMode::Pr,
5588 ok: false,
5589 detail: why.to_owned(),
5590 empty: false,
5591 });
5592 state.event("land", format!("stopped: {why}"));
5593 state.save()?;
5594 Ok(())
5595}
5596
5597pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
5613 let out = tokio::process::Command::new("gh")
5614 .args(args)
5615 .current_dir(cwd)
5616 .env_remove("GH_REPO")
5617 .quiet()
5618 .stdin(std::process::Stdio::null())
5619 .output()
5620 .await
5621 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
5622 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
5623 let err = String::from_utf8_lossy(&out.stderr);
5624 if body.trim().is_empty() {
5625 body = err.into_owned();
5626 } else if !err.trim().is_empty() {
5627 body.push_str(&err);
5628 }
5629 Ok((out.status.success(), body.trim().to_owned()))
5630}
5631
5632#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5634pub(crate) enum Verdict {
5635 Pass,
5636 Fail,
5637 Pending,
5638 Unknown,
5639}
5640
5641impl Verdict {
5642 pub(crate) fn label(self) -> &'static str {
5644 match self {
5645 Self::Pass => "passed",
5646 Self::Fail => "failed",
5647 Self::Pending => "pending",
5648 Self::Unknown => "unknown",
5649 }
5650 }
5651}
5652
5653#[derive(Debug, Deserialize)]
5654#[serde(rename_all = "camelCase")]
5655struct GhPr {
5656 #[serde(default)]
5657 url: String,
5658 #[serde(default)]
5659 number: u64,
5660 #[serde(default)]
5661 state: String,
5662 #[serde(default)]
5663 title: String,
5664 #[serde(default)]
5665 status_check_rollup: Vec<GhCheck>,
5666 #[serde(default)]
5673 merge_state_status: String,
5674 #[serde(default)]
5678 head_ref_oid: String,
5679 #[serde(default)]
5680 base_ref_name: String,
5681 #[serde(default)]
5682 reviews: Vec<GhReview>,
5683 #[serde(default)]
5684 comments: Vec<GhComment>,
5685}
5686
5687#[derive(Debug, Deserialize)]
5692#[serde(rename_all = "camelCase")]
5693struct GhCheck {
5694 #[serde(default)]
5695 name: Option<String>,
5696 #[serde(default)]
5697 context: Option<String>,
5698 #[serde(default)]
5699 status: Option<String>,
5700 #[serde(default)]
5701 conclusion: Option<String>,
5702 #[serde(default)]
5703 state: Option<String>,
5704 #[serde(default)]
5705 details_url: Option<String>,
5706 #[serde(default)]
5707 target_url: Option<String>,
5708 #[serde(default)]
5711 is_required: Option<bool>,
5712}
5713
5714impl GhCheck {
5715 fn label(&self) -> String {
5717 self.name
5718 .clone()
5719 .or_else(|| self.context.clone())
5720 .unwrap_or_else(|| "(unnamed check)".to_owned())
5721 }
5722
5723 fn url(&self) -> Option<&str> {
5725 self.details_url
5726 .as_deref()
5727 .or(self.target_url.as_deref())
5728 .filter(|u| !u.is_empty())
5729 }
5730
5731 fn verdict(&self) -> Verdict {
5739 if let Some(status) = self.status.as_deref() {
5740 if !status.eq_ignore_ascii_case("COMPLETED") {
5741 return Verdict::Pending;
5742 }
5743 }
5744 let outcome = self
5745 .conclusion
5746 .as_deref()
5747 .or(self.state.as_deref())
5748 .unwrap_or("");
5749 match outcome.to_ascii_uppercase().as_str() {
5750 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
5751 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
5752 | "ACTION_REQUIRED" => Verdict::Fail,
5753 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
5754 Verdict::Pending
5755 }
5756 _ => Verdict::Unknown,
5757 }
5758 }
5759}
5760
5761#[derive(Debug, Deserialize)]
5762struct GhAuthor {
5763 #[serde(default)]
5764 login: String,
5765}
5766
5767#[derive(Debug, Deserialize)]
5768struct GhReview {
5769 #[serde(default)]
5770 author: GhAuthor,
5771 #[serde(default)]
5772 body: String,
5773}
5774
5775#[derive(Debug, Deserialize)]
5776struct GhComment {
5777 #[serde(default)]
5778 author: GhAuthor,
5779 #[serde(default)]
5780 body: String,
5781}
5782
5783#[derive(Debug, Deserialize)]
5784struct GhUser {
5785 #[serde(default)]
5786 login: String,
5787}
5788
5789#[derive(Debug, Deserialize)]
5790struct GhInline {
5791 #[serde(default)]
5792 user: GhUser,
5793 #[serde(default)]
5794 path: Option<String>,
5795 #[serde(default)]
5796 line: Option<u64>,
5797 #[serde(default)]
5798 body: String,
5799}
5800
5801impl Default for GhAuthor {
5802 fn default() -> Self {
5803 Self {
5804 login: "(unknown)".to_owned(),
5805 }
5806 }
5807}
5808
5809impl Default for GhUser {
5810 fn default() -> Self {
5811 Self {
5812 login: "(unknown)".to_owned(),
5813 }
5814 }
5815}
5816
5817#[cfg(test)]
5818pub(crate) mod tests {
5819 use super::*;
5820 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
5821
5822 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
5823 format!(
5824 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
5825 )
5826 }
5827
5828 #[test]
5829 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
5830 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
5831 assert_eq!(
5832 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
5833 Ok(())
5834 );
5835 for (json, why) in [
5836 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
5837 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
5838 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
5839 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
5840 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
5841 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
5842 ] {
5843 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
5844 .unwrap_err()
5845 .why;
5846 assert!(err.contains(why), "{json}: {err}");
5847 }
5848 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
5850 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
5851 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
5852 assert!(
5853 !closable(
5854 &head_json("x", "main", "OPEN", false),
5855 "magi/27b2/A",
5856 "main",
5857 &[]
5858 )
5859 .unwrap_err()
5860 .retry
5861 );
5862 assert!(is_forge_url("https://github.com/o/r.git"));
5863 assert!(is_forge_url("git@github.com:o/r.git"));
5864 assert!(!is_forge_url("/tmp/origin.git"));
5865 assert!(!is_forge_url("C:\\work\\origin.git"));
5866 assert!(!is_forge_url("file:///tmp/origin.git"));
5867 assert!(forge_unavailable(
5868 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
5869 ));
5870 assert!(!forge_unavailable(
5871 "gh pr list failed: error connecting to api.github.com"
5872 ));
5873 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
5874 assert!(
5876 closable(
5877 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
5878 "b",
5879 "main",
5880 &["aaa".to_owned()]
5881 )
5882 .is_err()
5883 );
5884 }
5885
5886 #[test]
5887 fn the_close_comment_names_the_commit_on_the_base() {
5888 let e = crate::already::Evidence {
5889 proof: crate::already::Proof::PatchId,
5890 tip: "1234567890".to_owned(),
5891 commits: vec!["0e368de0000".to_owned()],
5892 };
5893 let c = superseded_comment("main", &e);
5894 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
5895 }
5896
5897 const GREEN_OPEN: &str = r####"{
5899 "url": "https://github.com/yukimemi/magi/pull/10",
5900 "number": 10,
5901 "state": "OPEN",
5902 "mergeStateStatus": "CLEAN",
5903 "statusCheckRollup": [
5904 {
5905 "__typename": "CheckRun",
5906 "conclusion": "SKIPPED",
5907 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
5908 "name": "review",
5909 "status": "COMPLETED",
5910 "workflowName": "claude-review"
5911 },
5912 {
5913 "__typename": "CheckRun",
5914 "conclusion": "SUCCESS",
5915 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
5916 "name": "check (ubuntu-latest)",
5917 "status": "COMPLETED",
5918 "workflowName": "CI"
5919 },
5920 {
5921 "__typename": "CheckRun",
5922 "conclusion": "SUCCESS",
5923 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
5924 "name": "rustfmt",
5925 "status": "COMPLETED",
5926 "workflowName": "CI"
5927 },
5928 {
5929 "__typename": "StatusContext",
5930 "context": "CodeRabbit",
5931 "state": "SUCCESS",
5932 "targetUrl": ""
5933 }
5934 ],
5935 "reviews": [],
5936 "comments": [
5937 {
5938 "author": {
5939 "login": "coderabbitai"
5940 },
5941 "authorAssociation": "NONE",
5942 "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"
5943 }
5944 ]
5945}"####;
5946
5947 const RED_OPEN: &str = r####"{
5949 "url": "https://github.com/yukimemi/magi/pull/9",
5950 "number": 9,
5951 "state": "OPEN",
5952 "mergeStateStatus": "UNSTABLE",
5953 "statusCheckRollup": [
5954 {
5955 "__typename": "CheckRun",
5956 "conclusion": "SUCCESS",
5957 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5958 "name": "check (ubuntu-latest)",
5959 "status": "COMPLETED",
5960 "workflowName": "CI"
5961 },
5962 {
5963 "__typename": "CheckRun",
5964 "conclusion": "SUCCESS",
5965 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5966 "name": "rustfmt",
5967 "status": "COMPLETED",
5968 "workflowName": "CI"
5969 },
5970 {
5971 "__typename": "CheckRun",
5972 "conclusion": "FAILURE",
5973 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5974 "name": "editorconfig",
5975 "status": "COMPLETED",
5976 "workflowName": "CI"
5977 },
5978 {
5979 "__typename": "StatusContext",
5980 "context": "CodeRabbit",
5981 "state": "SUCCESS",
5982 "targetUrl": ""
5983 }
5984 ],
5985 "reviews": [],
5986 "comments": [
5987 {
5988 "author": {
5989 "login": "coderabbitai"
5990 },
5991 "authorAssociation": "NONE",
5992 "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"
5993 }
5994 ]
5995}"####;
5996
5997 const PENDING_OPEN: &str = r####"{
5999 "url": "https://github.com/yukimemi/magi/pull/9",
6000 "number": 9,
6001 "state": "OPEN",
6002 "statusCheckRollup": [
6003 {
6004 "__typename": "CheckRun",
6005 "conclusion": "SUCCESS",
6006 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6007 "name": "check (ubuntu-latest)",
6008 "status": "COMPLETED",
6009 "workflowName": "CI"
6010 },
6011 {
6012 "__typename": "CheckRun",
6013 "conclusion": "SUCCESS",
6014 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6015 "name": "rustfmt",
6016 "status": "COMPLETED",
6017 "workflowName": "CI"
6018 },
6019 {
6020 "__typename": "CheckRun",
6021 "conclusion": null,
6022 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6023 "name": "editorconfig",
6024 "status": "IN_PROGRESS",
6025 "workflowName": "CI"
6026 },
6027 {
6028 "__typename": "StatusContext",
6029 "context": "CodeRabbit",
6030 "state": "SUCCESS",
6031 "targetUrl": ""
6032 }
6033 ],
6034 "reviews": [],
6035 "comments": []
6036}"####;
6037
6038 const MERGED: &str = r####"{
6040 "url": "https://github.com/yukimemi/magi/pull/16",
6041 "number": 16,
6042 "state": "MERGED",
6043 "statusCheckRollup": [
6044 {
6045 "__typename": "CheckRun",
6046 "conclusion": "SUCCESS",
6047 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
6048 "name": "check (ubuntu-latest)",
6049 "status": "COMPLETED",
6050 "workflowName": "CI"
6051 },
6052 {
6053 "__typename": "CheckRun",
6054 "conclusion": "SUCCESS",
6055 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
6056 "name": "review",
6057 "status": "COMPLETED",
6058 "workflowName": "claude-review"
6059 }
6060 ],
6061 "reviews": [],
6062 "comments": []
6063}"####;
6064
6065 const REVIEWED_OPEN: &str = r####"{
6067 "url": "https://github.com/yukimemi/magi/pull/12",
6068 "number": 12,
6069 "state": "OPEN",
6070 "statusCheckRollup": [
6071 {
6072 "__typename": "CheckRun",
6073 "conclusion": "SUCCESS",
6074 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
6075 "name": "check (ubuntu-latest)",
6076 "status": "COMPLETED",
6077 "workflowName": "CI"
6078 },
6079 {
6080 "__typename": "CheckRun",
6081 "conclusion": "SUCCESS",
6082 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
6083 "name": "review",
6084 "status": "COMPLETED",
6085 "workflowName": "claude-review"
6086 }
6087 ],
6088 "reviews": [
6089 {
6090 "author": {
6091 "login": "claude"
6092 },
6093 "state": "COMMENTED",
6094 "body": ""
6095 }
6096 ],
6097 "comments": [
6098 {
6099 "author": {
6100 "login": "coderabbitai"
6101 },
6102 "authorAssociation": "NONE",
6103 "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"
6104 },
6105 {
6106 "author": {
6107 "login": "claude"
6108 },
6109 "authorAssociation": "NONE",
6110 "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"
6111 }
6112 ]
6113}"####;
6114
6115 const INLINE: &str = r####"[
6117 {
6118 "user": {
6119 "login": "claude[bot]"
6120 },
6121 "path": "src/graph.rs",
6122 "line": 231,
6123 "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"
6124 }
6125]"####;
6126
6127 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
6129<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
6130
6131> [!IMPORTANT]
6132> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
6133>
6134> This repository does not receive automatic reviews because it has fewer than 10 stars.
6135>
6136> <details>
6137> <summary>⚙️ Run configuration</summary>
6138>
6139> **Configuration used**: defaults
6140>
6141> **Review profile**: CHILL
6142>
6143> **Plan**: Team
6144>
6145> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
6146>
6147> </details>
6148
6149<!-- end of auto-generated comment: skip review by coderabbit.ai -->
6150
6151<!-- tips_start -->
6152
6153---
6154
6155Thanks 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.
6156
6157<details>
6158<summary>❤️ Share</summary>
6159
6160- [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"####;
6161
6162 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
6164
6165---
6166### Reviewing PR #16
6167
6168- [x] Read AGENTS.md conventions
6169- [x] Review `src/daemon.rs` changes
6170- [x] Review `src/main.rs` changes (new `doctor` reporting)
6171- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
6172- [x] Check test coverage for new behavior
6173- [x] Run verification commands (blocked — see note)
6174- [x] Post findings"####;
6175
6176 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
6178
6179---
6180### Review: `magi review <branch>` — cheap-half-only graph
6181
6182Read 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.
6183
6184**Correctness**
6185
6186- 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"####;
6187
6188 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
6189 PrState {
6190 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
6191 number: 16,
6192 state: PrLifecycle::Open,
6193 checks,
6194 blocking: if matches!(checks, Checks::Red) {
6198 Blocking::Yes
6199 } else {
6200 Blocking::No
6201 },
6202 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
6203 review_comments: (0..comments)
6204 .map(|i| ReviewComment {
6205 author: "coderabbitai".to_owned(),
6206 path: Some("src/graph.rs".to_owned()),
6207 line: Some(231),
6208 body: format!("finding {i}"),
6209 })
6210 .collect(),
6211 }
6212 }
6213
6214 #[test]
6215 fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
6216 use CiExpectation::{Absent, Expected};
6217 for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
6218 let p = pr(checks, &["x"], 0);
6219 for waited in [Duration::ZERO, CHECKS_GRACE] {
6220 assert_eq!(
6221 decide_with(&p, 0, 4, waited, Expected),
6222 decide(&p, 0, 4, waited)
6223 );
6224 }
6225 }
6226 for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
6228 let p = pr(checks, &["x"], 0);
6229 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
6230 assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
6231 }
6232 let mut p = pr(Checks::Unknown, &[], 0);
6234 p.blocking = Blocking::Conflict;
6235 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
6236 p.state = PrLifecycle::Merged;
6238 assert_eq!(
6239 decide_with(&p, 0, 4, Duration::ZERO, Absent),
6240 Step::Done { merged: true }
6241 );
6242 }
6243
6244 #[test]
6245 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
6246 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
6247 assert_eq!(state.number, 10);
6248 assert_eq!(state.state, PrLifecycle::Open);
6249 assert_eq!(state.checks, Checks::Green);
6250 assert!(state.failing.is_empty());
6251 assert!(
6252 state.review_comments.is_empty(),
6253 "the only comment is CodeRabbit's trigger notice: {:?}",
6254 state.review_comments
6255 );
6256 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
6257 }
6258
6259 #[test]
6260 fn a_failing_check_parses_as_red_and_is_named() {
6261 let state = parse_pr(RED_OPEN).expect("red fixture parses");
6262 assert_eq!(state.checks, Checks::Red);
6263 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
6264 let mut blocking = state.clone();
6271 blocking.blocking = Blocking::Yes;
6272 match decide(&blocking, 0, 4, Duration::ZERO) {
6273 Step::Fix { reason } => {
6274 assert!(reason.contains("editorconfig"), "reason: {reason}");
6275 assert!(reason.contains("failing"), "reason: {reason}");
6276 }
6277 other => panic!("expected a fix round, got {other:?}"),
6278 }
6279 }
6280
6281 #[test]
6282 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
6283 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
6284 assert_eq!(state.checks, Checks::Pending);
6285 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
6286 }
6287
6288 #[test]
6289 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
6290 let state = parse_pr(MERGED).expect("merged fixture parses");
6291 assert_eq!(state.state, PrLifecycle::Merged);
6292 assert_eq!(
6293 decide(&state, 0, 4, Duration::ZERO),
6294 Step::Done { merged: true }
6295 );
6296 }
6297
6298 #[test]
6299 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
6300 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
6301 assert_eq!(state.checks, Checks::Green);
6302 let authors: Vec<&str> = state
6303 .review_comments
6304 .iter()
6305 .map(|c| c.author.as_str())
6306 .collect();
6307 assert_eq!(
6308 authors,
6309 vec!["claude"],
6310 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
6311 );
6312 match decide(&state, 0, 4, Duration::ZERO) {
6313 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
6314 other => panic!("expected a fix round, got {other:?}"),
6315 }
6316 }
6317
6318 #[test]
6319 fn inline_review_comments_keep_their_file_and_line() {
6320 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
6321 assert_eq!(comments.len(), 1);
6322 assert_eq!(comments[0].author, "claude[bot]");
6323 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
6324 assert_eq!(comments[0].line, Some(231));
6325 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
6326 }
6327
6328 #[test]
6329 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
6330 assert!(
6331 is_noise(CODERABBIT_TRIGGER),
6332 "CodeRabbit's trigger notice declares itself not a review"
6333 );
6334 assert!(
6335 is_noise(CLAUDE_CHECKLIST),
6336 "a progress checklist asks for nothing"
6337 );
6338 assert!(
6339 !is_noise(CLAUDE_FINDING),
6340 "a review that names a bug is input, not noise"
6341 );
6342
6343 let mut clean = pr(Checks::Green, &[], 0);
6344 clean.review_comments.push(ReviewComment {
6345 author: "coderabbitai".to_owned(),
6346 path: None,
6347 line: None,
6348 body: CODERABBIT_TRIGGER.to_owned(),
6349 });
6350 clean.review_comments.retain(|c| !is_noise(&c.body));
6351 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
6352
6353 let mut found = pr(Checks::Green, &[], 0);
6354 found.review_comments.push(ReviewComment {
6355 author: "claude".to_owned(),
6356 path: None,
6357 line: None,
6358 body: CLAUDE_FINDING.to_owned(),
6359 });
6360 found.review_comments.retain(|c| !is_noise(&c.body));
6361 assert!(matches!(
6362 decide(&found, 0, 4, Duration::ZERO),
6363 Step::Fix { .. }
6364 ));
6365 }
6366
6367 #[test]
6368 fn the_policy_table_holds_for_every_combination_that_matters() {
6369 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
6370 (
6371 "pending checks are waited for, even on the last round",
6372 pr(Checks::Pending, &[], 0),
6373 4,
6374 4,
6375 Duration::ZERO,
6376 Step::Wait,
6377 ),
6378 (
6379 "red checks are fixed",
6380 pr(Checks::Red, &["editorconfig"], 0),
6381 0,
6382 4,
6383 Duration::ZERO,
6384 Step::Fix {
6385 reason: "1 check(s) failing: editorconfig".to_owned(),
6386 },
6387 ),
6388 (
6389 "green with comments is fixed, not merged",
6390 pr(Checks::Green, &[], 2),
6391 1,
6392 4,
6393 Duration::ZERO,
6394 Step::Fix {
6395 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
6396 .to_owned(),
6397 },
6398 ),
6399 (
6400 "green and clean merges",
6401 pr(Checks::Green, &[], 0),
6402 3,
6403 4,
6404 Duration::ZERO,
6405 Step::Merge,
6406 ),
6407 (
6408 "an unreadable rollup is waited on while the grace lasts",
6409 pr(Checks::Unknown, &[], 0),
6410 0,
6411 4,
6412 Duration::ZERO,
6413 Step::Wait,
6414 ),
6415 (
6416 "an unreadable rollup is never merged once the grace is spent",
6417 pr(Checks::Unknown, &[], 0),
6418 0,
6419 4,
6420 CHECKS_GRACE,
6421 Step::GiveUp {
6422 reason: "no check status is readable on the pull request after 3 minute(s); \
6423 refusing to merge on a guess"
6424 .to_owned(),
6425 },
6426 ),
6427 ];
6428 for (what, state, round, budget, waited, want) in cases {
6429 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
6430 }
6431 }
6432
6433 #[test]
6434 fn the_forge_verdict_survives_the_round_trip_from_gh() {
6435 let green = parse_pr(GREEN_OPEN).expect("parse");
6439 assert_eq!(green.blocking, Blocking::No);
6440 let red = parse_pr(RED_OPEN).expect("parse");
6441 assert_eq!(
6442 red.blocking,
6443 Blocking::No,
6444 "`UNSTABLE` is mergeable: the red check is one nobody requires"
6445 );
6446 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
6447 let quiet =
6449 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
6450 assert_eq!(quiet.blocking, Blocking::Unsaid);
6451 }
6452
6453 #[test]
6454 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
6455 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
6461 nonblocking.blocking = Blocking::No;
6462 assert_eq!(
6463 decide(&nonblocking, 0, 4, Duration::ZERO),
6464 Step::Merge,
6465 "the forge says nothing is in the way, so nothing is"
6466 );
6467
6468 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
6470 blocking.blocking = Blocking::Yes;
6471 assert!(matches!(
6472 decide(&blocking, 0, 4, Duration::ZERO),
6473 Step::Fix { .. }
6474 ));
6475
6476 let mut commented = pr(Checks::Red, &["coverage"], 1);
6479 commented.blocking = Blocking::No;
6480 assert!(matches!(
6481 decide(&commented, 0, 4, Duration::ZERO),
6482 Step::Fix { .. }
6483 ));
6484
6485 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
6487 unsaid.blocking = Blocking::Unsaid;
6488 assert!(matches!(
6489 decide(&unsaid, 0, 4, Duration::ZERO),
6490 Step::Fix { .. }
6491 ));
6492 }
6493
6494 #[test]
6495 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
6496 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
6497 red.blocking = Blocking::No;
6498 assert_eq!(
6499 decide(&red, 0, 4, Duration::ZERO),
6500 Step::Merge,
6501 "announcing must not change the decision"
6502 );
6503 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
6504 assert!(said.contains("yukimemi/magi"), "{said}");
6505 assert!(said.contains("#16"), "{said}");
6506 assert!(
6507 said.contains("https://github.com/yukimemi/magi/pull/16"),
6508 "{said}"
6509 );
6510 assert!(
6511 said.contains("test (windows-latest)") && said.contains("coverage"),
6512 "{said}"
6513 );
6514
6515 let green = pr(Checks::Green, &["stale"], 0);
6517 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
6518 }
6519
6520 #[test]
6521 fn red_merge_notices_omit_empty_check_details() {
6522 for checks in [vec![], vec!["", " "]] {
6523 let red = pr(Checks::Red, &checks, 0);
6524 let summary = red_merge_summary("owner/repo", &red).unwrap();
6525 let notice = crate::notices::merged_red("run-1", &summary);
6526 assert_eq!(
6527 notice.message,
6528 format!("Merged owner/repo PR #16 with red checks ({})", red.url)
6529 );
6530 assert_eq!(notice.severity, crate::notices::Severity::Warn);
6531 }
6532 let red = pr(Checks::Red, &["", " test ", " "], 0);
6533 let summary = red_merge_summary("owner/repo", &red).unwrap();
6534 assert!(summary.contains("with red checks: test ("), "{summary}");
6535 }
6536
6537 #[test]
6538 fn the_repo_label_comes_from_the_pull_request_url() {
6539 let p = Path::new("/tmp/checkout");
6540 assert_eq!(
6541 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
6542 "yukimemi/magi"
6543 );
6544 assert_eq!(repo_label(p, "not a url"), "checkout");
6545 }
6546
6547 #[test]
6548 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
6549 let mut conflicted = pr(Checks::Green, &[], 0);
6554 conflicted.blocking = Blocking::Conflict;
6555 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
6556
6557 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
6561 red.blocking = Blocking::Conflict;
6562 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
6563
6564 let mut merged = pr(Checks::Red, &[], 0);
6566 merged.blocking = Blocking::Conflict;
6567 merged.state = PrLifecycle::Merged;
6568 assert_eq!(
6569 decide(&merged, 0, 4, Duration::ZERO),
6570 Step::Done { merged: true }
6571 );
6572 }
6573
6574 #[test]
6575 fn the_forge_verdict_is_read_off_merge_state_status() {
6576 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
6579 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
6580 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
6581 }
6582 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
6583 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
6584 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
6585 for quiet in ["", "UNKNOWN"] {
6588 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
6589 assert!(Blocking::of(quiet).stops_a_merge());
6590 }
6591 }
6592
6593 #[test]
6594 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
6595 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
6596 let jj = "could not determine current branch: failed to run git: not on any branch";
6598
6599 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
6600 .expect("the forge says merged, so it merged");
6601 assert!(landed.ok);
6602 assert!(
6603 landed.detail.contains("but the pull request is merged"),
6604 "the record must not read as a clean success: {}",
6605 landed.detail
6606 );
6607 assert!(
6608 landed.detail.contains("not on any branch"),
6609 "and it must keep what the command actually said: {}",
6610 landed.detail
6611 );
6612
6613 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
6615 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
6616 assert!(merged_after_all(&argv, jj, None).is_none());
6618 }
6619
6620 #[test]
6621 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
6622 let mut state = pr(Checks::Red, &["editorconfig"], 3);
6623 state.state = PrLifecycle::Closed;
6624 assert_eq!(
6625 decide(&state, 0, 4, Duration::ZERO),
6626 Step::Done { merged: false },
6627 "a human closing the pull request ends the loop, whatever CI says"
6628 );
6629 }
6630
6631 #[test]
6632 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
6633 let red = decide(
6634 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
6635 4,
6636 4,
6637 Duration::ZERO,
6638 );
6639 match red {
6640 Step::GiveUp { reason } => {
6641 assert!(reason.contains("editorconfig"), "reason: {reason}");
6642 assert!(reason.contains("test (macos)"), "reason: {reason}");
6643 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
6644 }
6645 other => panic!("expected a give-up, got {other:?}"),
6646 }
6647
6648 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
6649 match commented {
6650 Step::GiveUp { reason } => {
6651 assert!(reason.contains("unresolved"), "reason: {reason}");
6652 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
6653 }
6654 other => panic!("expected a give-up, got {other:?}"),
6655 }
6656 }
6657
6658 #[test]
6659 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
6660 let candidate_commit = "magi: candidate A (uncommitted work)";
6661 let subject = merge_subject(candidate_commit, "add retries to the uploader");
6662 let argv = merge_argv(16, &subject);
6663
6664 assert!(argv.contains(&"--squash".to_owned()));
6665 assert!(argv.contains(&"--delete-branch".to_owned()));
6666 assert!(argv.contains(&"--subject".to_owned()));
6667 assert_eq!(
6668 argv.last().map(String::as_str),
6669 Some("add retries to the uploader"),
6670 "the subject must not be the candidate commit message"
6671 );
6672 assert_ne!(subject, candidate_commit);
6673 }
6674
6675 #[test]
6676 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
6677 assert_eq!(
6678 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
6679 "feat: a queue, an unattended loop, and a phone UI"
6680 );
6681 assert_eq!(
6682 merge_subject("", "# port the retry logic\n\ndetails"),
6683 "port the retry logic",
6684 "an empty title falls back to the task's first line, heading marks stripped"
6685 );
6686 }
6687
6688 #[test]
6689 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
6690 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
6691 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
6692 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
6693 assert_eq!(job_of("https://coderabbit.ai/status"), None);
6694 assert_eq!(run_of(""), None);
6695 }
6696
6697 #[test]
6698 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
6699 let mut out = Vec::new();
6700 push_if_outstanding(
6701 &mut out,
6702 ReviewComment {
6703 author: "yukimemi".to_owned(),
6704 path: None,
6705 line: None,
6706 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
6707 },
6708 );
6709 assert!(out.is_empty());
6710 }
6711
6712 fn run_state() -> RunState {
6716 let mut state = RunState::new(
6717 std::path::PathBuf::from("/repo/magi"),
6718 "main".to_owned(),
6719 "abcdef1234".to_owned(),
6720 "add retries to the uploader".to_owned(),
6721 crate::config::Config::default(),
6722 );
6723 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
6728 let nanos = std::time::SystemTime::now()
6729 .duration_since(std::time::UNIX_EPOCH)
6730 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
6731 state.id = format!(
6732 "20261004-{nanos:06}-{:04x}",
6733 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
6734 );
6735 state
6736 }
6737
6738 fn green_pr() -> PrState {
6739 PrState {
6740 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
6741 number: 42,
6742 state: PrLifecycle::Open,
6743 checks: Checks::Green,
6744 blocking: Blocking::No,
6746 failing: Vec::new(),
6747 review_comments: vec![ReviewComment {
6748 author: "coderabbitai".to_owned(),
6749 path: Some("src/land.rs".to_owned()),
6750 line: Some(212),
6751 body: "this branch never checks the exit code".to_owned(),
6752 }],
6753 }
6754 }
6755
6756 #[test]
6757 fn land_fixer_defers_review_and_verification_without_a_cache() {
6758 let state = run_state();
6759 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6760 assert!(p.contains(crate::prompt::child_run_ownership()), "{p}");
6761 assert!(p.contains("Commit in this worktree"), "{p}");
6762 }
6763
6764 #[test]
6765 fn github_facing_land_text_is_english_whatever_the_language() {
6766 let mut state = run_state();
6767 state.config.graph.language = "ja".to_owned();
6768 let comment = stop_comment(&state.id, "checks are still red");
6769 assert!(crate::github_text::check("", &comment).is_empty());
6770 assert!(comment.is_ascii(), "{comment}");
6771 assert!(comment.starts_with(MARKER));
6772
6773 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6774 let ja_at = p.find("Write all prose in ja").unwrap();
6775 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
6776 assert!(ja_at < rule_at, "{p}");
6777 assert!(p.contains("stays in Japanese"), "{p}");
6778
6779 state.config.graph.language = "en".to_owned();
6780 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6781 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
6782 assert!(!p.contains("does not apply"), "{p}");
6783 }
6784
6785 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
6786
6787 fn panel() -> String {
6788 approval_panel(
6789 &run_state(),
6790 &green_pr(),
6791 NUMSTAT,
6792 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
6793 &[
6794 "land: ask before merging".to_owned(),
6795 "land: colour the diff".to_owned(),
6796 ],
6797 "feat: merge approval from the phone",
6798 )
6799 }
6800
6801 #[test]
6802 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
6803 let html = panel();
6804 for needle in [
6805 "42",
6806 "main",
6807 "src/land.rs",
6808 "src/web.rs",
6809 "assets/logo.png",
6810 "feat: merge approval from the phone",
6811 "land: ask before merging",
6812 "land: colour the diff",
6813 "coderabbitai",
6814 "this branch never checks the exit code",
6815 "green",
6816 ] {
6817 assert!(html.contains(needle), "the panel must state `{needle}`");
6818 }
6819 }
6820
6821 async fn landing_git_fixture() -> (tempfile::TempDir, RunState, String) {
6822 crate::run::pin_test_home();
6823 let tmp = tempfile::tempdir().unwrap();
6824 let repo = tmp.path().join("repo");
6825 let remote = tmp.path().join("remote.git");
6826 std::fs::create_dir(&repo).unwrap();
6827 git::git(tmp.path(), &["init", "--bare", remote.to_str().unwrap()])
6828 .await
6829 .unwrap();
6830 git::git(&repo, &["init", "-b", "main"]).await.unwrap();
6831 git::git(&repo, &["config", "user.name", "Test"])
6832 .await
6833 .unwrap();
6834 git::git(&repo, &["config", "user.email", "test@example.test"])
6835 .await
6836 .unwrap();
6837 std::fs::write(repo.join("base"), "base").unwrap();
6838 git::git(&repo, &["add", "."]).await.unwrap();
6839 git::git(&repo, &["commit", "-m", "base"]).await.unwrap();
6840 git::git(
6841 &repo,
6842 &["remote", "add", "origin", remote.to_str().unwrap()],
6843 )
6844 .await
6845 .unwrap();
6846 git::git(&repo, &["push", "origin", "main"]).await.unwrap();
6847 git::git(&repo, &["checkout", "-b", "winner"])
6848 .await
6849 .unwrap();
6850 std::fs::write(repo.join("change"), "change").unwrap();
6851 git::git(&repo, &["add", "."]).await.unwrap();
6852 git::git(&repo, &["commit", "-m", "change"]).await.unwrap();
6853 let head = git::rev_parse(&repo, "HEAD").await.unwrap();
6854 git::git(&repo, &["push", "origin", "HEAD:refs/heads/task-head"])
6855 .await
6856 .unwrap();
6857 let mut state = run_state();
6858 state.repo = repo.clone();
6859 state.base_commit = git::rev_parse(&repo, "origin/main").await.unwrap();
6860 state.config.graph.land = true;
6861 state.config.graph.land_approval = true;
6862 state.pr_head = Some("task-head".into());
6863 state.pr_pushed_head = Some(head.clone());
6864 state.candidates = vec![winning_candidate("change")];
6865 state.candidates[0].worktree = repo;
6866 state.candidates[0].branch = "winner".into();
6867 state.tally = Some(uncontested_tally());
6868 state.gate_ran = true;
6869 state.gate_head = Some(head.clone());
6870 state.land_sync_pending = true;
6871 state.save().unwrap();
6872 (tmp, state, head)
6873 }
6874
6875 async fn resume_observed_head(state: &mut RunState, head: &str) -> Result<String> {
6876 let forge = Scripted::new(vec![seen(head, Checks::Green, "CLEAN", false)], vec![]);
6877 resume_landing_head(state, head, &green_pr().url, &forge).await
6878 }
6879
6880 async fn advance_landing_base(state: &RunState) {
6881 git::git(&state.repo, &["checkout", "main"]).await.unwrap();
6882 std::fs::write(state.repo.join("advance"), "advance").unwrap();
6883 git::git(&state.repo, &["add", "."]).await.unwrap();
6884 git::git(&state.repo, &["commit", "-m", "advance base"])
6885 .await
6886 .unwrap();
6887 git::git(&state.repo, &["push", "origin", "main"])
6888 .await
6889 .unwrap();
6890 git::git(&state.repo, &["checkout", "winner"])
6891 .await
6892 .unwrap();
6893 }
6894
6895 #[tokio::test]
6896 async fn landing_resume_gates_rebased_sha_and_publishes_it_to_the_existing_head() {
6897 let (_tmp, mut state, old) = landing_git_fixture().await;
6898 advance_landing_base(&state).await;
6899 state.config.verify.gate = vec!["test -f advance".into()];
6900 let head = resume_observed_head(&mut state, &old).await.unwrap();
6901 assert_ne!(head, old);
6902 assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
6903 assert!(state.gate_allows_progress());
6904 assert_eq!(
6905 git::remote_head(&state.repo, "origin", "task-head")
6906 .await
6907 .unwrap(),
6908 Some(head.clone())
6909 );
6910 assert_eq!(state.pr_pushed_head, Some(head));
6911 assert!(!state.land_sync_pending);
6912 assert!(state.config.graph.land_approval);
6913 }
6914
6915 fn landing_gate_fixer(state: &mut RunState, script: &str) {
6916 state.config.disk.min_free_bytes = 0;
6917 state.config.graph.gate_fix_rounds = 1;
6918 state.config.agents = vec![crate::config::AgentSpec {
6919 id: "mock".into(),
6920 kind: crate::config::AgentKind::Command,
6921 model: None,
6922 command: vec!["sh".into(), "-c".into(), script.into()],
6923 extra_args: Vec::new(),
6924 env: Default::default(),
6925 prompt_delivery: None,
6926 max_turn_secs: None,
6927 }];
6928 state.candidates[0].agent = "mock".into();
6929 }
6930
6931 #[tokio::test]
6932 async fn landing_resume_revalidates_an_operator_published_winner_extension() {
6933 let (_tmp, mut state, old) = landing_git_fixture().await;
6934 std::fs::write(state.repo.join("change"), "operator update").unwrap();
6935 git::commit_all(&state.repo, "extend winner").await.unwrap();
6936 let updated = git::rev_parse(&state.repo, "HEAD").await.unwrap();
6937 git::git(
6938 &state.repo,
6939 &["push", "origin", "HEAD:refs/heads/task-head"],
6940 )
6941 .await
6942 .unwrap();
6943 state.config.verify.gate = vec!["exit 1".into()];
6944 state.config.graph.gate_fix_rounds = 0;
6945 assert!(resume_observed_head(&mut state, &updated).await.is_err());
6946 assert_ne!(old, updated);
6947 assert_eq!(state.gate_head.as_ref(), Some(&updated));
6948 assert!(!state.gate_allows_progress());
6949 assert!(state.land_sync_pending);
6950 state.config.verify.gate = vec!["grep -q 'operator update' change".into()];
6951 let head = resume_observed_head(&mut state, &updated).await.unwrap();
6952 assert_eq!(head, updated);
6953 assert!(state.gate_allows_progress());
6954 assert!(!state.land_sync_pending);
6955 let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
6956 land_with(&mut state, &green_pr().url, &forge)
6957 .await
6958 .unwrap();
6959 assert!(state.parked);
6960 assert_eq!(state.land_approval.as_ref().unwrap().head, head);
6961 assert!(forge.argvs().is_empty());
6962 }
6963
6964 #[tokio::test]
6965 async fn landing_resume_refuses_remote_only_and_divergent_operator_updates() {
6966 for divergent in [false, true] {
6967 let (_tmp, mut state, old) = landing_git_fixture().await;
6968 if divergent {
6969 git::git(&state.repo, &["reset", "--hard", "origin/main"])
6970 .await
6971 .unwrap();
6972 }
6973 std::fs::write(state.repo.join("external"), "external").unwrap();
6974 git::commit_all(&state.repo, "external update")
6975 .await
6976 .unwrap();
6977 let external = git::rev_parse(&state.repo, "HEAD").await.unwrap();
6978 git::git(
6979 &state.repo,
6980 &["push", "--force", "origin", "HEAD:refs/heads/task-head"],
6981 )
6982 .await
6983 .unwrap();
6984 if !divergent {
6985 git::git(&state.repo, &["reset", "--hard", &old])
6986 .await
6987 .unwrap();
6988 }
6989 let error = resume_observed_head(&mut state, &external)
6990 .await
6991 .unwrap_err();
6992 assert!(
6993 error
6994 .to_string()
6995 .contains("public head moved outside this run")
6996 );
6997 assert_eq!(state.pr_pushed_head.as_ref(), Some(&old));
6998 assert_eq!(
6999 git::remote_head(&state.repo, "origin", "task-head")
7000 .await
7001 .unwrap(),
7002 Some(external)
7003 );
7004 }
7005 }
7006
7007 #[tokio::test]
7008 async fn landing_resume_repairs_a_failed_rebased_gate_before_publication() {
7009 let (_tmp, mut state, old) = landing_git_fixture().await;
7010 advance_landing_base(&state).await;
7011 landing_gate_fixer(
7012 &mut state,
7013 "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7014 );
7015 state.config.verify.gate =
7016 vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7017 state.config.verify.e2e = vec!["test -f advance".into()];
7018 state.config.graph.gate_fix_rounds = 0;
7021 assert!(resume_observed_head(&mut state, &old).await.is_err());
7022 assert_eq!(
7023 git::remote_head(&state.repo, "origin", "task-head")
7024 .await
7025 .unwrap(),
7026 Some(old.clone())
7027 );
7028 state.config.graph.gate_fix_rounds = 1;
7029 let head = resume_observed_head(&mut state, &old).await.unwrap();
7030 assert_eq!(state.gate_fixes.len(), 1);
7031 assert!(state.gate_fixes[0].committed);
7032 assert!(state.gate_allows_progress());
7033 assert_eq!(state.gate_head.as_ref(), Some(&head));
7034 assert_eq!(
7035 git::remote_head(&state.repo, "origin", "task-head")
7036 .await
7037 .unwrap(),
7038 Some(head.clone())
7039 );
7040 let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7041 land_with(&mut state, &green_pr().url, &forge)
7042 .await
7043 .unwrap();
7044 assert!(state.parked);
7045 assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7046 assert!(forge.argvs().is_empty());
7047 }
7048
7049 #[tokio::test]
7050 async fn landing_gate_repairs_stop_at_the_saved_budget_without_publishing() {
7051 let (_tmp, mut state, old) = landing_git_fixture().await;
7052 advance_landing_base(&state).await;
7053 landing_gate_fixer(
7054 &mut state,
7055 "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7056 );
7057 state.config.verify.gate = vec!["echo still red; exit 1".into()];
7058 for _ in 0..2 {
7059 assert!(resume_observed_head(&mut state, &old).await.is_err());
7060 assert_eq!(state.gate_fixes.len(), 1);
7061 assert_eq!(
7062 git::remote_head(&state.repo, "origin", "task-head")
7063 .await
7064 .unwrap(),
7065 Some(old.clone())
7066 );
7067 }
7068 }
7069
7070 #[tokio::test]
7071 async fn landing_gate_repair_cannot_publish_failed_e2e_on_repeated_resumes() {
7072 let (_tmp, mut state, old) = landing_git_fixture().await;
7073 advance_landing_base(&state).await;
7074 landing_gate_fixer(
7075 &mut state,
7076 "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7077 );
7078 state.config.verify.gate =
7079 vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7080 state.config.verify.e2e = vec!["grep -q '^change$' change".into()];
7082 for resume in 0..3 {
7083 assert!(resume_observed_head(&mut state, &old).await.is_err());
7084 state = RunState::load(&state.id).unwrap();
7085 let evidence = state.gate_repair_e2e.as_ref().unwrap();
7086 let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7087 assert_eq!(evidence.head, head);
7088 assert!(!evidence.passed);
7089 assert!(evidence.outcomes.iter().any(|o| !o.ok()));
7090 assert_eq!(state.gate_fixes.len(), 1);
7091 assert_eq!(
7092 git::remote_head(&state.repo, "origin", "task-head")
7093 .await
7094 .unwrap(),
7095 Some(old.clone())
7096 );
7097 if resume > 0 {
7098 assert!(
7099 state.gate_allows_progress(),
7100 "a green gate must not bypass red e2e"
7101 );
7102 }
7103 }
7104 state.config.verify.e2e = vec!["grep -q fixed change".into()];
7107 let head = resume_observed_head(&mut state, &old).await.unwrap();
7108 assert!(state.gate_repair_e2e.as_ref().unwrap().passed);
7109 assert_eq!(state.gate_repair_e2e.as_ref().unwrap().head, head);
7110 assert_eq!(state.gate_fixes.len(), 1);
7111 let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7112 land_with(&mut state, &green_pr().url, &forge)
7113 .await
7114 .unwrap();
7115 assert!(state.parked);
7116 assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7117 assert!(forge.argvs().is_empty());
7118 }
7119
7120 #[tokio::test]
7121 async fn landing_resumes_recheck_pending_deferred_and_stale_repair_e2e() {
7122 for phase in 0..3 {
7123 let (_tmp, mut state, old) = landing_git_fixture().await;
7124 advance_landing_base(&state).await;
7125 let head = resume_observed_head(&mut state, &old).await.unwrap();
7126 state.land_sync_pending = true;
7129 state.config.verify.e2e = vec!["echo e2e is red; exit 1".into()];
7130 state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7131 head: if phase == 2 {
7132 old.clone()
7133 } else {
7134 head.clone()
7135 },
7136 passed: phase == 2,
7137 outcomes: if phase == 1 {
7138 vec![crate::run::CommandOutcome {
7139 command: state.config.verify.e2e[0].clone(),
7140 code: None,
7141 output_tail: "verification resource unavailable".into(),
7142 duration_ms: 0,
7143 resource_blocked: true,
7144 }]
7145 } else {
7146 Vec::new()
7147 },
7148 });
7149 state.save().unwrap();
7150 for _ in 0..2 {
7151 state = RunState::load(&state.id).unwrap();
7152 assert!(resume_observed_head(&mut state, &head).await.is_err());
7153 let evidence = state.gate_repair_e2e.as_ref().unwrap();
7154 assert_eq!(evidence.head, head);
7155 assert!(!evidence.passed);
7156 assert!(!evidence.outcomes.is_empty());
7157 assert_eq!(
7158 git::remote_head(&state.repo, "origin", "task-head")
7159 .await
7160 .unwrap(),
7161 Some(head.clone())
7162 );
7163 assert!(state.land_sync_pending);
7164 }
7165 }
7166 }
7167
7168 async fn interrupt_landing_rebase(state: &mut RunState) {
7169 state.land_rebase_from = Some(git::rev_parse(&state.repo, "HEAD").await.unwrap());
7170 state.save().unwrap();
7171 let scratch = state.dir().join("rebase");
7172 assert_eq!(
7173 crate::rebase::rebase_with_fixer(state, &scratch, "winner", "origin/main")
7174 .await
7175 .unwrap(),
7176 crate::rebase::Rebased::Applied
7177 );
7178 assert!(!git::is_clean(&state.repo).await.unwrap());
7179 }
7180
7181 async fn landing_setup(state: &mut RunState) {
7182 git::git(
7183 &state.repo,
7184 &["config", "extensions.worktreeConfig", "true"],
7185 )
7186 .await
7187 .unwrap();
7188 state.config.worktree.setup =
7189 vec![toml::from_str("run = \"printf secret > .env\"").unwrap()];
7190 crate::worktree_setup::prepare(&state.config, &state.repo, &state.repo)
7191 .await
7192 .unwrap();
7193 state.config.verify.gate = vec!["test -f advance && test -f .env".into()];
7194 }
7195
7196 #[tokio::test]
7197 async fn landing_resume_restores_setup_after_both_fresh_and_interrupted_rebases() {
7198 for phase in 0..3 {
7199 let (_tmp, mut state, old) = landing_git_fixture().await;
7200 advance_landing_base(&state).await;
7201 landing_setup(&mut state).await;
7202 if phase > 0 {
7203 interrupt_landing_rebase(&mut state).await;
7204 if phase == 2 {
7205 git::sync_to_head(&state.repo).await.unwrap();
7207 assert!(!state.repo.join(".env").exists());
7208 }
7209 state = RunState::load(&state.id).unwrap();
7210 }
7211 let head = resume_observed_head(&mut state, &old).await.unwrap();
7212 assert_ne!(head, old);
7213 assert!(state.gate_allows_progress());
7214 assert!(state.land_rebase_from.is_none());
7215 assert_eq!(
7216 std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7217 "secret"
7218 );
7219 assert!(git::is_clean(&state.repo).await.unwrap());
7220 assert!(
7221 !git::git(&state.repo, &["ls-tree", "--name-only", "HEAD"])
7222 .await
7223 .unwrap()
7224 .contains(".env")
7225 );
7226 }
7227 }
7228
7229 #[tokio::test]
7230 async fn landing_restoration_protects_ignored_files_outside_setup_products() {
7231 let (_tmp, mut state, _) = landing_git_fixture().await;
7232 advance_landing_base(&state).await;
7233 landing_setup(&mut state).await;
7234 interrupt_landing_rebase(&mut state).await;
7235 let locals = [
7238 "local notes",
7239 #[cfg(unix)]
7240 "local\nnotes",
7241 ];
7242 let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7243 .await
7244 .unwrap();
7245 std::fs::write(state.repo.join(excludes), "local*\n").unwrap();
7246 for local in locals {
7247 std::fs::write(state.repo.join(local), "keep these notes").unwrap();
7248 assert!(
7249 git::git_raw(&state.repo, &["check-ignore", "--quiet", local])
7250 .await
7251 .unwrap()
7252 .ok()
7253 );
7254 }
7255 restore_landing_worktree(&mut state).await.unwrap();
7256 for local in locals {
7257 assert_eq!(
7258 std::fs::read_to_string(state.repo.join(local)).unwrap(),
7259 "keep these notes"
7260 );
7261 }
7262 assert_eq!(
7263 std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7264 "secret"
7265 );
7266 assert!(git::is_clean(&state.repo).await.unwrap());
7267 assert!(state.land_rebase_from.is_none());
7268 }
7269
7270 #[tokio::test]
7271 async fn landing_restoration_rejects_ignored_paths_conflicting_with_the_new_tree() {
7272 for local in ["advance", "advance/notes", "new-directory"] {
7273 let (_tmp, mut state, _) = landing_git_fixture().await;
7274 advance_landing_base(&state).await;
7275 git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7277 std::fs::create_dir(state.repo.join("new-directory")).unwrap();
7278 std::fs::write(state.repo.join("new-directory/tracked"), "new").unwrap();
7279 git::git(&state.repo, &["add", "."]).await.unwrap();
7280 git::git(&state.repo, &["commit", "-m", "add directory"])
7281 .await
7282 .unwrap();
7283 git::git(&state.repo, &["push", "origin", "main"])
7284 .await
7285 .unwrap();
7286 git::git(&state.repo, &["checkout", "winner"])
7287 .await
7288 .unwrap();
7289 let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7290 .await
7291 .unwrap();
7292 std::fs::write(state.repo.join(excludes), "advance\nnew-directory\n").unwrap();
7293 let path = state.repo.join(local);
7294 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
7295 std::fs::write(&path, "local notes").unwrap();
7296 interrupt_landing_rebase(&mut state).await;
7297 let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7298 let checkpoint = state.land_rebase_from.clone();
7299 let error = restore_landing_worktree(&mut state).await.unwrap_err();
7300 assert!(
7301 error.to_string().contains("conflicting with HEAD"),
7302 "{error:#}"
7303 );
7304 assert_eq!(std::fs::read_to_string(&path).unwrap(), "local notes");
7305 assert_eq!(
7306 git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7307 before
7308 );
7309 assert_eq!(state.land_rebase_from, checkpoint);
7310 }
7311 }
7312
7313 #[tokio::test]
7314 async fn landing_resume_preserves_ignored_gate_artifacts_without_setup() {
7315 for interrupted in [false, true] {
7316 let (_tmp, mut state, old) = landing_git_fixture().await;
7317 assert!(state.config.worktree.setup.is_empty());
7318 let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7320 .await
7321 .unwrap();
7322 std::fs::write(state.repo.join(excludes), "target/\n").unwrap();
7323 state.config.verify.gate =
7324 vec!["mkdir -p target && printf artifact > target/probe".into()];
7325 state.gate_ran = false;
7326 resume_observed_head(&mut state, &old).await.unwrap();
7327 assert_eq!(
7328 std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7329 "artifact"
7330 );
7331 advance_landing_base(&state).await;
7332 if interrupted {
7333 interrupt_landing_rebase(&mut state).await;
7334 state = RunState::load(&state.id).unwrap();
7335 }
7336 state.config.verify.gate =
7337 vec!["test -f advance && test \"$(cat target/probe)\" = artifact".into()];
7338 let head = resume_observed_head(&mut state, &old).await.unwrap();
7339 assert_ne!(head, old);
7340 assert!(state.gate_allows_progress());
7341 assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7342 assert_eq!(
7343 std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7344 "artifact"
7345 );
7346 assert!(state.land_rebase_from.is_none());
7347 assert_eq!(
7348 git::remote_head(&state.repo, "origin", "task-head")
7349 .await
7350 .unwrap(),
7351 Some(head.clone())
7352 );
7353 let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7354 land_with(&mut state, &green_pr().url, &forge)
7355 .await
7356 .unwrap();
7357 assert!(state.parked);
7358 assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7359 assert!(forge.argvs().is_empty());
7360 }
7361 }
7362
7363 #[tokio::test]
7364 #[cfg(unix)]
7365 async fn landing_restoration_waits_for_external_coordination_before_changing_files() {
7366 use std::os::fd::AsRawFd as _;
7367
7368 for interrupted in [false, true] {
7369 let (_tmp, mut state, _) = landing_git_fixture().await;
7370 advance_landing_base(&state).await;
7371 landing_setup(&mut state).await;
7372 if interrupted {
7373 interrupt_landing_rebase(&mut state).await;
7374 }
7375 state.config.worktree.setup =
7376 vec![toml::from_str("run = \"printf restored > .env\"").unwrap()];
7377 let common = crate::repo_lock::common_dir(&state.repo)
7378 .await
7379 .unwrap()
7380 .unwrap();
7381 std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7382 state.config.coordination = crate::config::test_coordination();
7383 state.config.graph.timeout_resource_wait = 0;
7384 let external = std::fs::OpenOptions::new()
7385 .read(true)
7386 .write(true)
7387 .create(true)
7388 .truncate(false)
7389 .open(common.join("updater.lock"))
7390 .unwrap();
7391 let locked =
7393 unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
7394 assert_eq!(locked, 0);
7395 let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7396 let checkpoint = state.land_rebase_from.clone();
7397 let error = restore_landing_worktree(&mut state).await.unwrap_err();
7398 assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7399 assert_eq!(
7400 git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7401 before
7402 );
7403 assert_eq!(state.land_rebase_from, checkpoint);
7404 assert_eq!(
7405 std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7406 "secret"
7407 );
7408
7409 drop(external);
7410 state.config.graph.timeout_resource_wait = 2;
7411 restore_landing_worktree(&mut state).await.unwrap();
7412 assert!(git::is_clean(&state.repo).await.unwrap());
7413 assert!(state.land_rebase_from.is_none());
7414 assert_eq!(
7415 std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7416 "restored"
7417 );
7418 }
7419 }
7420
7421 #[tokio::test]
7422 #[cfg(unix)]
7423 async fn landing_restoration_inherits_cancellation_while_waiting_for_a_local_seat() {
7424 use std::sync::atomic::{AtomicBool, Ordering};
7425
7426 let (_tmp, mut state, _) = landing_git_fixture().await;
7427 advance_landing_base(&state).await;
7428 landing_setup(&mut state).await;
7429 interrupt_landing_rebase(&mut state).await;
7430 let common = crate::repo_lock::common_dir(&state.repo)
7431 .await
7432 .unwrap()
7433 .unwrap();
7434 std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7435 state.config.coordination = crate::config::test_coordination();
7436 let holder = crate::repo_lock::SeatLease::for_checkout(
7437 &state.repo,
7438 true,
7439 state.config.coordination.as_ref(),
7440 )
7441 .await
7442 .unwrap();
7443 assert!(holder.is_some());
7444 let cancelled = Arc::new(AtomicBool::new(false));
7445 let signal = cancelled.clone();
7446 let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7447 signal.load(Ordering::SeqCst)
7448 });
7449 let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7450 let checkpoint = state.land_rebase_from.clone();
7451 let (result, ()) = tokio::join!(
7452 tokio::time::timeout(
7453 Duration::from_secs(2),
7454 wait.scope(restore_landing_worktree(&mut state)),
7455 ),
7456 async {
7457 tokio::time::sleep(Duration::from_millis(100)).await;
7458 cancelled.store(true, Ordering::SeqCst);
7459 }
7460 );
7461 let error = result
7462 .expect("cancellation must release the waiting driver")
7463 .unwrap_err();
7464 assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7465 assert_eq!(
7466 git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7467 before
7468 );
7469 assert_eq!(state.land_rebase_from, checkpoint);
7470 assert_eq!(
7471 std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7472 "secret"
7473 );
7474 drop(holder);
7475 restore_landing_worktree(&mut state).await.unwrap();
7476 assert!(state.land_rebase_from.is_none());
7477 assert!(git::is_clean(&state.repo).await.unwrap());
7478 }
7479
7480 #[tokio::test]
7481 #[cfg(unix)]
7482 async fn landing_verification_inherits_cancellation_while_waiting_for_a_local_seat() {
7483 use std::sync::atomic::{AtomicBool, Ordering};
7484
7485 for e2e in [false, true] {
7486 let (_tmp, mut state, head) = landing_git_fixture().await;
7487 let common = crate::repo_lock::common_dir(&state.repo)
7488 .await
7489 .unwrap()
7490 .unwrap();
7491 std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7492 state.config.coordination = crate::config::test_coordination();
7493 if e2e {
7494 state.config.verify.e2e = vec!["true".into()];
7495 state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7496 head: head.clone(),
7497 passed: false,
7498 outcomes: Vec::new(),
7499 });
7500 } else {
7501 state.config.verify.gate = vec!["true".into()];
7502 state.gate_ran = false;
7503 }
7504 let holder = crate::repo_lock::SeatLease::for_checkout(
7505 &state.repo,
7506 true,
7507 state.config.coordination.as_ref(),
7508 )
7509 .await
7510 .unwrap();
7511 assert!(holder.is_some());
7512 let cancelled = Arc::new(AtomicBool::new(false));
7513 let signal = cancelled.clone();
7514 let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7515 signal.load(Ordering::SeqCst)
7516 });
7517 let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7518 let url = green_pr().url;
7519 let (result, ()) = tokio::join!(
7520 tokio::time::timeout(
7521 Duration::from_secs(2),
7522 wait.scope(publish_landing_head(&mut state, &head, &url, &forge)),
7523 ),
7524 async {
7525 tokio::time::sleep(Duration::from_millis(100)).await;
7526 cancelled.store(true, Ordering::SeqCst);
7527 }
7528 );
7529 let error = result
7530 .expect("cancellation must release the verification waiter")
7531 .unwrap_err();
7532 assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7533 assert!(forge.argvs().is_empty());
7534 assert_eq!(
7535 git::remote_head(&state.repo, "origin", "task-head")
7536 .await
7537 .unwrap(),
7538 Some(head.clone())
7539 );
7540 drop(holder);
7541 assert_eq!(
7543 publish_landing_head(&mut state, &head, &url, &forge)
7544 .await
7545 .unwrap(),
7546 head
7547 );
7548 }
7549 }
7550
7551 #[tokio::test]
7552 async fn landing_resume_preserves_user_edits_after_an_interrupted_rebase() {
7553 for staged in [false, true] {
7554 let (_tmp, mut state, old) = landing_git_fixture().await;
7555 advance_landing_base(&state).await;
7556 interrupt_landing_rebase(&mut state).await;
7557 std::fs::write(state.repo.join("change"), "user edit").unwrap();
7558 if staged {
7559 git::git(&state.repo, &["add", "change"]).await.unwrap();
7560 }
7561 assert!(resume_observed_head(&mut state, &old).await.is_err());
7562 assert_eq!(
7563 std::fs::read_to_string(state.repo.join("change")).unwrap(),
7564 "user edit"
7565 );
7566 assert_eq!(
7567 git::remote_head(&state.repo, "origin", "task-head")
7568 .await
7569 .unwrap(),
7570 Some(old)
7571 );
7572 }
7573 }
7574
7575 #[tokio::test]
7576 async fn landing_resume_returns_to_approval_wait_and_closed_pr_skips_sync() {
7577 let (_tmp, mut state, head) = landing_git_fixture().await;
7578 let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7579 let id = state.id.clone();
7580 let pr = land_with(&mut state, &green_pr().url, &forge)
7581 .await
7582 .unwrap();
7583 assert_eq!(pr.state, PrLifecycle::Open);
7584 assert_eq!(state.id, id);
7585 assert!(state.parked);
7586 assert_eq!(state.status, RunStatus::Landing);
7587 assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7588 assert!(forge.argvs().is_empty());
7589
7590 state.land_sync_pending = true;
7591 std::fs::write(state.repo.join("dirty"), "do not touch").unwrap();
7592 let mut closed = seen("foreign", Checks::Green, "CLEAN", false);
7593 closed.pr.state = PrLifecycle::Closed;
7594 let forge = Scripted::new(vec![closed], vec![]);
7595 assert_eq!(
7596 land_with(&mut state, &green_pr().url, &forge)
7597 .await
7598 .unwrap()
7599 .state,
7600 PrLifecycle::Closed
7601 );
7602 assert_eq!(
7603 std::fs::read_to_string(state.repo.join("dirty")).unwrap(),
7604 "do not touch"
7605 );
7606 assert!(forge.argvs().is_empty());
7607 }
7608
7609 #[tokio::test]
7610 async fn landing_resume_does_not_publish_a_failed_rebased_gate() {
7611 let (_tmp, mut state, old) = landing_git_fixture().await;
7612 advance_landing_base(&state).await;
7613 state.config.verify.gate = vec!["exit 1".into()];
7614 assert!(resume_observed_head(&mut state, &old).await.is_err());
7615 assert_ne!(state.gate_head.as_deref(), Some(old.as_str()));
7616 assert!(!state.gate_allows_progress());
7617 assert_eq!(
7618 git::remote_head(&state.repo, "origin", "task-head")
7619 .await
7620 .unwrap(),
7621 Some(old)
7622 );
7623 assert!(state.land_sync_pending);
7624 }
7625
7626 #[tokio::test]
7627 async fn landing_publication_recovers_a_push_before_save_and_refuses_a_lease_conflict() {
7628 let (_tmp, mut state, old) = landing_git_fixture().await;
7629 advance_landing_base(&state).await;
7630 let pushed = resume_observed_head(&mut state, &old).await.unwrap();
7631 state.pr_pushed_head = Some(old.clone());
7633 state.land_sync_pending = true;
7634 assert_eq!(
7635 resume_observed_head(&mut state, &pushed).await.unwrap(),
7636 pushed
7637 );
7638 git::git(
7641 &state.repo,
7642 &["push", "--force", "origin", "main:refs/heads/task-head"],
7643 )
7644 .await
7645 .unwrap();
7646 assert!(
7647 publish_landing_head(
7648 &mut state,
7649 &pushed,
7650 &green_pr().url,
7651 &Scripted::new(vec![seen(&pushed, Checks::Green, "CLEAN", false)], vec![])
7652 )
7653 .await
7654 .unwrap_err()
7655 .to_string()
7656 .contains("publishing gated")
7657 );
7658 assert!(
7659 resume_observed_head(&mut state, "foreign-head")
7660 .await
7661 .is_err()
7662 );
7663 }
7664 #[tokio::test]
7665 async fn landing_publication_refuses_a_pr_closed_during_gate() {
7666 let (_tmp, mut state, head) = landing_git_fixture().await;
7667 let mut closed = seen(&head, Checks::Green, "CLEAN", false);
7668 closed.pr.state = PrLifecycle::Closed;
7669 let forge = Scripted::new(vec![closed], vec![]);
7670 assert!(
7671 publish_landing_head(&mut state, &head, &green_pr().url, &forge)
7672 .await
7673 .unwrap_err()
7674 .to_string()
7675 .contains("PR closed or merged")
7676 );
7677 }
7678
7679 fn winning_candidate(summary: &str) -> Candidate {
7681 Candidate {
7682 index: 0,
7683 label: 'A',
7684 agent: "opus".to_owned(),
7685 branch: "magi/x/A".to_owned(),
7686 worktree: PathBuf::from("/wt/A"),
7687 summary: summary.to_owned(),
7688 stat: String::new(),
7689 files: 1,
7690 commits: 1,
7691 empty: false,
7692 failed: None,
7693 verified_noop: None,
7694 duration_ms: 0,
7695 folded: false,
7696 }
7697 }
7698
7699 fn uncontested_tally() -> Tally {
7700 Tally {
7701 first_choice: BTreeMap::from([('A', 1)]),
7702 borda: BTreeMap::new(),
7703 winner: 'A',
7704 rankings: 1,
7705 unanimous_initial: true,
7706 deliberated: false,
7707 changed_votes: 0,
7708 unanimous_final: true,
7709 tie_break: None,
7710 judges: 1,
7711 present: 1,
7712 quorum: 1,
7713 met_quorum: true,
7714 uncontested: None,
7715 }
7716 }
7717
7718 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
7719 ReviewRecord {
7720 attempts: 0,
7721 reviewer,
7722 agent: agent.to_owned(),
7723 summary: summary.to_owned(),
7724 findings: Vec::new(),
7725 vote: None,
7726 failed: None,
7727 duration_ms: 0,
7728 }
7729 }
7730
7731 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
7732 let answered = reviews.len();
7733 ReviewRound {
7734 round,
7735 head: "abc1234".to_owned(),
7736 verified_head: None,
7737 verified_at: None,
7738 reviews,
7739 e2e: Vec::new(),
7740 verify_retried: false,
7741 e2e_deferred: false,
7742 e2e_defer_reason: None,
7743 fix: None,
7744 blocking: 0,
7745 answered,
7746 expected: answered,
7747 clean: true,
7748 progressed: false,
7749 vote_split: false,
7750 reconsideration: Vec::new(),
7751 verdict: None,
7752 }
7753 }
7754
7755 #[test]
7756 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
7757 let en = panel();
7758 assert!(en.contains("Task"), "{en}");
7759 assert!(en.contains("add retries to the uploader"), "{en}");
7760
7761 let mut state = run_state();
7762 state.config.graph.language = "ja".to_owned();
7763 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7764 assert!(ja.contains("タスク"), "{ja}");
7765 assert!(
7766 ja.contains("add retries to the uploader"),
7767 "the task itself is not translated: {ja}"
7768 );
7769 }
7770
7771 #[test]
7772 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
7773 let html = panel();
7777 assert!(!html.contains("What changed"), "{html}");
7778 assert!(!html.contains("Review verdict"), "{html}");
7779 }
7780
7781 #[test]
7782 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
7783 let mut state = run_state();
7784 state.candidates = vec![winning_candidate("")];
7785 state.tally = Some(uncontested_tally());
7786 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7787 assert!(
7788 !html.contains("What changed"),
7789 "an empty summary must not render an empty box: {html}"
7790 );
7791 }
7792
7793 #[test]
7794 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
7795 let mut state = run_state();
7796 state.candidates = vec![winning_candidate(
7797 "Added a retry loop around the uploader PUT call.",
7798 )];
7799 state.tally = Some(uncontested_tally());
7800 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7801 assert!(en.contains("What changed"), "{en}");
7802 assert!(
7803 en.contains("Added a retry loop around the uploader PUT call."),
7804 "{en}"
7805 );
7806
7807 state.config.graph.language = "ja".to_owned();
7808 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7809 assert!(ja.contains("変更内容"), "{ja}");
7810 assert!(
7811 ja.contains("Added a retry loop around the uploader PUT call."),
7812 "{ja}"
7813 );
7814 }
7815
7816 #[test]
7817 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
7818 let mut state = run_state();
7819 state.reviews = vec![
7820 review_round(
7821 1,
7822 vec![review_record(1, "alpha", "found a race, sent back")],
7823 ),
7824 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
7825 ];
7826 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7827 assert!(en.contains("Review verdict"), "{en}");
7828 assert!(en.contains("race is fixed, clean"), "{en}");
7829 assert!(
7830 !en.contains("found a race, sent back"),
7831 "only the round that actually cleared the merge should show: {en}"
7832 );
7833
7834 state.config.graph.language = "ja".to_owned();
7835 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7836 assert!(ja.contains("レビューの結論"), "{ja}");
7837 assert!(ja.contains("レビュアー"), "{ja}");
7838 assert!(ja.contains("race is fixed, clean"), "{ja}");
7839 }
7840
7841 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
7847 ReviewRecord {
7848 attempts: 0,
7849 reviewer,
7850 agent: agent.to_owned(),
7851 summary: String::new(),
7852 findings: Vec::new(),
7853 vote: None,
7854 failed: Some(reason.to_owned()),
7855 duration_ms: 0,
7856 }
7857 }
7858
7859 #[test]
7860 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
7861 let mut state = run_state();
7862 state.reviews = vec![review_round(
7863 1,
7864 vec![
7865 review_record(1, "alpha", "clean, nothing to add"),
7866 unanswered_review_record(2, "beta", "timed out"),
7867 ],
7868 )];
7869 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7870 assert!(en.contains("clean, nothing to add"), "{en}");
7871 assert!(
7872 en.contains("produced no answer: timed out"),
7873 "a seat that never answered must say so, not render a blank box: {en}"
7874 );
7875 assert!(
7876 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
7877 "no reviewer box may be left empty: {en}"
7878 );
7879
7880 state.config.graph.language = "ja".to_owned();
7881 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7882 assert!(ja.contains("回答なし: timed out"), "{ja}");
7883 }
7884
7885 #[test]
7886 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
7887 let html = panel();
7888 assert!(!html.contains("<script"), "no script survives the csp");
7889 assert!(!html.contains("<form"), "form-action is 'none'");
7890 let pr = green_pr();
7891 assert_eq!(
7892 html.matches("http").count(),
7893 html.matches(pr.url.as_str()).count(),
7894 "the only http url in the panel is the pull request's own link"
7895 );
7896 assert!(
7897 html.contains("target=\"_blank\""),
7898 "the PR link must open in a new tab so it escapes the iframe"
7899 );
7900 assert!(
7901 html.contains("rel=\"noopener noreferrer\""),
7902 "external links opening in new tabs must carry noopener noreferrer"
7903 );
7904 }
7905
7906 #[test]
7907 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
7908 let html = panel();
7909 assert!(
7910 html.contains(">+</span>"),
7911 "an added line carries a `+` in the gutter, not only a background"
7912 );
7913 assert!(
7914 html.contains(">-</span>"),
7915 "a removed line carries a `-` in the gutter, not only a background"
7916 );
7917 assert!(
7918 html.contains(">new line</span>"),
7919 "the marker is moved to the gutter, so the body is printed once without it"
7920 );
7921 }
7922
7923 #[test]
7924 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
7925 let total = DIFF_MAX_LINES + 100;
7926 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
7927 let html = approval_panel(
7928 &run_state(),
7929 &green_pr(),
7930 NUMSTAT,
7931 &diff,
7932 &[],
7933 "feat: something long",
7934 );
7935 assert!(
7936 html.contains(&format!("100 of {total} diff lines omitted")),
7937 "the note must say exactly how much was cut"
7938 );
7939 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
7940 assert!(
7941 !html.contains(&format!("line {DIFF_MAX_LINES}")),
7942 "nothing past the threshold is rendered"
7943 );
7944 assert!(
7945 html.contains("/repo/magi"),
7946 "the note says where the rest is"
7947 );
7948 }
7949
7950 #[test]
7951 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
7952 let html = approval_panel(
7953 &run_state(),
7954 &green_pr(),
7955 "1\t2\tsrc/<b>&\"x\"'.rs",
7956 "",
7957 &[],
7958 "subject",
7959 );
7960 assert!(html.contains("src/<b>&"x"'.rs"));
7961 assert!(
7962 !html.contains("<b>"),
7963 "an agent-influenced path must never become markup"
7964 );
7965 }
7966
7967 #[tokio::test]
7968 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
7969 let a = std::path::PathBuf::from("/repo/a");
7970 let b = std::path::PathBuf::from("/repo/b");
7971
7972 let held = repo_merge_lock(&a).lock_owned().await;
7973
7974 assert!(
7977 repo_merge_lock(&a).try_lock().is_err(),
7978 "a second merge into the same repository must not proceed concurrently"
7979 );
7980
7981 assert!(
7985 repo_merge_lock(&b).try_lock().is_ok(),
7986 "a different repository's merge lock must be independent"
7987 );
7988
7989 drop(held);
7990 assert!(
7991 repo_merge_lock(&a).try_lock().is_ok(),
7992 "the lock is released once the holder is done"
7993 );
7994 }
7995
7996 pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
7997 let mut state = landing_state();
7998 let mut rec = review_record(1, "alpha", "precise rejection reason");
7999 rec.vote = Some(if reject {
8000 crate::verdict::ReviewVote::Reject
8001 } else {
8002 crate::verdict::ReviewVote::Approve
8003 });
8004 if major {
8005 rec.findings = contested().findings;
8006 }
8007 let mut round = review_round(1, vec![rec]);
8008 round.clean = false;
8009 round.blocking = usize::from(major);
8010 state.reviews.push(round);
8011 state.config.graph.review_rounds = 1;
8012 state
8013 }
8014
8015 #[test]
8016 fn approval_policy_controls_are_independent_and_keep_all_reasons() {
8017 for every in [false, true] {
8018 for contested in [false, true] {
8019 for major in [false, true] {
8020 for reject in [false, true] {
8021 let mut state = repair_state(major, reject);
8022 state.config.graph.land_approval = every;
8023 state.config.graph.hold_contested_merge = contested;
8024 let reasons = approval_reasons(&state);
8025 assert_eq!(reasons.contains(&ApprovalReason::EveryMerge), every);
8026 assert_eq!(
8027 reasons.contains(&ApprovalReason::OpenBlockingFindings),
8028 contested && major
8029 );
8030 assert_eq!(
8031 reasons.contains(&ApprovalReason::FinalReject),
8032 contested && reject
8033 );
8034 assert_eq!(
8035 reasons.len(),
8036 usize::from(every)
8037 + usize::from(contested && major)
8038 + usize::from(contested && reject)
8039 );
8040 }
8041 }
8042 }
8043 }
8044 }
8045
8046 #[test]
8047 fn approval_policy_and_rendering_follow_reconsideration_in_both_directions() {
8048 use crate::verdict::ReviewVote::{Approve, Reject};
8049 for (initial_reject, final_vote) in [(true, Approve), (false, Reject)] {
8050 let mut state = repair_state(false, initial_reject);
8051 state.reviews[0]
8052 .reconsideration
8053 .push(crate::run::ReviewRevoteRecord {
8054 reviewer: 1,
8055 agent: "alpha".into(),
8056 vote: Some(final_vote),
8057 reason: "changed after reading the panel <evidence>".into(),
8058 failed: None,
8059 });
8060 assert_eq!(
8061 approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8062 final_vote == Reject
8063 );
8064 for language in ["en", "ja"] {
8065 state.config.graph.language = language.into();
8066 let evidence = approval_evidence(&state);
8067 assert!(evidence.contains(&format!("→ {}", final_vote.label())));
8068 assert!(evidence.contains("changed after reading the panel <evidence>"));
8069 let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8070 assert!(html.contains("changed after reading the panel <evidence>"));
8071 assert!(!html.contains("<evidence>"));
8072 }
8073 state.reviews[0].reconsideration[0].vote = None;
8074 state.reviews[0].reconsideration[0].failed = Some("timed out".into());
8075 assert_eq!(
8076 approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8077 initial_reject
8078 );
8079 assert!(approval_evidence(&state).contains("timed out"));
8080 }
8081 }
8082
8083 #[tokio::test]
8084 async fn approval_merge_paths_apply_controls_to_major_and_reject_independently() {
8085 for every in [false, true] {
8086 for contested in [false, true] {
8087 for (major, reject) in [(false, false), (false, true), (true, false), (true, true)]
8088 {
8089 let mut state = repair_state(major, reject);
8090 state.config.graph.land_approval = every;
8091 state.config.graph.hold_contested_merge = contested;
8092 let forge = Scripted::new(
8093 vec![seen("abc", Checks::Green, "CLEAN", false)],
8094 vec![(true, "")],
8095 );
8096 land_with(&mut state, URL, &forge).await.unwrap();
8097 let expected = every || (contested && (major || reject));
8098 assert_eq!(state.land_approval.is_some(), expected);
8099 assert_eq!(forge.calls().contains(&"merge"), !expected);
8100 if expected {
8101 assert!(state.parked);
8102 }
8103 }
8104 }
8105 }
8106 }
8107
8108 #[tokio::test]
8109 async fn reviewed_branch_hold_is_read_before_landing_decision() {
8110 for (base, control) in [
8111 (false, "land_approval"),
8112 (true, "land_approval"),
8113 (false, "hold_contested_merge"),
8114 ] {
8115 let (_tmp, mut state, _) = landing_git_fixture().await;
8116 state.config.graph.land_approval = base;
8117 state.config.graph.hold_contested_merge = false;
8118 state.reviewed_commits = Some(Vec::new());
8119 if control == "hold_contested_merge" {
8120 state.reviews = repair_state(false, true).reviews;
8121 }
8122 std::fs::write(
8123 state.repo.join("magi.toml"),
8124 format!("[graph]\n{control} = {}\n", !base),
8125 )
8126 .unwrap();
8127 git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8128 git::git(&state.repo, &["commit", "-m", "approval policy"])
8129 .await
8130 .unwrap();
8131 let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8132 state.land_sync_pending = false;
8133 state.pr_pushed_head = Some(head.clone());
8134 state.gate_head = Some(head.clone());
8135 let forge = Scripted::new(
8136 vec![seen(&head, Checks::Green, "CLEAN", false)],
8137 vec![(true, "")],
8138 );
8139 land_with(&mut state, URL, &forge).await.unwrap();
8140 assert!(
8141 state.land_approval.is_some(),
8142 "base={base}, control={control}"
8143 );
8144 assert!(!forge.calls().contains(&"merge"));
8145 if !base {
8146 let question = ask::Questions::open()
8147 .get(&state.land_approval.as_ref().unwrap().question)
8148 .unwrap();
8149 assert!(question.detail.contains(&format!(
8150 "{control}=true from the reviewed branch's magi.toml"
8151 )));
8152 }
8153 }
8154 }
8155
8156 #[tokio::test]
8157 async fn reviewed_branch_hold_asks_and_survives_removal_and_resume() {
8158 let (_tmp, mut state, _) = landing_git_fixture().await;
8159 state.config.graph.land_approval = false;
8160 state.config.graph.hold_contested_merge = false;
8161 state.reviewed_commits = Some(Vec::new());
8162 std::fs::write(
8163 state.repo.join("magi.toml"),
8164 "[graph]\nland_approval = true\n[verify]\ngate = ['untrusted']\n",
8165 )
8166 .unwrap();
8167 git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8168 git::git(&state.repo, &["commit", "-m", "require approval"])
8169 .await
8170 .unwrap();
8171 let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8172 let gate = state.config.verify.gate.clone();
8173 state.observe_branch_owner_holds(&head).await;
8174 assert_eq!(state.config.verify.gate, gate);
8175 assert_eq!(approval_reasons(&state), [ApprovalReason::EveryMerge]);
8176 git::git(&state.repo, &["rm", "magi.toml"]).await.unwrap();
8177 git::git(&state.repo, &["commit", "-m", "remove config"])
8178 .await
8179 .unwrap();
8180 let later = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8181 state = serde_json::from_str(&serde_json::to_string(&state).unwrap()).unwrap();
8182 state.observe_branch_owner_holds(&later).await;
8183 assert_eq!(state.branch_owner_holds[0].head, head);
8184 state.land_sync_pending = false;
8185 state.pr_pushed_head = Some(later.clone());
8186 state.gate_head = Some(later.clone());
8187 let forge = Scripted::new(
8188 vec![seen(&later, Checks::Green, "CLEAN", false)],
8189 vec![(true, "")],
8190 );
8191 land_with(&mut state, URL, &forge).await.unwrap();
8192 assert!(!forge.calls().contains(&"merge"));
8193 let question = ask::Questions::open()
8194 .get(&state.land_approval.as_ref().unwrap().question)
8195 .unwrap();
8196 assert!(question.detail.contains("Approval required because:"));
8197 assert!(
8198 question
8199 .detail
8200 .contains("land_approval=true from the reviewed branch's magi.toml")
8201 );
8202 }
8203
8204 #[tokio::test]
8205 async fn approval_snapshot_exposes_minor_findings_and_survives_disabled_controls() {
8206 let mut state = repair_state(false, true);
8207 state.reviews[0].clean = true;
8208 let mut finding = contested().findings.remove(0);
8209 finding.severity = crate::verdict::Severity::Minor;
8210 state.reviews[0].reviews[0].findings.push(finding);
8211 assert!(state.open_findings().is_empty());
8212 let mut pr = green_pr();
8213 pr.review_comments.clear();
8214 for language in ["en", "ja"] {
8215 state.config.graph.language = language.into();
8216 let html = approval_panel(&state, &pr, "", "", &[], "subject");
8217 assert!(html.contains("[R3-1-1] minor"));
8218 assert!(html.contains(if language == "ja" {
8219 "内部指摘の解決を意味するものではありません"
8220 } else {
8221 "This does not mean internal findings are resolved"
8222 }));
8223 assert!(
8224 html.find(if language == "ja" {
8225 "承認が必要な理由:"
8226 } else {
8227 "Approval required because:"
8228 })
8229 .unwrap()
8230 < html.find("[R3-1-1]").unwrap()
8231 );
8232 }
8233 state.config.graph.language = "en".into();
8234 let observed = seen("abc", Checks::Green, "CLEAN", false);
8235 assert_eq!(
8236 approval_gate(&mut state, &observed.pr, "subject", None, "abc")
8237 .await
8238 .unwrap(),
8239 ApprovalGate::Pending
8240 );
8241 let id = state.land_approval.as_ref().unwrap().question.clone();
8242 let q = ask::Questions::open().get(&id).unwrap();
8243 assert!(q.detail.starts_with("Approval required because:"));
8244 assert!(q.detail.contains("final reviewer reject"));
8245 assert!(q.detail.contains("[R3-1-1] minor"));
8246 assert!(
8247 q.detail
8248 .contains("No adoption or resolution evidence recorded")
8249 );
8250 state.config.graph.land_approval = false;
8251 state.config.graph.hold_contested_merge = false;
8252 state.reviews.clear();
8253 assert_eq!(approval_reasons(&state), [ApprovalReason::ExistingDecision]);
8254 assert!(deputy_brief(&q, Some(&state)).contains("final reviewer reject"));
8255 assert!(deputy_brief(&q, None).contains("[R3-1-1] minor"));
8256 let forge = Scripted::new(vec![observed], vec![]);
8257 land_with(&mut state, URL, &forge).await.unwrap();
8258 assert!(!forge.calls().contains(&"merge"));
8259 assert_eq!(state.land_approval.as_ref().unwrap().question, id);
8260 assert_eq!(ask::Questions::open().get(&id).unwrap().detail, q.detail);
8261 }
8262
8263 #[tokio::test]
8264 async fn approval_evidence_keeps_all_findings_and_bounds_unicode_prose() {
8265 let mut state = repair_state(false, true);
8266 state.config.graph.land_approval = false;
8267 let long_reason = format!("{}REASON_TAIL", "拒否🦀".repeat(200));
8268 let long_detail = format!("{}DETAIL_TAIL", "詳細🦀".repeat(200));
8269 let long_title = format!("{}TITLE_TAIL", "題🦀".repeat(100));
8270 state.reviews[0].reviews[0].summary = long_reason.clone();
8271 for i in 1..=10 {
8272 let mut finding = contested().findings.remove(0);
8273 finding.id = format!("R1-1-{i}");
8274 finding.severity = if i == 9 {
8275 crate::verdict::Severity::Major
8276 } else {
8277 crate::verdict::Severity::Minor
8278 };
8279 finding.title = long_title.clone();
8280 finding.detail = long_detail.clone();
8281 state.reviews[0].reviews[0].findings.push(finding);
8282 }
8283 state.reviews[0].fix = Some(crate::run::FixRecord {
8284 agent: "fixer".into(),
8285 addressed: vec!["R1-1-9".into()],
8286 rejected: vec![crate::verdict::Rejection {
8287 id: "R1-1-10".into(),
8288 why: long_reason.clone(),
8289 }],
8290 notes: long_detail.clone(),
8291 committed: false,
8292 failed: None,
8293 duration_ms: 0,
8294 continuation: None,
8295 });
8296 assert_eq!(
8297 approval_reasons(&state),
8298 [
8299 ApprovalReason::OpenBlockingFindings,
8300 ApprovalReason::FinalReject
8301 ]
8302 );
8303 let excerpt = approval_excerpt(&long_reason, 300);
8304 assert_eq!(excerpt.chars().count(), 300);
8305 assert!(excerpt.ends_with('…'));
8306 let boundary = "拒".repeat(300);
8307 assert_eq!(approval_excerpt(&boundary, 300), boundary);
8308 for language in ["en", "ja"] {
8309 state.config.graph.language = language.into();
8310 for revote in [
8311 None,
8312 Some(crate::verdict::ReviewVote::Reject),
8313 Some(crate::verdict::ReviewVote::Approve),
8314 ] {
8315 state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8316 state.reviews[0].reconsideration.clear();
8317 if let Some(vote) = revote {
8318 state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Approve);
8319 state.reviews[0]
8320 .reconsideration
8321 .push(crate::run::ReviewRevoteRecord {
8322 reviewer: 1,
8323 agent: "alpha".into(),
8324 vote: Some(vote),
8325 reason: long_reason.clone(),
8326 failed: None,
8327 });
8328 }
8329 let evidence = approval_evidence(&state);
8330 let final_vote = revote.unwrap_or(crate::verdict::ReviewVote::Reject);
8331 assert!(evidence.contains(&format!("#1 (alpha): {}", final_vote.label())));
8332 assert!(approval_reasons(&state).contains(&ApprovalReason::OpenBlockingFindings));
8333 assert_eq!(
8334 approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8335 final_vote == crate::verdict::ReviewVote::Reject
8336 );
8337 assert!(evidence.contains(&excerpt));
8338 assert!(evidence.contains("[R1-1-8] minor"));
8339 assert!(evidence.contains("[R1-1-9] major"));
8340 assert!(evidence.contains("[R1-1-10] minor"));
8341 for tail in ["REASON_TAIL", "DETAIL_TAIL", "TITLE_TAIL"] {
8342 assert!(!evidence.contains(tail));
8343 }
8344 assert!(evidence.contains(if language == "ja" {
8345 "採用申告(解決未確認)"
8346 } else {
8347 "claimed addressed (resolution unconfirmed)"
8348 }));
8349 assert!(evidence.contains(if language == "ja" {
8350 "不採用理由"
8351 } else {
8352 "declined"
8353 }));
8354 let major_evidence = evidence
8355 .split("[R1-1-9] major")
8356 .nth(1)
8357 .unwrap()
8358 .split("[R1-1-10]")
8359 .next()
8360 .unwrap();
8361 assert!(major_evidence.contains(if language == "ja" {
8362 "採用申告(解決未確認)"
8363 } else {
8364 "claimed addressed (resolution unconfirmed)"
8365 }));
8366 let last_evidence = evidence.split("[R1-1-10] minor").nth(1).unwrap();
8367 assert!(last_evidence.contains(if language == "ja" {
8368 "不採用理由"
8369 } else {
8370 "declined"
8371 }));
8372 let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8373 assert!(html.contains(&esc(&evidence)));
8374 assert_eq!(
8375 approval_gate(&mut state, &green_pr(), "subject", None, "abc")
8376 .await
8377 .unwrap(),
8378 ApprovalGate::Pending
8379 );
8380 let id = &state.land_approval.take().unwrap().question;
8381 let q = ask::Questions::open().get(id).unwrap();
8382 assert!(q.detail.starts_with(&evidence));
8383 }
8384 state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8385 }
8386 }
8387
8388 #[test]
8389 fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
8390 for (major, reject) in [(true, false), (false, true), (true, true)] {
8391 let state = repair_state(major, reject);
8392 assert!(repair_available(&state));
8393 assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
8394 }
8395 assert!(!repair_available(&repair_state(false, false)));
8396 let mut clean = repair_state(true, false);
8397 clean.reviews[0].clean = true;
8398 assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
8399 assert_eq!(approval(Some(REPAIR)), Approval::Repair);
8400 }
8401
8402 #[tokio::test]
8403 async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
8404 let mut state = repair_state(true, false);
8405 let pr = green_pr();
8406 approval_gate(&mut state, &pr, "feat: x", None, "abc")
8407 .await
8408 .unwrap();
8409 let store = ask::Questions::open();
8410 let id = state.land_approval.as_ref().unwrap().question.clone();
8411 store
8413 .update(&id, |q| {
8414 q.choices = vec![APPROVE.into(), HOLD.into()];
8415 Ok(())
8416 })
8417 .unwrap();
8418 let upgraded = store.get(&id).unwrap();
8419 assert_eq!(upgraded.choices, approval_choices(&state));
8420 assert!(!upgraded.actions.contains_key(HOLD));
8421 assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
8422 assert!(
8423 store
8424 .list()
8425 .iter()
8426 .any(|q| q.id == id && q.choices == upgraded.choices)
8427 );
8428 assert!(
8429 crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
8430 );
8431 assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
8432 store
8433 .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
8434 .unwrap();
8435 store
8438 .update(&id, |q| {
8439 q.actions.clear();
8440 Ok(())
8441 })
8442 .unwrap();
8443 assert_eq!(
8444 store.get(&id).unwrap().chosen_action(),
8445 Some(&ask::ChoiceAction::Resume {
8446 run: state.id.clone()
8447 })
8448 );
8449 let binding = state.land_approval.clone();
8450 assert_eq!(
8451 approval_gate(&mut state, &pr, "feat: x", None, "abc")
8452 .await
8453 .unwrap(),
8454 ApprovalGate::Repair
8455 );
8456 assert!(state.land_approval.is_none());
8457 assert_eq!(state.merge_repairs.len(), 1);
8458 let request = &state.merge_repairs[0];
8459 assert_eq!(request.question, id);
8460 assert_eq!(request.head, "abc");
8461 assert_eq!(request.pr, pr.url);
8462 assert_eq!(
8463 request.reviews[0].reviews[0].summary,
8464 "precise rejection reason"
8465 );
8466 assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
8467 assert_eq!(
8468 request.reviews[0].reviews[0].findings[0].detail,
8469 state.reviews[0].reviews[0].findings[0].detail
8470 );
8471 state.land_approval = binding;
8473 assert_eq!(
8474 approval_gate(&mut state, &pr, "feat: x", None, "abc")
8475 .await
8476 .unwrap(),
8477 ApprovalGate::Repair
8478 );
8479 assert_eq!(state.merge_repairs.len(), 1);
8480 state.land_approval = None;
8481 state.config.graph.land_approval = false;
8482 state.config.graph.hold_contested_merge = false;
8483 state.reviews.clear();
8484 assert_eq!(approval_reasons(&state), [ApprovalReason::AfterRepair]);
8485 assert_eq!(
8486 approval_gate(&mut state, &pr, "feat: x", None, "abc")
8487 .await
8488 .unwrap(),
8489 ApprovalGate::Pending
8490 );
8491 let fresh_id = &state.land_approval.as_ref().unwrap().question;
8492 assert_ne!(fresh_id, &id);
8493 let fresh = store.get(fresh_id).unwrap();
8494 assert!(
8495 fresh
8496 .detail
8497 .contains("fresh owner approval after merge repair")
8498 );
8499 assert!(fresh.detail.contains("No review recorded"));
8500 assert!(!fresh.detail.contains("precise rejection reason"));
8501 }
8502
8503 #[tokio::test]
8504 async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
8505 for choice in [REPAIR, HOLD] {
8506 let mut state = repair_state(false, true);
8507 let observed = seen("abc", Checks::Green, "CLEAN", false);
8508 approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
8509 .await
8510 .unwrap();
8511 let id = state.land_approval.as_ref().unwrap().question.clone();
8512 ask::Questions::open()
8513 .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
8514 .unwrap();
8515 let forge = Scripted::new(vec![observed], vec![(true, "")]);
8516 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8517 .await
8518 .unwrap();
8519 assert!(
8520 forge
8521 .argvs()
8522 .iter()
8523 .all(|a| a.iter().any(|s| s == "--disable-auto"))
8524 );
8525 assert!(!forge.calls().contains(&"fix"));
8526 assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
8527 assert_ne!(state.status, RunStatus::Merged);
8528 }
8529 }
8530
8531 #[test]
8532 fn only_the_merge_choice_merges_and_silence_holds() {
8533 let table = [
8534 (None, Approval::Hold),
8535 (Some("merge"), Approval::Merge),
8536 (Some(" merge\n"), Approval::Merge),
8537 (Some("hold"), Approval::Hold),
8538 (Some(""), Approval::Hold),
8539 (Some("yes"), Approval::Hold),
8540 ];
8541 for (answer, want) in table {
8542 assert_eq!(
8543 approval(answer),
8544 want,
8545 "answer {answer:?} must resolve to {want:?}"
8546 );
8547 }
8548 }
8549
8550 #[tokio::test]
8551 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
8552 let mut state = landing_state();
8553 state.config.graph.land_approval = true;
8554 let pr = green_pr();
8555
8556 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
8557 .await
8558 .unwrap();
8559 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
8560 assert!(
8561 !state.parked,
8562 "approval_gate itself never sets `parked`; only its caller does"
8563 );
8564
8565 let store = ask::Questions::open();
8566 let filed: Vec<_> = store
8567 .list()
8568 .into_iter()
8569 .filter(|q| q.run == state.id)
8570 .collect();
8571 assert_eq!(filed.len(), 1, "exactly one question is filed");
8572 assert_eq!(filed[0].node, APPROVAL_NODE);
8573 assert_eq!(filed[0].choices, approval_choices(&state));
8574 assert!(filed[0].status.open());
8575
8576 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
8580 .await
8581 .unwrap();
8582 assert_eq!(again, ApprovalGate::Pending);
8583 let still_one = store
8584 .list()
8585 .into_iter()
8586 .filter(|q| q.run == state.id)
8587 .count();
8588 assert_eq!(
8589 still_one, 1,
8590 "asking twice must not double-file the question"
8591 );
8592 }
8593
8594 #[tokio::test]
8595 async fn approving_the_existing_question_is_read_back_as_approved() {
8596 crate::run::pin_test_home();
8597 let mut state = run_state();
8598 state.config.graph.land_approval = true;
8599 let pr = green_pr();
8600 assert_eq!(
8601 approval_gate(&mut state, &pr, "feat: x", None, "abc")
8602 .await
8603 .unwrap(),
8604 ApprovalGate::Pending
8605 );
8606
8607 let store = ask::Questions::open();
8608 let mut q = store
8609 .list()
8610 .into_iter()
8611 .find(|q| q.run == state.id)
8612 .expect("filed above");
8613 let talks = crate::talk::Talks::open();
8614 let cfg = crate::config::Config {
8615 agents: vec![crate::config::AgentSpec {
8616 id: "mock".into(),
8617 kind: crate::config::AgentKind::Command,
8618 model: None,
8619 command: vec!["true".into()],
8620 extra_args: Vec::new(),
8621 env: Default::default(),
8622 prompt_delivery: None,
8623 max_turn_secs: None,
8624 }],
8625 ..crate::config::Config::default()
8626 };
8627 let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
8628 crate::consult::begin(&store, &talks, &q, &talk).unwrap();
8629 crate::talk::drain(&mut talk, &talks).unwrap();
8630 assert_eq!(
8631 approval_gate(&mut state, &pr, "feat: x", None, "abc")
8632 .await
8633 .unwrap(),
8634 ApprovalGate::Pending,
8635 "consultation cannot approve or hold the merge"
8636 );
8637 q = store.get(&q.id).unwrap();
8638 assert!(q.answer.is_none());
8639 talk.turns.push(crate::talk::Turn {
8640 sender: None,
8641 breaks: Some(Vec::new()),
8642 messages: Vec::new(),
8643 posted_by: None,
8644 who: crate::talk::Who::Operator,
8645 body: "merge abc".into(),
8646 at: Timestamp::now(),
8647 attachments: Vec::new(),
8648 usage: None,
8649 });
8650 talks.put(&mut talk).unwrap();
8651 crate::consult::validate_answer(
8652 &q,
8653 &talks,
8654 &talk.id,
8655 crate::queue::CHAT_NODE,
8656 APPROVE,
8657 Some("merge abc"),
8658 )
8659 .unwrap();
8660 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8661 store.put(&mut q).unwrap();
8662
8663 assert_eq!(
8664 approval_gate(&mut state, &pr, "feat: x", None, "abc")
8665 .await
8666 .unwrap(),
8667 ApprovalGate::Approved
8668 );
8669 }
8670
8671 #[tokio::test]
8672 async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
8673 crate::run::pin_test_home();
8674 let store = ask::Questions::open();
8675 for via_list in [false, true] {
8676 let mut state = repair_state(false, true);
8677 let pr = green_pr();
8678 approval_gate(&mut state, &pr, "feat: x", None, "abc")
8679 .await
8680 .unwrap();
8681 let id = state.land_approval.as_ref().unwrap().question.clone();
8682 store
8683 .update(&id, |q| {
8684 q.actions.insert(
8685 HOLD.to_owned(),
8686 ask::ChoiceAction::Resume {
8687 run: state.id.clone(),
8688 },
8689 );
8690 Ok(())
8691 })
8692 .unwrap();
8693 let q = if via_list {
8694 store.list().into_iter().find(|q| q.id == id).unwrap()
8695 } else {
8696 store.get(&id).unwrap()
8697 };
8698 assert!(!q.actions.contains_key(HOLD));
8699 store
8700 .update(&id, |q| {
8701 assert!(!q.actions.contains_key(HOLD));
8703 assert!(!upgrade_repair_choice(q, &state));
8704 q.answer(ask::Answer::Choice(HOLD.to_owned()))
8705 })
8706 .unwrap();
8707 assert!(store.get(&id).unwrap().chosen_action().is_none());
8708 assert_eq!(
8709 approval_gate(&mut state, &pr, "feat: x", None, "abc")
8710 .await
8711 .unwrap(),
8712 ApprovalGate::Held
8713 );
8714 }
8715 }
8716
8717 #[tokio::test]
8718 async fn explicit_hold_stays_held_but_expired_approval_is_reasked() {
8719 crate::run::pin_test_home();
8720 let store = ask::Questions::open();
8721
8722 let mut held_state = run_state();
8723 held_state.config.graph.land_approval = true;
8724 let pr = green_pr();
8725 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
8726 .await
8727 .unwrap();
8728 let mut q = store
8729 .list()
8730 .into_iter()
8731 .find(|q| q.run == held_state.id)
8732 .expect("filed above");
8733 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
8734 store.put(&mut q).unwrap();
8735 assert_eq!(
8736 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
8737 .await
8738 .unwrap(),
8739 ApprovalGate::Held
8740 );
8741
8742 let mut abandoned_state = run_state();
8743 abandoned_state.config.graph.land_approval = true;
8744 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
8745 .await
8746 .unwrap();
8747 let mut q = store
8748 .list()
8749 .into_iter()
8750 .find(|q| q.run == abandoned_state.id)
8751 .expect("filed above");
8752 q.abandon("no answer within the timeout");
8753 store.put(&mut q).unwrap();
8754 assert_eq!(
8755 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
8756 .await
8757 .unwrap(),
8758 ApprovalGate::Pending,
8759 "an expired approval must ask again without merging"
8760 );
8761 assert_eq!(
8762 store.get(&q.id).unwrap().status,
8763 ask::QuestionStatus::Abandoned
8764 );
8765 assert_ne!(
8766 abandoned_state.land_approval.as_ref().unwrap().question,
8767 q.id
8768 );
8769 }
8770
8771 fn contested() -> ContestedHandoff {
8772 let finding = |id: &str, n: u32| crate::verdict::Finding {
8773 id: id.to_owned(),
8774 severity: crate::verdict::Severity::Major,
8775 file: Some("src/a.rs".to_owned()),
8776 line: Some(n),
8777 title: format!("problem {id}"),
8778 detail: String::new(),
8779 };
8780 ContestedHandoff {
8781 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
8782 rejecters: vec![(1, "alpha".to_owned())],
8783 }
8784 }
8785
8786 #[test]
8787 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
8788 let mut state = run_state();
8789 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
8790 state.contested_handoff = Some(contested());
8791 assert!(contested_to_ask(&state).is_some());
8792 state.config.graph.hold_contested_merge = false;
8793 assert!(
8794 contested_to_ask(&state).is_none(),
8795 "the switch restores today"
8796 );
8797 }
8798
8799 #[test]
8800 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
8801 let q = ask::Question::new(
8802 "run-1".to_owned(),
8803 APPROVAL_NODE.to_owned(),
8804 "land".to_owned(),
8805 "Merge?".to_owned(),
8806 String::new(),
8807 vec![APPROVE.to_owned(), HOLD.to_owned()],
8808 );
8809 let none = deputy_brief(&q, None);
8810 assert!(none.contains("could not be read"), "{none}");
8811 assert!(none.contains("Silence is a hold"), "{none}");
8812
8813 let mut state = run_state();
8814 state.pr = Some(crate::run::PrRecord {
8815 url: "https://example.test/pull/7".to_owned(),
8816 number: 7,
8817 state: "open".to_owned(),
8818 checks: "green".to_owned(),
8819 round: 0,
8820 rounds: 3,
8821 red_at_merge: Vec::new(),
8822 });
8823 state.contested_handoff = Some(contested());
8824 let b = deputy_brief(&q, Some(&state));
8825 assert!(b.contains("https://example.test/pull/7"), "{b}");
8826 assert!(b.contains("not recorded in this legacy question"), "{b}");
8827 assert!(!b.contains("This question was filed although"));
8828 state.contested_handoff = None;
8829 assert!(deputy_brief(&q, Some(&state)).contains("not recorded in this legacy question"));
8830 }
8831
8832 #[test]
8833 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
8834 for lang in ["en", "ja"] {
8835 let mut cfg = crate::config::Config::default();
8836 cfg.graph.language = lang.to_owned();
8837 let w = words(&cfg.graph.language);
8838 let text = w.approval_detail(
8839 "https://github.com/yukimemi/magi/pull/42",
8840 "main",
8841 "feat: x",
8842 Some(&contested()),
8843 );
8844 assert!(text.contains("pull/42"), "{text}");
8845 assert!(
8846 text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
8847 "{text}"
8848 );
8849 assert!(text.contains("R3-1-5"), "{text}");
8850 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
8851 assert!(text.contains("2"), "the rest are counted: {text}");
8852 assert!(text.contains("#1 (alpha)"), "{text}");
8853 }
8854 let plain = words("en").approval_detail("u", "main", "s", None);
8855 assert!(!plain.contains("reject"), "{plain}");
8856 }
8857
8858 #[tokio::test]
8859 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
8860 crate::run::pin_test_home();
8861 let mut state = run_state();
8862 state.config.graph.land_approval = false;
8863 state.contested_handoff = Some(contested());
8864 let pr = green_pr();
8865 let c = contested_to_ask(&state);
8866 assert_eq!(
8867 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
8868 .await
8869 .unwrap(),
8870 ApprovalGate::Pending,
8871 "silence is a hold"
8872 );
8873 let store = ask::Questions::open();
8874 let filed: Vec<_> = store
8875 .list()
8876 .into_iter()
8877 .filter(|q| q.run == state.id)
8878 .collect();
8879 assert_eq!(filed.len(), 1);
8880 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
8881
8882 assert_eq!(
8883 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
8884 .await
8885 .unwrap(),
8886 ApprovalGate::Pending
8887 );
8888 let mut q = store
8889 .list()
8890 .into_iter()
8891 .find(|q| q.run == state.id)
8892 .unwrap();
8893 assert_eq!(q.id, filed[0].id, "the same question after a resume");
8894 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8895 store.put(&mut q).unwrap();
8896 assert_eq!(
8897 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
8898 .await
8899 .unwrap(),
8900 ApprovalGate::Approved
8901 );
8902 }
8903
8904 #[test]
8905 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
8906 let rows = parse_numstat(NUMSTAT);
8907 assert_eq!(
8908 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
8909 ["src/web.rs", "src/land.rs", "assets/logo.png"]
8910 );
8911 assert_eq!(rows[2].added, None, "a binary file has no line counts");
8912 }
8913 #[test]
8914 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
8915 let mut state = run_state();
8919 state.config.graph.language = "ja".to_owned();
8920 let pr = green_pr();
8921 let commits = ["c1".to_owned()];
8922
8923 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
8924 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
8925 assert!(ja.contains("squash されるコミット"), "{ja}");
8926 assert!(ja.contains("レビューコメント"), "{ja}");
8927 assert!(ja.contains("差分"), "{ja}");
8928 assert!(
8929 !ja.contains("Commits being squashed"),
8930 "no English left over"
8931 );
8932
8933 let w = words("ja");
8934 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
8935 assert!(
8936 w.approval_detail("http://x/1", "main", "feat: x", None)
8937 .contains("パネル")
8938 );
8939
8940 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
8942 assert!(ja.contains("feat: x"), "nor is the merge subject");
8943
8944 state.config.graph.language = "en".to_owned();
8947 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
8948 assert!(en.contains("Commits being squashed"), "{en}");
8949 assert_eq!(words("Klingon").html_lang, "en");
8950 }
8951
8952 #[test]
8956 fn pick_open_pr_classifies_by_count_and_base() {
8957 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
8958 assert_eq!(
8959 pick_open_pr(one, "main").unwrap(),
8960 OpenPr::One {
8961 url: "https://x/pull/58".into(),
8962 title: "t".into()
8963 }
8964 );
8965 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
8966 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
8967 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
8968 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
8969 assert_eq!(
8970 pick_open_pr(two, "main").unwrap(),
8971 OpenPr::Many(vec!["u1".into(), "u2".into()])
8972 );
8973 let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
8975 assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
8976 assert!(pick_open_pr("not json", "main").is_err());
8977 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
8979 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
8980 }
8981
8982 #[test]
8983 fn pick_merged_pr_picks_the_unique_match() {
8984 let json = r#"[
8985 {"url": "https://github.com/o/r/pull/42", "number": 42,
8986 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
8987 ]"#;
8988 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
8989 let found = pick_merged_pr(json, "main", created_at)
8990 .expect("valid json")
8991 .expect("one unambiguous match");
8992 assert_eq!(found.url, "https://github.com/o/r/pull/42");
8993 assert_eq!(found.number, 42);
8994 }
8995
8996 #[test]
9000 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
9001 let json = r#"[
9002 {"url": "https://github.com/o/r/pull/42", "number": 42,
9003 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
9004 {"url": "https://github.com/o/r/pull/43", "number": 43,
9005 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
9006 ]"#;
9007 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9008 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9009 }
9010
9011 #[test]
9015 fn pick_merged_pr_ignores_a_different_base_branch() {
9016 let json = r#"[
9017 {"url": "https://github.com/o/r/pull/42", "number": 42,
9018 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
9019 ]"#;
9020 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9021 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9022 }
9023
9024 #[test]
9027 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
9028 let json = r#"[
9029 {"url": "https://github.com/o/r/pull/42", "number": 42,
9030 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
9031 ]"#;
9032 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9033 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9034 }
9035
9036 #[test]
9037 fn slug_of_pr_url_reads_host_owner_and_repo() {
9038 assert_eq!(
9039 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
9040 Some("github.com/yukimemi/shun")
9041 );
9042 }
9043
9044 #[test]
9045 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
9046 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
9047 assert_eq!(slug_of_pr_url("not a url at all"), None);
9048 assert_eq!(slug_of_pr_url("https://github.com"), None);
9049 }
9050
9051 #[test]
9052 fn slug_of_repo_url_reads_host_owner_and_repo() {
9053 assert_eq!(
9054 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
9055 Some("github.com/yukimemi/magi")
9056 );
9057 assert_eq!(slug_of_repo_url("https://github.com"), None);
9058 }
9059
9060 #[test]
9061 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
9062 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
9063 .expect("same repo, different case");
9064 }
9065
9066 #[test]
9071 fn ensure_same_repo_refuses_a_different_repo() {
9072 let err =
9073 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
9074 let msg = format!("{err:#}");
9075 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9076 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
9077 }
9078
9079 #[test]
9084 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
9085 let err = ensure_same_repo(
9086 "github.com/yukimemi/magi",
9087 "github.example.com/yukimemi/magi",
9088 )
9089 .unwrap_err();
9090 let msg = format!("{err:#}");
9091 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9092 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
9093 }
9094
9095 #[tokio::test]
9100 async fn find_external_merge_returns_none_without_a_winner() {
9101 let state = RunState::new(
9102 PathBuf::from("/no/such/repo"),
9103 "main".to_owned(),
9104 "0000000000000000000000000000000000000000".to_owned(),
9105 "irrelevant".to_owned(),
9106 crate::config::Config::default(),
9107 );
9108 assert_eq!(find_external_merge(&state).await.unwrap(), None);
9109 }
9110
9111 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
9112 let mut run = RunState::new(
9113 PathBuf::from(repo),
9114 "main".to_owned(),
9115 "abcdef1234".to_owned(),
9116 "x".to_owned(),
9117 crate::config::Config::default(),
9118 );
9119 run.id = id.to_owned();
9120 run.status = status;
9121 run.driver_exited = true;
9122 run.pr = Some(crate::run::PrRecord {
9123 number: url.rsplit('/').next().unwrap().parse().unwrap(),
9124 url: url.to_owned(),
9125 state: state.to_owned(),
9126 checks: "red".to_owned(),
9127 round: 0,
9128 rounds: 2,
9129 red_at_merge: Vec::new(),
9130 });
9131 run.save_under(home).unwrap();
9132 }
9133
9134 fn recorded(home: &Path, id: &str) -> String {
9135 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
9136 serde_json::from_str::<RunState>(&body)
9137 .unwrap()
9138 .pr
9139 .unwrap()
9140 .state
9141 }
9142
9143 const PR: &str = "https://github.com/o/r/pull/7";
9144
9145 #[test]
9146 fn write_through_updates_predecessors_and_siblings_only() {
9147 let tmp = tempfile::tempdir().unwrap();
9148 let h = tmp.path();
9149 pr_run(
9150 h,
9151 "20261004-100000-aaaa",
9152 "/repo/r",
9153 RunStatus::Superseded,
9154 PR,
9155 "open",
9156 );
9157 pr_run(
9158 h,
9159 "20261004-100100-bbbb",
9160 "/repo/r",
9161 RunStatus::Blocked,
9162 PR,
9163 "open",
9164 );
9165 pr_run(
9167 h,
9168 "20261004-100200-cccc",
9169 "/repo/r",
9170 RunStatus::Landing,
9171 PR,
9172 "open",
9173 );
9174 pr_run(
9176 h,
9177 "20261004-100300-dddd",
9178 "/repo/other",
9179 RunStatus::Blocked,
9180 "https://github.com/o/other/pull/7",
9181 "open",
9182 );
9183 pr_run(
9185 h,
9186 "20261004-100400-eeee",
9187 "/repo/r",
9188 RunStatus::Blocked,
9189 "https://github.com/o/r/pull/8",
9190 "open",
9191 );
9192 pr_run(
9193 h,
9194 "20261004-100500-ffff",
9195 "/repo/r",
9196 RunStatus::Merged,
9197 PR,
9198 "open",
9199 );
9200 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
9201
9202 assert_eq!(
9203 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9204 2
9205 );
9206 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9207 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
9208 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9209 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9210 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9211 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
9213 assert_eq!(
9215 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9216 0
9217 );
9218 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
9219 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
9220 }
9221
9222 #[test]
9223 fn lifecycle_updates_preserve_live_and_unknown_terminal_drivers() {
9224 let home = tempfile::tempdir().unwrap();
9225 let live_id = "20261004-100000-aaaa";
9226 let unknown_id = "20261004-100100-bbbb";
9227 for id in [live_id, unknown_id] {
9228 pr_run(home.path(), id, "repo", RunStatus::Ready, PR, "open");
9229 let mut s = RunState::load_under(id, home.path()).unwrap();
9230 s.driver_exited = false;
9231 s.driver_claimed = None;
9233 if id == live_id {
9234 s.driver_pid = Some(std::process::id());
9235 s.driver_started_at = crate::proc::process_started_at(std::process::id());
9236 }
9237 s.save_under(home.path()).unwrap();
9238 if id == unknown_id {
9239 assert_eq!(
9240 s.liveness_under(home.path(), false),
9241 crate::run::Liveness::Unknown
9242 );
9243 }
9244 }
9245 let known = BTreeMap::from([(PR.to_owned(), PrLifecycle::Merged)]);
9246 assert_eq!(apply_pr_states(home.path(), &known), 0);
9247 for id in [live_id, unknown_id] {
9248 assert_eq!(recorded(home.path(), id), "open");
9249 }
9250 }
9251
9252 #[test]
9253 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
9254 let tmp = tempfile::tempdir().unwrap();
9255 let h = tmp.path();
9256 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
9257 pr_run(
9258 h,
9259 "20261004-100000-aaaa",
9260 "/repo/r",
9261 RunStatus::Superseded,
9262 &url(1),
9263 "open",
9264 );
9265 pr_run(
9266 h,
9267 "20261004-100100-bbbb",
9268 "/repo/r",
9269 RunStatus::Blocked,
9270 &url(2),
9271 "open",
9272 );
9273 pr_run(
9274 h,
9275 "20261004-100200-cccc",
9276 "/repo/r",
9277 RunStatus::Ready,
9278 &url(3),
9279 "open",
9280 );
9281 pr_run(
9282 h,
9283 "20261004-100300-dddd",
9284 "/repo/r",
9285 RunStatus::Ready,
9286 &url(4),
9287 "open",
9288 );
9289 pr_run(
9290 h,
9291 "20261004-100400-eeee",
9292 "/repo/r",
9293 RunStatus::Implementing,
9294 &url(1),
9295 "open",
9296 );
9297 assert_eq!(stale_open_prs(h).len(), 4);
9298
9299 let mut known = BTreeMap::new();
9300 known.insert(url(1), PrLifecycle::Merged);
9301 known.insert(url(2), PrLifecycle::Closed);
9302 known.insert(url(3), PrLifecycle::Open);
9303 assert_eq!(apply_pr_states(h, &known), 2);
9305 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9306 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
9307 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9308 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9309 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9310 assert_eq!(apply_pr_states(h, &known), 0);
9311 }
9312
9313 use std::collections::VecDeque;
9316 use std::sync::Mutex;
9317
9318 struct Scripted {
9321 views: Mutex<VecDeque<Seen>>,
9322 merges: Mutex<VecDeque<(bool, String)>>,
9323 fix: Mutex<Option<Fixed>>,
9324 log: Mutex<Vec<&'static str>>,
9325 argvs: Mutex<Vec<Vec<String>>>,
9326 required: Mutex<Option<BTreeSet<String>>>,
9327 merged: Mutex<bool>,
9330 queued: Mutex<bool>,
9331 unreadable_after_merge: Mutex<bool>,
9333 reruns: Mutex<Vec<String>>,
9335 rerun_fails: Mutex<bool>,
9336 clock: Mutex<i64>,
9338 }
9339
9340 impl Scripted {
9341 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
9342 Self {
9343 views: Mutex::new(views.into()),
9344 merges: Mutex::new(
9345 merges
9346 .into_iter()
9347 .map(|(ok, m)| (ok, m.to_owned()))
9348 .collect(),
9349 ),
9350 fix: Mutex::new(None),
9351 log: Mutex::new(Vec::new()),
9352 argvs: Mutex::new(Vec::new()),
9353 required: Mutex::new(None),
9354 merged: Mutex::new(false),
9355 queued: Mutex::new(false),
9356 unreadable_after_merge: Mutex::new(false),
9357 reruns: Mutex::new(Vec::new()),
9358 rerun_fails: Mutex::new(false),
9359 clock: Mutex::new(1_000),
9360 }
9361 }
9362 fn argvs(&self) -> Vec<Vec<String>> {
9363 self.argvs.lock().unwrap().clone()
9364 }
9365 fn calls(&self) -> Vec<&'static str> {
9366 self.log.lock().unwrap().clone()
9367 }
9368 }
9369
9370 impl Forge for Scripted {
9371 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
9372 self.log.lock().unwrap().push("view");
9373 if *self.unreadable_after_merge.lock().unwrap()
9374 && !self.argvs.lock().unwrap().is_empty()
9375 {
9376 anyhow::bail!("forge unreachable");
9377 }
9378 let mut v = self.views.lock().unwrap();
9379 let mut seen = if v.len() > 1 {
9380 v.pop_front().unwrap()
9381 } else {
9382 v[0].clone()
9383 };
9384 if *self.merged.lock().unwrap() {
9385 seen.pr.state = PrLifecycle::Merged;
9386 }
9387 Ok(seen)
9388 }
9389 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
9390 self.log.lock().unwrap().push("merge");
9391 self.argvs.lock().unwrap().push(argv.to_vec());
9392 let out = self
9393 .merges
9394 .lock()
9395 .unwrap()
9396 .pop_front()
9397 .expect("unscripted merge");
9398 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
9399 {
9400 *self.merged.lock().unwrap() = true;
9401 }
9402 Ok(out)
9403 }
9404 async fn poll(&self) {
9405 self.log.lock().unwrap().push("poll");
9406 *self.clock.lock().unwrap() += POLL.as_secs() as i64;
9407 }
9408 async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
9409 self.log.lock().unwrap().push("rerun");
9410 self.reruns.lock().unwrap().push(run.to_owned());
9411 if *self.rerun_fails.lock().unwrap() {
9412 anyhow::bail!("gh run rerun: no");
9413 }
9414 Ok(())
9415 }
9416 fn now(&self) -> i64 {
9417 *self.clock.lock().unwrap()
9418 }
9419 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
9420 self.required.lock().unwrap().clone()
9421 }
9422 async fn fix(
9423 &self,
9424 _state: &mut RunState,
9425 _pr: &PrState,
9426 _round: usize,
9427 _budget: usize,
9428 _reason: &str,
9429 _logs: &str,
9430 ) -> Result<Fixed> {
9431 self.log.lock().unwrap().push("fix");
9432 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
9433 }
9434 }
9435
9436 const REFUSED: &str =
9437 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
9438
9439 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
9440 let mut pr = green_pr();
9441 pr.checks = checks;
9442 pr.blocking = Blocking::of(merge_state);
9443 if !comments {
9444 pr.review_comments.clear();
9445 }
9446 Seen {
9447 pr,
9448 title: "feat: x".to_owned(),
9449 failing_urls: Vec::new(),
9450 head: head.to_owned(),
9451 rollup_head: head.to_owned(),
9452 merge_state: merge_state.to_owned(),
9453 contexts: Vec::new(),
9454 base: "main".to_owned(),
9455 }
9456 }
9457
9458 fn landing_state() -> RunState {
9459 crate::run::pin_test_home();
9460 let mut state = run_state();
9461 state.config.graph.land_approval = false;
9462 state
9463 }
9464
9465 #[test]
9466 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
9467 assert!(!awaiting_new_head(None, "aaa"));
9468 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
9469 assert!(awaiting_new_head(Some("abc123"), "def456"));
9470 assert!(awaiting_new_head(Some("abc123"), ""));
9471 }
9472
9473 #[test]
9474 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
9475 let open = |c, m: &str| seen("a", c, m, false);
9476 let table = [
9477 (None, false, Refused::Pending),
9478 (
9479 Some(open(Checks::Pending, "BLOCKED")),
9480 false,
9481 Refused::Pending,
9482 ),
9483 (
9484 Some(open(Checks::Unknown, "BLOCKED")),
9485 false,
9486 Refused::Pending,
9487 ),
9488 (
9489 Some(open(Checks::Green, "UNKNOWN")),
9490 false,
9491 Refused::Pending,
9492 ),
9493 (Some(open(Checks::Green, "")), false, Refused::Pending),
9494 (
9495 Some(open(Checks::Green, "BLOCKED")),
9496 false,
9497 Refused::Recheck,
9498 ),
9499 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
9500 ];
9501 for (after, rechecked, want) in table {
9502 assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
9503 }
9504 let mut closed = open(Checks::Green, "CLEAN");
9505 closed.pr.state = PrLifecycle::Closed;
9506 assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
9507 }
9508
9509 #[tokio::test]
9510 async fn a_normal_landing_merges_on_the_first_look() {
9511 let mut state = landing_state();
9512 let forge = Scripted::new(
9513 vec![seen("a", Checks::Green, "CLEAN", false)],
9514 vec![(true, "")],
9515 );
9516 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9517 .await
9518 .unwrap();
9519 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9521 assert_eq!(state.status, RunStatus::Merged);
9522 }
9523
9524 #[tokio::test]
9525 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
9526 let mut state = landing_state();
9527 let forge = Scripted::new(
9528 vec![seen("a", Checks::Green, "CLEAN", false)],
9529 std::iter::repeat_n((true, ""), 100).collect(),
9530 );
9531 *forge.queued.lock().unwrap() = true;
9532 let task = async {
9533 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9534 .await
9535 .unwrap();
9536 };
9537 task.await;
9540 assert_ne!(state.status, RunStatus::Merged);
9541 }
9542
9543 #[tokio::test]
9544 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
9545 let mut state = landing_state();
9546 let forge = Scripted::new(
9547 vec![seen("a", Checks::Green, "CLEAN", false)],
9548 std::iter::repeat_n((true, ""), 100).collect(),
9549 );
9550 *forge.unreadable_after_merge.lock().unwrap() = true;
9551 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9552 .await
9553 .ok();
9554 assert_ne!(state.status, RunStatus::Merged);
9555 }
9556
9557 #[tokio::test]
9558 async fn a_task_public_head_is_awaited_on_land_entry() {
9559 let mut state = landing_state();
9560 state.pr_head = Some("magi/task-abcd-1234".into());
9561 state.pr_pushed_head = Some("new".into());
9562 let forge = Scripted::new(
9563 vec![
9564 seen("old", Checks::Green, "CLEAN", false),
9565 seen("new", Checks::Green, "CLEAN", false),
9566 ],
9567 vec![(true, "")],
9568 );
9569 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9570 .await
9571 .unwrap();
9572 assert_eq!(
9573 forge.calls(),
9574 ["view", "poll", "view", "view", "merge", "view"]
9575 );
9576 assert_eq!(state.status, RunStatus::Merged);
9577 }
9578
9579 #[tokio::test]
9580 async fn a_superseded_public_head_owner_cannot_reenter_land() {
9581 let mut state = landing_state();
9582 state.pr_taken_over_by = Some("next".into());
9583 let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
9584 assert!(
9585 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9586 .await
9587 .is_err()
9588 );
9589 assert!(forge.calls().is_empty());
9590 }
9591
9592 #[tokio::test]
9593 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
9594 let mut state = landing_state();
9595 let forge = Scripted::new(
9596 vec![
9597 seen("old", Checks::Green, "CLEAN", true),
9598 seen("old", Checks::Green, "CLEAN", true),
9600 seen("new", Checks::Pending, "BLOCKED", true),
9601 seen("new", Checks::Green, "CLEAN", true),
9602 ],
9603 vec![(true, "")],
9604 );
9605 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9606 head: "NEW".to_owned(),
9607 });
9608 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9609 .await
9610 .unwrap();
9611 assert_eq!(
9612 forge.calls(),
9613 [
9614 "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
9615 "view"
9616 ]
9617 );
9618 assert_eq!(state.status, RunStatus::Merged);
9619 }
9620
9621 #[tokio::test]
9622 async fn a_head_that_never_arrives_stops_naming_both_commits() {
9623 let mut state = landing_state();
9624 let forge = Scripted::new(
9625 vec![
9626 seen("old", Checks::Green, "CLEAN", true),
9627 seen("someone-elses", Checks::Green, "CLEAN", true),
9628 ],
9629 vec![],
9630 );
9631 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9632 head: "mine".to_owned(),
9633 });
9634 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9635 .await
9636 .unwrap();
9637 assert!(!forge.calls().contains(&"merge"));
9638 let why = state.merge.as_ref().unwrap().detail.clone();
9639 assert!(
9640 why.contains("mine") && why.contains("someone-elses"),
9641 "{why}"
9642 );
9643 assert_eq!(state.status, RunStatus::Blocked);
9644 }
9645
9646 #[tokio::test]
9647 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
9648 let mut state = landing_state();
9649 let forge = Scripted::new(
9650 vec![
9651 seen("a", Checks::Green, "CLEAN", false),
9652 seen("a", Checks::Green, "CLEAN", false),
9653 seen("a", Checks::Pending, "BLOCKED", false),
9654 seen("a", Checks::Pending, "BLOCKED", false),
9655 seen("a", Checks::Green, "CLEAN", false),
9656 ],
9657 vec![(false, REFUSED), (true, "")],
9658 );
9659 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9660 .await
9661 .unwrap();
9662 assert_eq!(
9663 forge.calls(),
9664 [
9665 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
9666 "view"
9667 ]
9668 );
9669 assert_eq!(state.status, RunStatus::Merged);
9670 }
9671
9672 #[tokio::test]
9673 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
9674 let mut state = landing_state();
9675 let forge = Scripted::new(
9676 vec![
9677 seen("a", Checks::Green, "CLEAN", false),
9678 seen("a", Checks::Green, "BLOCKED", false),
9679 ],
9680 vec![(false, REFUSED), (false, REFUSED)],
9681 );
9682 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9683 .await
9684 .unwrap();
9685 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
9687 let why = state.merge.as_ref().unwrap().detail.clone();
9688 assert!(
9689 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
9690 "{why}"
9691 );
9692 assert_eq!(state.status, RunStatus::Blocked);
9693 }
9694
9695 #[test]
9696 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
9697 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
9698 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
9699 assert_eq!(bound_head("old", "old", Some("new")), None);
9701 assert_eq!(bound_head("new", "old", Some("new")), None);
9703 assert_eq!(bound_head("new", "old", None), None);
9704 assert_eq!(bound_head("", "", None), None);
9706 assert_eq!(bound_head("", "", Some("new")), None);
9707 assert_eq!(bound_head("abc", "", None), None);
9708 }
9709
9710 fn view_json(head: &str) -> String {
9711 format!(
9712 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
9713 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
9714 "reviews":[],"comments":[]}}"#
9715 )
9716 }
9717
9718 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
9719 format!(
9720 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
9721 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
9722 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
9723 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
9724 )
9725 }
9726
9727 #[test]
9728 fn rollup_is_bound_to_the_commit_in_the_same_node() {
9729 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
9732 assert_eq!(s.rollup_head, "old");
9733 assert_eq!(s.pr.checks, Checks::Red);
9734 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
9735 assert_eq!(s.failing_urls.len(), 1);
9736 }
9737
9738 #[test]
9739 fn checks_come_from_the_node_not_the_view() {
9740 let view = view_json("new").replace(
9741 r#""reviews""#,
9742 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
9743 );
9744 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
9745 assert_eq!(s.pr.checks, Checks::Green);
9746 assert!(s.pr.failing.is_empty());
9747 assert_eq!(
9748 bound_head(&s.head, &s.rollup_head, Some("new")),
9749 Some("new")
9750 );
9751 }
9752
9753 #[test]
9754 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
9755 for node in [
9756 None,
9757 Some("not json".to_owned()),
9758 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
9759 Some(node_json("new", "SUCCESS", true)),
9760 ] {
9761 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
9762 assert!(s.rollup_head.is_empty());
9763 assert_eq!(s.pr.checks, Checks::Unknown);
9764 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
9765 }
9766 }
9767
9768 #[test]
9769 fn the_merge_command_is_pinned_to_the_observed_head() {
9770 let argv = merge_argv_at(7, "feat: x", "deadbeef");
9771 let at = argv
9772 .iter()
9773 .position(|a| a == "--match-head-commit")
9774 .unwrap();
9775 assert_eq!(argv[at + 1], "deadbeef");
9776 }
9777
9778 #[tokio::test]
9779 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
9780 let mut state = landing_state();
9781 let mut stale = seen("new", Checks::Red, "CLEAN", false);
9784 stale.rollup_head = "old".to_owned();
9785 let forge = Scripted::new(
9786 vec![seen("old", Checks::Green, "CLEAN", true), stale],
9787 vec![],
9788 );
9789 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9790 head: "new".to_owned(),
9791 });
9792 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9793 .await
9794 .unwrap();
9795 assert!(!forge.calls().contains(&"merge"));
9796 assert_eq!(state.status, RunStatus::Blocked);
9797 let why = state.merge.as_ref().unwrap().detail.clone();
9798 assert!(why.contains("new") && why.contains("old"), "{why}");
9799 }
9800
9801 #[test]
9802 fn a_refusal_read_against_another_commits_checks_is_pending() {
9803 let mut after = seen("a", Checks::Green, "BLOCKED", false);
9804 after.rollup_head = "old".to_owned();
9805 assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
9806 }
9807
9808 #[tokio::test]
9809 async fn a_matching_head_with_red_non_required_checks_still_merges() {
9810 let mut state = landing_state();
9811 let forge = Scripted::new(
9812 vec![seen("a", Checks::Red, "CLEAN", false)],
9813 vec![(true, "")],
9814 );
9815 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9816 .await
9817 .unwrap();
9818 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9819 assert_eq!(state.status, RunStatus::Merged);
9820 }
9821
9822 #[tokio::test]
9823 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
9824 let mut state = landing_state();
9825 let forge = Scripted::new(
9826 vec![
9827 seen("a", Checks::Green, "CLEAN", false),
9828 seen("a", Checks::Green, "CLEAN", false),
9829 seen("b", Checks::Green, "BLOCKED", false),
9831 seen("b", Checks::Green, "CLEAN", false),
9832 ],
9833 vec![(false, REFUSED), (true, "")],
9834 );
9835 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9836 .await
9837 .unwrap();
9838 assert_eq!(
9839 forge.calls(),
9840 [
9841 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
9842 ]
9843 );
9844 assert_eq!(state.status, RunStatus::Merged);
9845 }
9846
9847 fn merged_view(head: &str) -> Seen {
9848 let mut m = seen(head, Checks::Green, "CLEAN", false);
9849 m.pr.state = PrLifecycle::Merged;
9850 m
9851 }
9852
9853 fn has(argv: &[String], flag: &str) -> bool {
9854 argv.iter().any(|a| a == flag)
9855 }
9856
9857 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
9858 let at = argv.iter().position(|a| a == flag)?;
9859 argv.get(at + 1).map(String::as_str)
9860 }
9861
9862 const URL: &str = "https://github.com/o/r/pull/42";
9863
9864 #[tokio::test]
9865 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
9866 let mut state = landing_state();
9867 let forge = Scripted::new(
9868 vec![
9869 seen("abc", Checks::Green, "CLEAN", false),
9870 seen("abc", Checks::Green, "CLEAN", false),
9871 ],
9872 vec![(true, "")],
9873 );
9874 land_with(&mut state, URL, &forge).await.unwrap();
9875 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9876 let argv = &forge.argvs()[0];
9877 assert!(has(argv, "--squash") && has(argv, "--subject"));
9878 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
9879 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
9880 assert_eq!(state.status, RunStatus::Merged);
9881 assert!(state.land_armed_head.is_none());
9882 }
9883
9884 #[tokio::test]
9885 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
9886 let mut state = landing_state();
9887 state.land_armed_head = Some("a".to_owned());
9888 let forge = Scripted::new(
9889 vec![seen("a", Checks::Green, "CLEAN", false)],
9890 vec![(true, ""), (true, "")],
9891 );
9892 land_with(&mut state, URL, &forge).await.unwrap();
9893 let argvs = forge.argvs();
9894 assert!(has(&argvs[0], "--disable-auto"));
9895 assert!(!has(&argvs[1], "--auto"));
9896 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
9897 assert_eq!(state.status, RunStatus::Merged);
9898 assert!(state.land_armed_head.is_none());
9899 }
9900
9901 #[tokio::test]
9902 async fn an_approval_never_carries_over_to_a_new_head() {
9903 crate::run::pin_test_home();
9904 let mut state = run_state();
9905 state.config.graph.land_approval = true;
9906 let pr = green_pr();
9907 let store = ask::Questions::open();
9908
9909 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
9910 .await
9911 .unwrap();
9912 let mut q = store
9913 .list()
9914 .into_iter()
9915 .find(|q| q.run == state.id)
9916 .unwrap();
9917 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
9918 store.put(&mut q).unwrap();
9919 assert_eq!(
9920 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
9921 .await
9922 .unwrap(),
9923 ApprovalGate::Approved,
9924 "the same head keeps its approval"
9925 );
9926
9927 assert_eq!(
9929 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
9930 .await
9931 .unwrap(),
9932 ApprovalGate::Pending
9933 );
9934 let all: Vec<_> = store
9935 .list()
9936 .into_iter()
9937 .filter(|q| q.run == state.id)
9938 .collect();
9939 assert_eq!(all.len(), 2);
9940
9941 state.land_approval = None;
9943 assert_eq!(
9944 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
9945 .await
9946 .unwrap(),
9947 ApprovalGate::Pending
9948 );
9949 let open = store
9950 .list()
9951 .into_iter()
9952 .filter(|q| q.run == state.id && q.status.open())
9953 .count();
9954 assert_eq!(open, 1, "the superseded question was retired");
9955 }
9956
9957 #[tokio::test]
9958 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
9959 let mut state = landing_state();
9960 let forge = Scripted::new(
9961 vec![
9962 seen("a", Checks::Green, "CLEAN", false),
9963 seen("b", Checks::Green, "CLEAN", false),
9965 ],
9966 vec![(true, "")],
9967 );
9968 land_with(&mut state, URL, &forge).await.unwrap();
9969 let argvs = forge.argvs();
9970 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
9971 assert!(!has(&argvs[0], "--auto"));
9972 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
9973 assert_eq!(state.status, RunStatus::Merged);
9974 }
9975
9976 fn passing(label: &str) -> CheckInfo {
9977 CheckInfo {
9978 label: label.to_owned(),
9979 verdict: Verdict::Pass,
9980 required: Some(false),
9981 run: None,
9982 }
9983 }
9984
9985 fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
9986 (label.to_owned(), run.map(str::to_owned))
9987 }
9988
9989 fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
9990 LandReruns {
9991 head: head.to_owned(),
9992 runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
9993 }
9994 }
9995
9996 #[test]
9997 fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
9998 let f = [failing("test (macos)", Some("7"))];
9999 assert_eq!(
10000 rerun_step(&f, None, "h", 100),
10001 RerunStep::Rerun(vec!["7".to_owned()])
10002 );
10003 let rec = reran("h", &[("7", 100)]);
10004 let grace = crate::release_watch::RERUN_GRACE;
10005 assert_eq!(
10006 rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
10007 RerunStep::Grace
10008 );
10009 assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
10010 }
10011
10012 #[test]
10013 fn a_failure_without_a_run_id_goes_to_the_fixer() {
10014 let f = [failing("a", Some("7")), failing("ci/external", None)];
10015 assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
10016 assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
10017 }
10018
10019 #[test]
10020 fn a_moved_head_forgets_the_reruns() {
10021 let f = [failing("a", Some("7"))];
10022 let rec = reran("old", &[("7", 100)]);
10023 assert_eq!(
10024 rerun_step(&f, Some(&rec), "new", 101),
10025 RerunStep::Rerun(vec!["7".to_owned()])
10026 );
10027 }
10028
10029 #[test]
10030 fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
10031 let f = [
10032 failing("a", Some("7")),
10033 failing("b", Some("7")),
10034 failing("c", Some("8")),
10035 ];
10036 assert_eq!(
10037 rerun_step(&f, None, "h", 0),
10038 RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
10039 );
10040 let rec = reran("h", &[("7", 0)]);
10041 assert_eq!(
10042 rerun_step(&f, Some(&rec), "h", 1),
10043 RerunStep::Rerun(vec!["8".to_owned()])
10044 );
10045 }
10046
10047 #[test]
10048 fn only_blocking_failures_are_candidates_for_a_rerun() {
10049 let check = |label: &str, verdict, required, run: &str| CheckInfo {
10050 label: label.to_owned(),
10051 verdict,
10052 required,
10053 run: Some(run.to_owned()),
10054 };
10055 let got = rerunnable(&[
10056 check("passed", Verdict::Pass, Some(true), "1"),
10057 check("optional", Verdict::Fail, Some(false), "2"),
10058 check("required", Verdict::Fail, Some(true), "3"),
10059 check("unknown", Verdict::Fail, None, "4"),
10060 ]);
10061 let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
10062 assert_eq!(labels, ["required", "unknown"]);
10063 }
10064
10065 fn red(head: &str, run: Option<&str>) -> Seen {
10066 let mut s = seen(head, Checks::Red, "BLOCKED", false);
10067 s.pr.failing = vec!["test (macos)".to_owned()];
10068 s.contexts = vec![CheckInfo {
10069 label: "test (macos)".to_owned(),
10070 verdict: Verdict::Fail,
10071 required: Some(true),
10072 run: run.map(str::to_owned),
10073 }];
10074 s
10075 }
10076
10077 const RERUN_PR: &str = "https://github.com/o/r/pull/42";
10078
10079 #[tokio::test]
10080 async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
10081 let mut state = landing_state();
10082 let forge = Scripted::new(
10083 vec![
10084 red("a", Some("7")),
10085 seen("a", Checks::Pending, "BLOCKED", false),
10086 seen("a", Checks::Green, "CLEAN", false),
10087 ],
10088 vec![(true, "")],
10089 );
10090 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10091 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10092 assert!(!forge.calls().contains(&"fix"));
10093 assert_eq!(state.status, RunStatus::Merged);
10094 assert_eq!(state.pr.as_ref().unwrap().round, 0);
10095 let argvs = forge.argvs();
10096 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
10097 assert!(
10098 state
10099 .events
10100 .iter()
10101 .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
10102 );
10103 }
10104
10105 #[tokio::test]
10106 async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
10107 let mut state = landing_state();
10108 let forge = Scripted::new(
10109 vec![
10110 red("a", Some("7")),
10111 seen("a", Checks::Green, "CLEAN", false),
10112 ],
10113 vec![(true, "")],
10114 );
10115 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10116 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10118 assert_eq!(forge.reruns.lock().unwrap().len(), 1);
10119 let calls = forge.calls();
10120 let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
10121 let fix_at = calls.iter().position(|c| *c == "fix");
10122 assert!(fix_at.is_none_or(|f| f > rerun_at));
10123 }
10124
10125 #[tokio::test]
10126 async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
10127 let mut state = landing_state();
10128 let mut v = red("a", Some("7"));
10129 v.pr.checks = Checks::Pending;
10130 v.contexts.push(CheckInfo {
10131 label: "slow".to_owned(),
10132 verdict: Verdict::Pending,
10133 required: Some(true),
10134 run: Some("8".to_owned()),
10135 });
10136 let forge = Scripted::new(
10137 vec![v, seen("a", Checks::Green, "CLEAN", false)],
10138 vec![(true, "")],
10139 );
10140 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10141 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10142 assert_eq!(state.status, RunStatus::Merged);
10143 }
10144
10145 #[test]
10146 fn a_run_with_a_job_still_going_is_not_rerun_yet() {
10147 let mut v = red("a", Some("7"));
10148 v.contexts.push(CheckInfo {
10149 label: "same run, still going".to_owned(),
10150 verdict: Verdict::Pending,
10151 required: Some(true),
10152 run: Some("7".to_owned()),
10153 });
10154 assert!(rerunnable(&v.contexts).is_empty());
10155 }
10156
10157 #[tokio::test]
10158 async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
10159 let mut state = landing_state();
10160 state.config.graph.land_rounds = 0;
10161 let forge = Scripted::new(
10162 vec![
10163 red("a", Some("7")),
10164 seen("a", Checks::Green, "CLEAN", false),
10165 ],
10166 vec![(true, "")],
10167 );
10168 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10169 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10170 assert_eq!(state.status, RunStatus::Merged);
10171 assert!(!forge.calls().contains(&"fix"));
10172 assert_eq!(state.pr.as_ref().unwrap().round, 0);
10173 }
10174
10175 #[tokio::test]
10176 async fn the_last_fix_rounds_new_head_gets_its_rerun() {
10177 let mut state = landing_state();
10178 state.config.graph.land_rounds = 1;
10179 let forge = Scripted::new(
10180 vec![
10181 seen("a", Checks::Green, "CLEAN", true),
10182 red("b", Some("7")),
10183 seen("b", Checks::Green, "CLEAN", false),
10184 ],
10185 vec![(true, "")],
10186 );
10187 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10188 head: "b".to_owned(),
10189 });
10190 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10191 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10192 assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
10193 assert_eq!(state.pr.as_ref().unwrap().round, 1);
10194 assert_eq!(state.status, RunStatus::Merged);
10195 assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
10196 assert_eq!(
10197 value_of(&forge.argvs()[0], "--match-head-commit"),
10198 Some("b")
10199 );
10200 }
10201
10202 #[tokio::test]
10203 async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
10204 let mut state = landing_state();
10205 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10206 *forge.rerun_fails.lock().unwrap() = true;
10207 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10208 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10209 assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
10210 assert_eq!(
10211 forge.calls().iter().filter(|c| **c == "fix").count(),
10212 1,
10213 "{:?}",
10214 forge.calls()
10215 );
10216 }
10217
10218 #[tokio::test]
10219 async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
10220 let mut state = landing_state();
10221 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10222 state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
10224 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10225 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10226 assert!(forge.reruns.lock().unwrap().is_empty());
10227 }
10228
10229 #[tokio::test]
10230 async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
10231 let mut state = landing_state();
10232 state.config.graph.rerun_flaky_checks = false;
10233 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10234 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10235 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10236 assert!(forge.reruns.lock().unwrap().is_empty());
10237 assert!(forge.calls().contains(&"fix"));
10238
10239 let mut state = landing_state();
10240 let forge = Scripted::new(vec![red("a", None)], vec![]);
10241 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10242 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10243 assert!(forge.reruns.lock().unwrap().is_empty());
10244 assert!(forge.calls().contains(&"fix"));
10245 }
10246
10247 fn names(xs: &[&str]) -> BTreeSet<String> {
10248 xs.iter().map(|x| (*x).to_owned()).collect()
10249 }
10250
10251 #[test]
10252 fn a_required_check_the_rollup_never_listed_is_named() {
10253 let req = names(&["build"]);
10254 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
10255 assert!(why.contains("never reported: build"), "{why}");
10256 assert!(!why.contains("probably waiting for a review"), "{why}");
10257 }
10258
10259 #[test]
10260 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
10261 let why = waiting_on("BLOCKED", &[passing("review")], None);
10262 assert!(why.contains("could not be read"), "{why}");
10263 assert!(!why.contains("probably waiting for a review"), "{why}");
10264 }
10265
10266 #[test]
10267 fn all_required_reported_keeps_the_review_guess() {
10268 let req = names(&["build"]);
10269 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10270 assert!(why.contains("probably waiting for a review"), "{why}");
10271 assert!(!why.contains("never reported"), "{why}");
10272 }
10273
10274 #[test]
10275 fn required_names_match_the_rollup_ignoring_case_only() {
10276 let req = names(&["Build"]);
10277 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10278 assert!(!why.contains("never reported"), "{why}");
10279 }
10280
10281 #[test]
10282 fn required_contexts_are_read_from_protection_and_rulesets() {
10283 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
10284 assert_eq!(
10285 parse_classic_required(classic),
10286 Some(names(&["build", "lint"]))
10287 );
10288 let rules = r#"[{"type":"pull_request","parameters":{}},
10289 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
10290 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
10291 assert_eq!(parse_ruleset_required("nope"), None);
10292 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
10293 }
10294
10295 #[test]
10296 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
10297 let shown = BTreeSet::new();
10298 let ok = seen("a", Checks::Green, "CLEAN", false);
10299 let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
10300 assert!(guard(Some(&ok)));
10301 assert!(!guard(None));
10302 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
10303 let mut stale = ok.clone();
10304 stale.rollup_head = "old".to_owned();
10305 assert!(!guard(Some(&stale)));
10306 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
10307 assert!(!guard(Some(&merged_view("a"))));
10308 }
10309
10310 #[test]
10311 fn the_rollup_node_carries_whether_each_check_is_required() {
10312 let node = node_json("new", "SUCCESS", false)
10313 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
10314 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
10315 assert_eq!(s.contexts.len(), 1);
10316 assert_eq!(s.contexts[0].required, Some(true));
10317 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
10318 assert_eq!(s.contexts[0].required, None);
10319 }
10320
10321 #[tokio::test]
10322 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
10323 let mut state = landing_state();
10324 state.land_armed_head = Some("a".to_owned());
10325 let forge = Scripted::new(
10326 vec![seen("a", Checks::Green, "CLEAN", true)],
10327 vec![(false, "disable exploded")],
10328 );
10329 land_with(&mut state, URL, &forge).await.unwrap();
10330 assert!(!forge.calls().contains(&"fix"));
10331 assert_eq!(state.status, RunStatus::Blocked);
10332 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
10333 let why = state.merge.as_ref().unwrap().detail.clone();
10334 assert!(why.contains("disable exploded"), "{why}");
10335 }
10336}