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.config.graph.land_approval {
650 reasons.push(ApprovalReason::EveryMerge);
651 }
652 if state.config.graph.hold_contested_merge {
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 _ = writeln!(s, "- {label}");
733 }
734 s.push_str(if ja {
735 "\n再考後の各レビュアーの最終票:\n"
736 } else {
737 "\nFinal reviewer votes after reconsideration:\n"
738 });
739 if let Some(round) = state.reviews.last() {
740 let votes = round.final_votes();
741 for r in &round.reviews {
742 let vote = votes
743 .iter()
744 .find(|(seat, _, _)| *seat == r.reviewer)
745 .map(|(_, _, v)| v.label())
746 .unwrap_or(if ja { "記録なし" } else { "not recorded" });
747 let _ = writeln!(s, "#{} ({}): {vote}", r.reviewer, r.agent);
748 let rv = round
749 .reconsideration
750 .iter()
751 .find(|rv| rv.reviewer == r.reviewer);
752 if let Some(rv) = rv.filter(|rv| rv.vote.is_some()) {
753 let initial = r.vote.map(|v| v.label()).unwrap_or("?");
754 let _ = writeln!(
755 s,
756 " {}: {initial} → {vote}",
757 if ja { "再考" } else { "reconsideration" }
758 );
759 let _ = writeln!(
760 s,
761 " {}: {}",
762 if ja { "理由" } else { "reason" },
763 reason(if rv.reason.trim().is_empty() {
764 if ja { "記録なし" } else { "not recorded" }
765 } else {
766 &rv.reason
767 })
768 );
769 } else {
770 let _ = writeln!(
771 s,
772 " {}: {}",
773 if ja {
774 "初回票を保持・理由"
775 } else {
776 "initial vote retained; reason"
777 },
778 reason(if r.summary.trim().is_empty() {
779 if ja { "記録なし" } else { "not recorded" }
780 } else {
781 &r.summary
782 })
783 );
784 if let Some(rv) = rv {
785 let _ = writeln!(
786 s,
787 " {}: {}",
788 if ja {
789 "再投票の回答なし"
790 } else {
791 "no reconsideration vote"
792 },
793 reason(rv.failed.as_deref().unwrap_or(if ja {
794 "理由の記録なし"
795 } else {
796 "reason not recorded"
797 }))
798 );
799 }
800 }
801 if let Some(failed) = &r.failed {
802 let _ = writeln!(
803 s,
804 " {}: {}",
805 if ja { "回答なし" } else { "no answer" },
806 reason(failed)
807 );
808 }
809 }
810 } else {
811 s.push_str(if ja {
812 "レビュー記録なし\n"
813 } else {
814 "No review recorded\n"
815 });
816 }
817 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" });
818 let findings = state.last_round_findings();
819 for f in &findings {
820 let _ = writeln!(
821 s,
822 "[{}] {} {}:{}: {}\n{}",
823 f.id,
824 f.severity,
825 f.file.as_deref().unwrap_or("?"),
826 f.line.map(|l| l.to_string()).unwrap_or_else(|| "?".into()),
827 approval_excerpt(&f.title, TITLE_CHARS),
828 reason(&f.detail)
829 );
830 let mut evidence = false;
831 for fix in state.reviews.iter().filter_map(|r| r.fix.as_ref()).chain(
832 state
833 .operator_fixes
834 .iter()
835 .filter_map(|request| request.fix.as_ref()),
836 ) {
837 if fix.addressed.contains(&f.id) {
838 evidence = true;
839 let _ = writeln!(
840 s,
841 " {} ({}): {}",
842 if ja {
843 "採用申告(解決未確認)"
844 } else {
845 "claimed addressed (resolution unconfirmed)"
846 },
847 fix.agent,
848 reason(&fix.notes)
849 );
850 }
851 for rejection in fix.rejected.iter().filter(|r| r.id == f.id) {
852 evidence = true;
853 let _ = writeln!(
854 s,
855 " {}: {}",
856 if ja { "不採用理由" } else { "declined" },
857 reason(&rejection.why)
858 );
859 }
860 }
861 if !evidence {
862 s.push_str(if ja {
863 " 採用・解決確認の記録なし\n"
864 } else {
865 " No adoption or resolution evidence recorded\n"
866 });
867 }
868 }
869 if findings.is_empty() {
870 s.push_str(if ja {
871 "最終ラウンドに指摘の記録なし\n"
872 } else {
873 "No findings recorded in the last round\n"
874 });
875 }
876 s
877}
878
879pub fn approval_choices(state: &RunState) -> Vec<String> {
881 let mut choices = vec![
882 APPROVE.to_owned(),
883 HOLD.to_owned(),
884 REPAIR.to_owned(),
885 crate::merge_decision::WITHDRAW.to_owned(),
886 crate::merge_decision::DISMISS.to_owned(),
887 ];
888 choices.extend(
889 state
890 .config
891 .agents
892 .iter()
893 .map(|a| format!("{}{id}", crate::merge_decision::REIMPLEMENT, id = a.id)),
894 );
895 choices
896}
897
898pub fn upgrade_repair_choice(q: &mut ask::Question, state: &RunState) -> bool {
901 if q.node != APPROVAL_NODE
902 || !state
903 .land_approval
904 .as_ref()
905 .is_some_and(|a| a.question == q.id)
906 {
907 return false;
908 }
909 let before = (q.choices.clone(), q.actions.clone(), q.merge_target.clone());
910 if q.status.open() {
911 q.actions.remove(HOLD);
914 for choice in approval_choices(state) {
915 if !q.choices.contains(&choice) {
916 q.choices.push(choice);
917 }
918 }
919 if q.merge_target.is_none() {
920 q.merge_target = state.pr.as_ref().map(|pr| crate::merge_decision::Target {
921 pr: pr.url.clone(),
922 head: state.land_approval.as_ref().unwrap().head.clone(),
923 language: state.config.graph.language.clone(),
924 });
925 }
926 }
927 for choice in q.choices.clone() {
928 if (q.status.open()
929 && crate::merge_decision::stops(&choice)
930 && choice != HOLD
931 && choice != crate::merge_decision::DISMISS)
932 || (choice == REPAIR && (q.status.open() || q.resolution().as_deref() == Some(REPAIR)))
933 {
934 q.actions.insert(
935 choice,
936 ask::ChoiceAction::Resume {
937 run: state.id.clone(),
938 },
939 );
940 }
941 }
942 before != (q.choices.clone(), q.actions.clone(), q.merge_target.clone())
943}
944
945pub fn unhedged(message: &str, quote: &str) -> bool {
953 let quote = quote.trim();
954 !quote.is_empty() && message.contains(quote) && !hedged(message) && !retracts(message)
955}
956
957fn hedged(text: &str) -> bool {
961 const WORDS: &[&str] = &[
962 "maybe",
963 "probably",
964 "perhaps",
965 "might",
966 "if",
967 "unless",
968 "not",
969 "no",
970 "nope",
971 "stop",
972 "dont",
973 "abort",
974 "revert",
975 "undo",
976 "never",
977 "wait",
978 "hold",
979 "cancel",
980 "but",
981 "think",
982 "guess",
983 "suppose",
984 "unsure",
985 "yet",
986 "except",
987 "only",
988 "cannot",
989 "should",
990 "once",
991 "provided",
992 "providing",
993 "when",
994 "whenever",
995 "after",
996 "until",
997 "before",
998 "assuming",
999 "given",
1000 "while",
1001 "whether",
1002 "depending",
1003 "pending",
1004 ];
1005 const JA: &[&str] = &[
1006 "かも",
1007 "たぶん",
1008 "多分",
1009 "なら",
1010 "たら",
1011 "ちょっと待",
1012 "しないで",
1013 "しない",
1014 "保留",
1015 "まだ",
1016 "ただし",
1017 "やめ",
1018 "いや",
1019 "止め",
1020 "だめ",
1021 "ダメ",
1022 "じゃない",
1023 "ではない",
1024 "ですか",
1025 "かな",
1026 "でしょう",
1027 "思う",
1028 "でも",
1029 "けど",
1030 "ただ",
1031 "次第",
1032 "場合",
1033 "限り",
1034 "条件",
1035 "とき",
1036 "まで",
1037 "後で",
1038 ];
1039 if text.contains(['?', '?']) || JA.iter().any(|w| text.contains(w)) {
1040 return true;
1041 }
1042 text.to_lowercase()
1043 .replace('\u{2019}', "'")
1044 .split(|c: char| !(c.is_alphanumeric() || c == '\'') || !c.is_ascii())
1045 .filter(|w| !w.is_empty())
1046 .any(|w| WORDS.contains(&w) || w.ends_with("n't"))
1047}
1048
1049fn retracts(message: &str) -> bool {
1052 let lower = message.to_lowercase();
1053 [
1054 "やっぱ",
1055 "待って",
1056 "撤回",
1057 "never mind",
1058 "actually",
1059 "on second thought",
1060 ]
1061 .iter()
1062 .any(|w| lower.contains(w))
1063 || lower
1064 .split(|c: char| !c.is_ascii_alphabetic())
1065 .any(|w| w == "wait")
1066}
1067
1068pub const APPROVAL_NODE: &str = "land-approval";
1074
1075pub const DIFF_MAX_LINES: usize = 400;
1083
1084#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1086pub enum Approval {
1087 Merge,
1089 Repair,
1091 Hold,
1093}
1094
1095pub fn approval(answer: Option<&str>) -> Approval {
1103 match answer {
1104 Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
1105 Some(a) if a.trim() == REPAIR => Approval::Repair,
1106 _ => Approval::Hold,
1107 }
1108}
1109
1110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1112enum ApprovalGate {
1113 Approved,
1115 Repair,
1117 Held,
1120 Pending,
1122}
1123
1124fn esc(s: &str) -> String {
1134 let mut out = String::with_capacity(s.len());
1135 for c in s.chars() {
1136 match c {
1137 '&' => out.push_str("&"),
1138 '<' => out.push_str("<"),
1139 '>' => out.push_str(">"),
1140 '"' => out.push_str("""),
1141 '\'' => out.push_str("'"),
1142 _ => out.push(c),
1143 }
1144 }
1145 out
1146}
1147
1148#[derive(Debug, Clone, PartialEq, Eq)]
1150struct StatRow {
1151 path: String,
1152 added: Option<u64>,
1154 removed: Option<u64>,
1155}
1156
1157impl StatRow {
1158 fn churn(&self) -> u64 {
1161 self.added.unwrap_or(0) + self.removed.unwrap_or(0)
1162 }
1163}
1164
1165fn parse_numstat(numstat: &str) -> Vec<StatRow> {
1171 let mut rows: Vec<StatRow> = numstat
1172 .lines()
1173 .filter_map(|line| {
1174 let mut parts = line.splitn(3, '\t');
1175 let added = parts.next()?.trim();
1176 let removed = parts.next()?.trim();
1177 let path = parts.next()?.trim();
1178 if path.is_empty() {
1179 return None;
1180 }
1181 Some(StatRow {
1182 path: path.to_owned(),
1183 added: added.parse().ok(),
1184 removed: removed.parse().ok(),
1185 })
1186 })
1187 .collect();
1188 rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
1191 rows
1192}
1193
1194fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
1203 if line.starts_with("+++") || line.starts_with("---") {
1204 (" ", "color:#57606a;font-weight:600", line)
1205 } else if let Some(body) = line.strip_prefix('+') {
1206 ("+", "background:#e6ffec;color:#0a3622", body)
1207 } else if let Some(body) = line.strip_prefix('-') {
1208 ("-", "background:#ffebe9;color:#5c1a17", body)
1209 } else if line.starts_with("@@") {
1210 ("~", "background:#eef2ff;color:#3730a3", line)
1211 } else if let Some(body) = line.strip_prefix(' ') {
1212 (" ", "", body)
1213 } else {
1214 (" ", "color:#57606a;font-weight:600", line)
1215 }
1216}
1217
1218struct Words {
1227 html_lang: &'static str,
1228 task: &'static str,
1229 what_changed: &'static str,
1230 review_verdict: &'static str,
1231 reviewer: &'static str,
1232 reviewer_no_answer: &'static str,
1233 checks: &'static str,
1234 nothing_failing: &'static str,
1235 files_changed: &'static str,
1236 commits: &'static str,
1237 no_commits: &'static str,
1238 comments: &'static str,
1239 no_comments: &'static str,
1240 diff: &'static str,
1241 truncated: &'static str,
1242 lands_as: &'static str,
1243}
1244
1245const EN: Words = Words {
1246 html_lang: "en",
1247 task: "Task",
1248 what_changed: "What changed",
1249 review_verdict: "Review verdict",
1250 reviewer: "Reviewer",
1251 reviewer_no_answer: "produced no answer",
1252 checks: "Checks",
1253 nothing_failing: "Nothing failing.",
1254 files_changed: "file(s) changed",
1255 commits: "Commits being squashed",
1256 no_commits: "No commit subjects could be read from the branch.",
1257 comments: "External PR review comments",
1258 no_comments: "No unaddressed external PR comments at this observation. This does not mean internal findings are resolved.",
1259 diff: "Diff",
1260 truncated: "Truncated",
1261 lands_as: "They land as one commit titled",
1262};
1263
1264const JA: Words = Words {
1265 html_lang: "ja",
1266 task: "タスク",
1267 what_changed: "変更内容",
1268 review_verdict: "レビューの結論",
1269 reviewer: "レビュアー",
1270 reviewer_no_answer: "回答なし",
1271 checks: "チェック",
1272 nothing_failing: "失敗しているものはありません。",
1273 files_changed: "ファイル変更",
1274 commits: "squash されるコミット",
1275 no_commits: "ブランチからコミット件名を読めませんでした。",
1276 comments: "外部 PR レビューコメント",
1277 no_comments: "この時点で未対応の外部 PR コメントはありません。内部指摘の解決を意味するものではありません。",
1278 diff: "差分",
1279 truncated: "省略",
1280 lands_as: "これらは次の件名の1コミットとして入ります:",
1281};
1282
1283impl Words {
1284 fn lands_as_tail(&self) -> &'static str {
1288 if self.html_lang == "ja" {
1289 "。この件名も承認の対象です。"
1290 } else {
1291 ", which you are approving too."
1292 }
1293 }
1294
1295 fn approval_summary(&self, number: u64, subject: &str) -> String {
1297 if self.html_lang == "ja" {
1298 format!("プルリクエスト #{number} をマージ: {subject}")
1299 } else {
1300 format!("merge pull request #{number}: {subject}")
1301 }
1302 }
1303
1304 fn approval_detail(
1306 &self,
1307 url: &str,
1308 base: &str,
1309 subject: &str,
1310 contested: Option<&ContestedHandoff>,
1311 ) -> String {
1312 let body = if self.html_lang == "ja" {
1313 format!(
1314 "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
1315 できる状態です。差分の要約・パッチ・squash されるコミットは\
1316 下のパネルにあります。"
1317 )
1318 } else {
1319 format!(
1320 "{url} is green and ready to squash into `{base}` as `{subject}`. \
1321 The panel holds the diffstat, the patch and the commits being squashed."
1322 )
1323 };
1324 match contested {
1325 Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1326 None => body,
1327 }
1328 }
1329
1330 fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1333 const SHOWN: usize = 5;
1334 const TITLE_CHARS: usize = 100;
1335 let ja = self.html_lang == "ja";
1336 let mut out = if ja {
1337 format!(
1338 "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1339 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1340 )
1341 } else {
1342 format!(
1343 "{url} is held for approval although merge approvals are off: the \
1344 review ended with blocking findings still open and a reviewer \
1345 voting reject.\n"
1346 )
1347 };
1348 for f in c.findings.iter().take(SHOWN) {
1349 let at = match (&f.file, f.line) {
1350 (Some(file), Some(line)) => format!("{file}:{line}"),
1351 (Some(file), None) => file.clone(),
1352 _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1353 };
1354 let title: String = f.title.chars().take(TITLE_CHARS).collect();
1355 let _ = writeln!(out, "- {} {} {at}: {title}", f.id, f.severity);
1356 }
1357 if c.findings.len() > SHOWN {
1358 let more = c.findings.len() - SHOWN;
1359 let _ = writeln!(
1360 out,
1361 "{}",
1362 if ja {
1363 format!("- ほか {more} 件")
1364 } else {
1365 format!("- and {more} more")
1366 }
1367 );
1368 }
1369 let seats: Vec<String> = c
1370 .rejecters
1371 .iter()
1372 .map(|(seat, agent)| format!("#{seat} ({agent})"))
1373 .collect();
1374 let _ = write!(
1375 out,
1376 "{} {}",
1377 if ja {
1378 "却下したレビュアー:"
1379 } else {
1380 "Rejected by reviewer:"
1381 },
1382 seats.join(", ")
1383 );
1384 out
1385 }
1386
1387 fn truncated_note(
1389 &self,
1390 omitted: usize,
1391 total: usize,
1392 shown: usize,
1393 where_: &str,
1394 base: &str,
1395 head: &str,
1396 ) -> String {
1397 if self.html_lang == "ja" {
1398 format!(
1399 "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1400 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1401 プルリクエストにあります。"
1402 )
1403 } else {
1404 format!(
1405 "{omitted} of {total} diff lines omitted after the first {shown}. \
1406 The whole patch is in <code>{where_}</code> \
1407 (<code>git diff {base}...{head}</code>) and on the pull request."
1408 )
1409 }
1410 }
1411}
1412
1413fn words(language: &str) -> &'static Words {
1416 if crate::lang::is_japanese(language) {
1417 &JA
1418 } else {
1419 &EN
1420 }
1421}
1422
1423pub fn approval_panel(
1435 state: &RunState,
1436 pr: &PrState,
1437 diffstat: &str,
1438 diff: &str,
1439 commits: &[String],
1440 subject: &str,
1441) -> String {
1442 let rows = parse_numstat(diffstat);
1443 let w = words(&state.config.graph.language);
1444 let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1445
1446 let _ = writeln!(
1447 h,
1448 "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1449 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1450 w.html_lang
1451 );
1452 let _ = writeln!(
1453 h,
1454 "<title>merge #{} — {}</title>\n</head>",
1455 pr.number,
1456 esc(subject)
1457 );
1458 h.push_str(
1459 "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1460 'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1461 word-break:break-word\">\n",
1462 );
1463
1464 let _ = writeln!(
1466 h,
1467 "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1468 <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1469 <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1470 <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1471 <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#0969da\">{}</a></p>",
1472 pr.number,
1473 esc(&state.base_branch),
1474 esc(subject),
1475 esc(&state.id),
1476 esc(&pr.url),
1477 esc(&pr.url),
1478 );
1479
1480 let _ = writeln!(
1481 h,
1482 "<section style=\"padding:12px;background:#fff8c5;border:1px solid #9a6700;border-radius:6px;white-space:pre-wrap\">{}</section>",
1483 esc(&approval_evidence(state))
1484 );
1485
1486 let _ = writeln!(
1489 h,
1490 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1491 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1492 w.task,
1493 esc(&state.instruction)
1494 );
1495
1496 if let Some(summary) = state
1498 .winner()
1499 .map(|c| c.summary.as_str())
1500 .filter(|s| !s.is_empty())
1501 {
1502 let _ = writeln!(
1503 h,
1504 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1505 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1506 w.what_changed,
1507 esc(summary)
1508 );
1509 }
1510
1511 if let Some(round) = state.reviews.last() {
1514 let _ = writeln!(
1515 h,
1516 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1517 w.review_verdict
1518 );
1519 for r in &round.reviews {
1520 let body = match &r.failed {
1527 Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1528 None => esc(&r.summary),
1529 };
1530 let _ = writeln!(
1531 h,
1532 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1533 border-radius:6px\">\
1534 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1535 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1536 w.reviewer,
1537 r.reviewer,
1538 esc(&r.agent),
1539 body,
1540 );
1541 }
1542 }
1543
1544 let _ = writeln!(
1545 h,
1546 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1547 w.checks,
1548 esc(pr.checks.as_str())
1549 );
1550 if pr.failing.is_empty() {
1551 let _ = writeln!(
1552 h,
1553 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1554 w.nothing_failing
1555 );
1556 } else {
1557 h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1558 for f in &pr.failing {
1559 let _ = writeln!(h, "<li>{}</li>", esc(f));
1560 }
1561 h.push_str("</ul>\n");
1562 }
1563
1564 let _ = writeln!(
1567 h,
1568 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1569 rows.len(),
1570 w.files_changed
1571 );
1572 h.push_str(
1573 "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1574 <thead><tr>\
1575 <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1576 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1577 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1578 </th></tr></thead>\n<tbody>\n",
1579 );
1580 let mut total_added = 0u64;
1581 let mut total_removed = 0u64;
1582 for r in &rows {
1583 total_added += r.added.unwrap_or(0);
1584 total_removed += r.removed.unwrap_or(0);
1585 let cell = |n: Option<u64>| match n {
1586 Some(n) => n.to_string(),
1587 None => "bin".to_owned(),
1588 };
1589 let _ = writeln!(
1590 h,
1591 "<tr>\
1592 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1593 font-family:ui-monospace,monospace\">{}</td>\
1594 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1595 color:#0a3622\">{}</td>\
1596 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1597 color:#5c1a17\">{}</td></tr>",
1598 esc(&r.path),
1599 cell(r.added),
1600 cell(r.removed),
1601 );
1602 }
1603 let _ = writeln!(
1604 h,
1605 "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1606 <td style=\"padding:4px 2px\">total</td>\
1607 <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1608 <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1609 </tr></tfoot>\n</table>"
1610 );
1611
1612 let _ = writeln!(
1614 h,
1615 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1616 w.commits
1617 );
1618 if commits.is_empty() {
1619 h.push_str(&format!(
1620 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1621 w.no_commits
1622 ));
1623 } else {
1624 h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1625 for c in commits {
1626 let _ = writeln!(h, "<li>{}</li>", esc(c));
1627 }
1628 h.push_str("</ol>\n");
1629 }
1630 let _ = writeln!(
1631 h,
1632 "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1633 w.lands_as,
1634 esc(subject),
1635 w.lands_as_tail()
1636 );
1637
1638 let _ = writeln!(
1640 h,
1641 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1642 w.comments
1643 );
1644 if pr.review_comments.is_empty() {
1645 h.push_str(&format!(
1646 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1647 w.no_comments
1648 ));
1649 } else {
1650 for c in &pr.review_comments {
1651 let anchor = match (&c.path, c.line) {
1652 (Some(p), Some(l)) => format!("{p}:{l}"),
1653 (Some(p), None) => p.clone(),
1654 _ => "pull request thread".to_owned(),
1655 };
1656 let _ = writeln!(
1657 h,
1658 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1659 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1660 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1661 esc(&c.author),
1662 esc(&anchor),
1663 esc(&tail(&c.body, 800)),
1664 );
1665 }
1666 }
1667
1668 let total = diff.lines().count();
1670 let shown = total.min(DIFF_MAX_LINES);
1671 let _ = writeln!(
1672 h,
1673 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1674 w.diff
1675 );
1676 h.push_str(
1677 "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1678 border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1679 );
1680 for line in diff.lines().take(shown) {
1681 let (gutter, style, body) = diff_row(line);
1682 let _ = writeln!(
1683 h,
1684 "<div style=\"display:flex;{style}\">\
1685 <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1686 border-right:1px solid #d0d7de\">{gutter}</span>\
1687 <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1688 esc(body),
1689 );
1690 }
1691 h.push_str("</div>\n");
1692 if total > shown {
1693 let omitted = total - shown;
1694 let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1695 let where_ = state.winner().map_or_else(
1696 || state.repo.display().to_string(),
1697 |w| w.worktree.display().to_string(),
1698 );
1699 let _ = writeln!(
1700 h,
1701 "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1702 font-size:13px\">{}: {}</p>",
1703 w.truncated,
1704 w.truncated_note(
1705 omitted,
1706 total,
1707 shown,
1708 &esc(&where_),
1709 &esc(&state.base_branch),
1710 &esc(head),
1711 ),
1712 );
1713 }
1714
1715 h.push_str("</body>\n</html>\n");
1716 h
1717}
1718
1719fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1722 if state.config.graph.hold_contested_merge {
1723 state.contested_handoff.clone()
1724 } else {
1725 None
1726 }
1727}
1728
1729pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1737 let mut s = format!(
1738 "This is the merge approval for run {run} (`magi show {run}`). The question's \
1739 own text above names the pull request. Answering `{APPROVE}` squash-merges \
1740 it into the base branch, which cannot be undone; `{HOLD}` leaves the pull \
1741 request open. Silence is a hold: the owner not answering never merges. Only \
1742 the owner choosing `{APPROVE}`, or clearly telling you to merge in their \
1743 own words, merges. Whether their wording is a clear, unconditional instruction \
1744 is your judgement alone: if it is doubtful, conditional, retracted or a \
1745 question, do not settle - ask back with `magi ask --thread`. Doubt and \
1746 silence are a hold.\n\n\
1747 This brief is a snapshot from when you were attached: check `magi show {run}` \
1748 and `gh pr view` (read-only) before telling the owner anything current. \
1749 You run with permission to write the question record, and what keeps you \
1750 from touching anything else is this brief and your instructions - so do \
1751 not change files, branches or the pull request.",
1752 run = q.run
1753 );
1754 if let Some(target) = &q.merge_target {
1755 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));
1756 }
1757 if q.choices.iter().any(|c| c == REPAIR) {
1758 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.");
1759 }
1760 if q.detail.contains("Approval required because:") || q.detail.contains("承認が必要な理由:")
1761 {
1762 s.push_str("\n\nSaved approval evidence (at question creation):\n");
1763 s.push_str(&q.detail);
1764 } else {
1765 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.");
1766 }
1767 let Some(state) = state else {
1768 s.push_str(
1769 "\n\nThe run's record could not be read, so the pull request, the panel \
1770 summary and any contested findings are not known to you beyond the \
1771 question's own text. Say so to the owner rather than guessing.",
1772 );
1773 return s;
1774 };
1775 if let Some(pr) = &state.pr {
1776 s.push_str(&format!(
1777 "\n\nPull request #{} {} (recorded state: {}, last seen).",
1778 pr.number, pr.url, pr.state
1779 ));
1780 }
1781 s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1782 if let Some(w) = state.winner() {
1783 s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1784 }
1785
1786 s
1787}
1788
1789fn merge_consent_superseded(state: &RunState, question: &ask::Question) -> Result<bool> {
1793 if !question.status.open() && approval(question.resolution().as_deref()) != Approval::Merge {
1794 return Ok(false);
1795 }
1796 let Some(task_id) = state
1797 .origin
1798 .as_ref()
1799 .and_then(|origin| origin.task.as_ref())
1800 else {
1801 return Ok(false);
1802 };
1803 let task = crate::queue::Queue::open()
1804 .get(task_id)
1805 .context("cannot verify operator decisions for the merge approval")?;
1806 Ok(task
1807 .operator_decided_at
1808 .is_some_and(|at| at > question.answered_at.unwrap_or(question.asked_at)))
1809}
1810
1811async fn approval_gate(
1831 state: &mut RunState,
1832 pr: &PrState,
1833 subject: &str,
1834 contested: Option<&ContestedHandoff>,
1835 head: &str,
1836) -> Result<ApprovalGate> {
1837 let store = ask::Questions::open();
1838 let reusable = state.land_approval.as_ref().and_then(|a| {
1843 store
1844 .list()
1845 .into_iter()
1846 .find(|q| q.id == a.question)
1847 .filter(|q| {
1848 a.head.eq_ignore_ascii_case(head)
1849 || q.resolution()
1850 .as_deref()
1851 .is_some_and(crate::merge_decision::stops)
1852 })
1853 });
1854 let reusable = match reusable {
1855 Some(q) if merge_consent_superseded(state, &q)? => None,
1856 other => other,
1857 };
1858 if reusable.is_none() {
1859 for stale in store
1860 .list()
1861 .into_iter()
1862 .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1863 {
1864 let why =
1865 "the pull request head or operator decision changed; asked again for approval";
1866 if let Err(e) = store.update(&stale.id, |q| {
1867 q.abandon(why);
1868 Ok(())
1869 }) {
1870 tracing::warn!("could not retire the superseded approval question: {e:#}");
1871 }
1872 }
1873 }
1874
1875 let q = match reusable {
1876 Some(mut q) => {
1877 if upgrade_repair_choice(&mut q, state) {
1878 q = store
1879 .update(&q.id, |current| {
1880 upgrade_repair_choice(current, state);
1881 Ok(())
1882 })?
1883 .0;
1884 }
1885 q
1886 }
1887 None => {
1888 let worktree = match state.winner() {
1889 Some(w) => w.worktree.clone(),
1890 None => state.repo.clone(),
1891 };
1892 let head = if head.is_empty() {
1895 state
1896 .winner()
1897 .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1898 } else {
1899 head.to_owned()
1900 };
1901 let remote = &state.config.merge.remote;
1904 let tracking = format!("{remote}/{}", state.base_branch);
1905 let base = if git::rev_exists(&worktree, &tracking).await {
1906 tracking
1907 } else {
1908 String::new()
1909 };
1910 let range = format!("{base}...{head}");
1911 let numstat = if base.is_empty() {
1915 String::new()
1916 } else {
1917 git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1918 .await
1919 .map(|o| o.stdout)
1920 .unwrap_or_default()
1921 };
1922 let diff = if base.is_empty() {
1923 String::new()
1924 } else {
1925 git::diff(&worktree, &base, &head).await.unwrap_or_default()
1926 };
1927 let commits: Vec<String> = if base.is_empty() {
1928 Vec::new()
1929 } else {
1930 git::git_raw(
1931 &worktree,
1932 &[
1933 "log",
1934 "--reverse",
1935 "--format=%s",
1936 &format!("{base}..{head}"),
1937 ],
1938 )
1939 .await
1940 .map(|o| o.stdout)
1941 .unwrap_or_default()
1942 .lines()
1943 .filter(|l| !l.trim().is_empty())
1944 .map(str::to_owned)
1945 .collect()
1946 };
1947
1948 let w = words(&state.config.graph.language);
1949 let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1950 let mut fresh = ask::Question::new(
1951 state.id.clone(),
1952 APPROVAL_NODE.to_owned(),
1953 "land".to_owned(),
1954 w.approval_summary(pr.number, subject),
1955 format!(
1956 "{}\n\n{}",
1957 approval_evidence(state),
1958 w.approval_detail(&pr.url, &state.base_branch, subject, contested)
1959 ),
1960 approval_choices(state),
1961 );
1962 fresh.merge_target = Some(crate::merge_decision::Target {
1963 pr: pr.url.clone(),
1964 head: head.clone(),
1965 language: state.config.graph.language.clone(),
1966 });
1967 if fresh.choices.iter().any(|c| c == REPAIR) {
1968 fresh.actions.insert(
1969 REPAIR.to_owned(),
1970 ask::ChoiceAction::Resume {
1971 run: state.id.clone(),
1972 },
1973 );
1974 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.");
1975 }
1976 for choice in fresh.choices.clone() {
1977 if crate::merge_decision::stops(&choice)
1978 && choice != HOLD
1979 && choice != crate::merge_decision::DISMISS
1980 {
1981 fresh.actions.insert(
1982 choice,
1983 ask::ChoiceAction::Resume {
1984 run: state.id.clone(),
1985 },
1986 );
1987 }
1988 }
1989 store
1990 .put_panel(&mut fresh, &html, &[])
1991 .context("write the merge approval panel")?;
1992 store
1993 .put_executable(&mut fresh)
1994 .context("file the merge approval question")?;
1995 state.land_approval = Some(LandApproval {
1996 question: fresh.id.clone(),
1997 head: head.clone(),
1998 });
1999 state.event(
2000 "land",
2001 format!("asking for merge approval ({})", fresh.short()),
2002 );
2003 state.save()?;
2004 if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
2005 tracing::warn!(
2009 "could not notify about merge approval question {}: {e:#} - \
2010 the web UI is the only surface for it now",
2011 fresh.short()
2012 );
2013 }
2014 fresh
2015 }
2016 };
2017
2018 Ok(match q.status {
2019 ask::QuestionStatus::Open => ApprovalGate::Pending,
2020 ask::QuestionStatus::Abandoned => ApprovalGate::Held,
2024 ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
2028 Approval::Merge => ApprovalGate::Approved,
2029 Approval::Repair if !state.reviews.is_empty() => {
2030 if !state.merge_repairs.iter().any(|r| r.question == q.id) {
2031 state.merge_repairs.push(crate::run::MergeRepair {
2032 question: q.id.clone(),
2033 head: head.to_owned(),
2034 pr: pr.url.clone(),
2035 reviews: state.reviews.clone(),
2036 gate: state.gate.clone(),
2037 gate_head: state.gate_head.clone(),
2038 gate_fixes: state.gate_fixes.clone(),
2039 pre_gate: state.pre_gate.clone(),
2040 phase: crate::run::MergeRepairPhase::Requested,
2041 failure: None,
2042 });
2043 state.land_approval = None;
2044 state.event(
2045 "repair",
2046 "owner requested fix findings and re-review; merge is not approved",
2047 );
2048 state.save()?;
2049 }
2050 ApprovalGate::Repair
2051 }
2052 Approval::Repair | Approval::Hold => ApprovalGate::Held,
2053 },
2054 })
2055}
2056
2057fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
2060 let mut failing = Vec::new();
2061 let mut pending = false;
2062 let mut unknown = false;
2063 for check in rollup {
2064 match check.verdict() {
2065 Verdict::Pass => {}
2066 Verdict::Pending => pending = true,
2067 Verdict::Fail => failing.push(check.label()),
2068 Verdict::Unknown => unknown = true,
2069 }
2070 }
2071 let checks = if rollup.is_empty() {
2072 Checks::Unknown
2073 } else if pending {
2074 Checks::Pending
2075 } else if !failing.is_empty() {
2076 Checks::Red
2077 } else if unknown {
2078 Checks::Unknown
2079 } else {
2080 Checks::Green
2081 };
2082 (checks, failing)
2083}
2084
2085pub fn parse_pr(json: &str) -> Result<PrState> {
2088 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2089 let state = match raw.state.to_ascii_uppercase().as_str() {
2090 "OPEN" => PrLifecycle::Open,
2091 "MERGED" => PrLifecycle::Merged,
2092 "CLOSED" => PrLifecycle::Closed,
2093 other => bail!("unknown pull request state `{other}`"),
2094 };
2095
2096 let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
2097
2098 let mut review_comments = Vec::new();
2099 for r in raw.reviews {
2100 push_if_outstanding(
2101 &mut review_comments,
2102 ReviewComment {
2103 author: r.author.login,
2104 path: None,
2105 line: None,
2106 body: r.body,
2107 },
2108 );
2109 }
2110 for c in raw.comments {
2111 push_if_outstanding(
2112 &mut review_comments,
2113 ReviewComment {
2114 author: c.author.login,
2115 path: None,
2116 line: None,
2117 body: c.body,
2118 },
2119 );
2120 }
2121
2122 Ok(PrState {
2123 url: raw.url,
2124 number: raw.number,
2125 state,
2126 checks,
2127 failing,
2128 review_comments,
2129 blocking: Blocking::of(&raw.merge_state_status),
2130 })
2131}
2132
2133#[derive(Debug, Clone, PartialEq, Eq)]
2135pub(crate) struct CheckView {
2136 pub name: String,
2137 pub verdict: Verdict,
2138 pub run: Option<String>,
2140 pub url: Option<String>,
2141}
2142
2143#[derive(Debug, Clone, PartialEq, Eq)]
2147pub(crate) struct RollupView {
2148 pub url: String,
2149 pub number: u64,
2150 pub state: PrLifecycle,
2151 pub head: String,
2152 pub checks: Vec<CheckView>,
2153}
2154
2155pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
2158 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2159 let state = match raw.state.to_ascii_uppercase().as_str() {
2160 "OPEN" => PrLifecycle::Open,
2161 "MERGED" => PrLifecycle::Merged,
2162 "CLOSED" => PrLifecycle::Closed,
2163 other => bail!("unknown pull request state `{other}`"),
2164 };
2165 let checks = raw
2166 .status_check_rollup
2167 .iter()
2168 .map(|c| CheckView {
2169 name: c.label(),
2170 verdict: c.verdict(),
2171 run: c.url().and_then(run_of),
2172 url: c.url().map(str::to_owned),
2173 })
2174 .collect();
2175 Ok(RollupView {
2176 url: raw.url,
2177 number: raw.number,
2178 state,
2179 head: raw.head_ref_oid,
2180 checks,
2181 })
2182}
2183
2184pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
2194 let view = gh(
2195 repo,
2196 &[
2197 "pr".to_owned(),
2198 "view".to_owned(),
2199 pr_url.to_owned(),
2200 "--json".to_owned(),
2201 "state".to_owned(),
2202 ],
2203 )
2204 .await?;
2205 if !view.0 {
2206 bail!("gh pr view {pr_url}: {}", view.1);
2207 }
2208 Ok(parse_pr(&view.1)?.state)
2212}
2213
2214#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2218pub struct ExternalMerge {
2219 pub url: String,
2221 pub number: u64,
2223}
2224
2225#[derive(Debug, Deserialize)]
2226#[serde(rename_all = "camelCase")]
2227struct GhMergedPr {
2228 url: String,
2229 number: u64,
2230 merged_at: String,
2231 base_ref_name: String,
2232}
2233
2234fn pick_merged_pr(
2248 json: &str,
2249 base_branch: &str,
2250 created_at: Timestamp,
2251) -> Result<Option<ExternalMerge>> {
2252 let raw: Vec<GhMergedPr> =
2253 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2254 let mut matches: Vec<ExternalMerge> = Vec::new();
2255 for pr in raw {
2256 if pr.base_ref_name != base_branch {
2257 continue;
2258 }
2259 let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
2260 continue;
2261 };
2262 if merged_at < created_at {
2263 continue;
2264 }
2265 matches.push(ExternalMerge {
2266 url: pr.url,
2267 number: pr.number,
2268 });
2269 }
2270 if matches.len() == 1 {
2271 Ok(matches.pop())
2272 } else {
2273 Ok(None)
2274 }
2275}
2276
2277#[derive(Debug, Clone, PartialEq, Eq)]
2279pub enum OpenPr {
2280 None,
2282 One {
2284 url: String,
2286 title: String,
2288 },
2289 Many(Vec<String>),
2291}
2292
2293#[derive(Debug, Deserialize)]
2294#[serde(rename_all = "camelCase")]
2295struct GhOpenPr {
2296 url: String,
2300 #[serde(default)]
2301 title: String,
2302 base_ref_name: String,
2303 #[serde(default)]
2306 is_cross_repository: bool,
2307}
2308
2309pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
2315 let raw: Vec<GhOpenPr> =
2316 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2317 let mut hits: Vec<GhOpenPr> = raw
2318 .into_iter()
2319 .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
2320 .collect();
2321 Ok(match hits.len() {
2322 0 => OpenPr::None,
2323 1 => {
2324 let p = hits.remove(0);
2325 OpenPr::One {
2326 url: p.url,
2327 title: p.title,
2328 }
2329 }
2330 _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
2331 })
2332}
2333
2334pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
2338 let (ok, out) = gh(
2339 repo,
2340 &[
2341 "pr".to_owned(),
2342 "list".to_owned(),
2343 "--head".to_owned(),
2344 branch.to_owned(),
2345 "--base".to_owned(),
2346 base.to_owned(),
2347 "--state".to_owned(),
2348 "open".to_owned(),
2349 "--json".to_owned(),
2350 "number,url,title,baseRefName,isCrossRepository".to_owned(),
2351 ],
2352 )
2353 .await?;
2354 if !ok {
2355 bail!("gh pr list failed: {out}");
2356 }
2357 pick_open_pr(&out, base)
2358}
2359
2360#[derive(Debug, Deserialize)]
2361#[serde(rename_all = "camelCase")]
2362struct GhPrHead {
2363 head_ref_name: String,
2364 base_ref_name: String,
2365 state: String,
2366 is_cross_repository: bool,
2369 head_ref_oid: String,
2372}
2373
2374#[derive(Debug, Clone, PartialEq, Eq)]
2376pub struct Refusal {
2377 pub retry: bool,
2380 pub why: String,
2382}
2383
2384impl Refusal {
2385 fn final_(why: String) -> Self {
2386 Self { retry: false, why }
2387 }
2388}
2389
2390pub fn closable(
2399 json: &str,
2400 branch: &str,
2401 base: &str,
2402 verified: &[String],
2403) -> std::result::Result<(), Refusal> {
2404 let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
2405 retry: true,
2406 why: format!("could not read the pull request ({e})"),
2407 })?;
2408 if pr.head_ref_name != branch {
2409 return Err(Refusal::final_(format!(
2410 "its head is `{}`, not this run's `{branch}`",
2411 pr.head_ref_name
2412 )));
2413 }
2414 if pr.is_cross_repository {
2415 return Err(Refusal::final_("its head lives in a fork".to_owned()));
2416 }
2417 if pr.base_ref_name != base {
2418 return Err(Refusal::final_(format!(
2419 "it targets `{}`, not `{base}`",
2420 pr.base_ref_name
2421 )));
2422 }
2423 if !pr.state.eq_ignore_ascii_case("open") {
2424 return Err(Refusal::final_(format!(
2425 "it is already {}",
2426 pr.state.to_ascii_lowercase()
2427 )));
2428 }
2429 if !verified.contains(&pr.head_ref_oid) {
2433 return Err(Refusal {
2434 retry: true,
2435 why: format!(
2436 "its head {} is not a commit this run checked against the base",
2437 crate::already::short_sha(&pr.head_ref_oid)
2438 ),
2439 });
2440 }
2441 Ok(())
2442}
2443
2444async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2449 let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2450 return false;
2451 };
2452 is_forge_url(url.trim())
2453}
2454
2455fn is_forge_url(url: &str) -> bool {
2456 url.contains("://") && !url.starts_with("file://")
2457 || url
2458 .split_once(':')
2459 .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2460}
2461
2462fn forge_unavailable(message: &str) -> bool {
2465 message.contains("known GitHub host") || message.contains("spawn gh")
2466}
2467
2468pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2471 let how = match evidence.proof {
2472 crate::already::Proof::PatchId => format!(
2473 "carried by commit {} on `{base}` with the same patch",
2474 evidence.names()
2475 ),
2476 crate::already::Proof::Ancestry => {
2477 format!("already in the history of `{base}` as {}", evidence.names())
2478 }
2479 crate::already::Proof::Tree => format!(
2480 "already part of `{base}` (merging this branch changes nothing at {})",
2481 crate::already::short_sha(&evidence.tip)
2482 ),
2483 };
2484 format!(
2485 "Closing: everything this branch adds is {how}, so there is nothing left to \
2486 land. This pull request was closed automatically after that was verified; \
2487 reopen it if you disagree."
2488 )
2489}
2490
2491pub async fn close_superseded_pr(
2503 state: &mut RunState,
2504 branch: &str,
2505 evidence: &crate::already::Evidence,
2506 verified: &[String],
2507) -> Result<std::result::Result<String, String>> {
2508 let repo = state.repo.clone();
2509 let base = state.base_branch.clone();
2510 let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2511 Some(p) => p.url.clone(),
2512 None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2517 return Ok(Err(
2518 "the remote is not a forge, so there is no pull request".to_owned(),
2519 ));
2520 }
2521 None => match find_open_pr(&repo, branch, &base).await {
2522 Err(e) if forge_unavailable(&format!("{e:#}")) => {
2523 return Ok(Err(format!("no forge to ask: {e:#}")));
2524 }
2525 Err(e) => return Err(e),
2526 Ok(OpenPr::One { url, .. }) => url,
2527 Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2528 Ok(OpenPr::Many(urls)) => {
2529 return Ok(Err(format!(
2530 "{} open pull requests name it; not choosing between them",
2531 urls.len()
2532 )));
2533 }
2534 },
2535 };
2536 let (ok, view) = gh(
2537 &repo,
2538 &[
2539 "pr".to_owned(),
2540 "view".to_owned(),
2541 url.clone(),
2542 "--json".to_owned(),
2543 "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2544 ],
2545 )
2546 .await?;
2547 if !ok {
2548 bail!("gh pr view {url} failed: {view}");
2549 }
2550 if let Err(refusal) = closable(&view, branch, &base, verified) {
2551 if refusal.retry {
2554 bail!("left {url} open: {}", refusal.why);
2555 }
2556 return Ok(Err(format!("left {url} open: {}", refusal.why)));
2557 }
2558 let (ok, out) = gh(
2559 &repo,
2560 &[
2561 "pr".to_owned(),
2562 "close".to_owned(),
2563 url.clone(),
2564 "--comment".to_owned(),
2565 superseded_comment(&base, evidence),
2566 ],
2567 )
2568 .await?;
2569 if !ok {
2570 bail!("gh pr close {url} failed: {out}");
2571 }
2572 if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2573 p.state = "closed".to_owned();
2574 }
2575 Ok(Ok(url))
2576}
2577
2578pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2582 let (title, _) = crate::github_text::prepare(state, title, "");
2583 edit_pr_title(repo, url, &title).await
2584}
2585
2586pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2590 edit_pr_title(repo, url, title).await
2591}
2592
2593pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2596 let (ok, _) = gh(
2597 repo,
2598 &[
2599 "pr".to_owned(),
2600 "edit".to_owned(),
2601 url.to_owned(),
2602 "--title".to_owned(),
2603 title.to_owned(),
2604 "--body".to_owned(),
2605 body.to_owned(),
2606 ],
2607 )
2608 .await
2609 .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2610 if !ok {
2611 bail!("gh pr edit failed while posting the chosen rewrite");
2613 }
2614 Ok(())
2615}
2616
2617async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2618 let (ok, out) = gh(
2619 repo,
2620 &[
2621 "pr".to_owned(),
2622 "edit".to_owned(),
2623 url.to_owned(),
2624 "--title".to_owned(),
2625 title.to_owned(),
2626 ],
2627 )
2628 .await?;
2629 if !ok {
2630 bail!("gh pr edit failed: {out}");
2631 }
2632 Ok(())
2633}
2634
2635pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2648 let Some(winner) = state.winner() else {
2649 return Ok(None);
2650 };
2651 let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2652 let out = gh(
2653 &state.repo,
2654 &[
2655 "pr".to_owned(),
2656 "list".to_owned(),
2657 "--head".to_owned(),
2658 branch.clone(),
2659 "--state".to_owned(),
2660 "merged".to_owned(),
2661 "--json".to_owned(),
2662 "url,number,mergedAt,baseRefName".to_owned(),
2663 ],
2664 )
2665 .await?;
2666 if !out.0 {
2667 bail!("gh pr list --head {branch}: {}", out.1);
2668 }
2669 pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2670}
2671
2672pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2684 let out = tokio::process::Command::new("git")
2685 .args(["merge-base", "--is-ancestor", branch, base_branch])
2686 .current_dir(repo)
2687 .quiet()
2688 .stdin(std::process::Stdio::null())
2689 .output()
2690 .await
2691 .context("spawn git merge-base --is-ancestor")?;
2692 Ok(out.status.success())
2693}
2694
2695fn forge_slug(url: &str) -> Option<(String, &str)> {
2703 let rest = url.rsplit("://").next()?;
2704 let (host, path) = rest.split_once('/')?;
2705 if host.is_empty() {
2706 return None;
2707 }
2708 Some((host.to_ascii_lowercase(), path))
2709}
2710
2711pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2719 let (host, path) = forge_slug(url)?;
2720 let mut segments = path.split('/');
2721 let owner = segments.next()?;
2722 let repo = segments.next()?;
2723 let kind = segments.next()?;
2724 if owner.is_empty() || repo.is_empty() || kind != "pull" {
2725 return None;
2726 }
2727 Some(format!("{host}/{owner}/{repo}"))
2728}
2729
2730fn slug_of_repo_url(url: &str) -> Option<String> {
2734 let (host, path) = forge_slug(url)?;
2735 let mut segments = path.split('/');
2736 let owner = segments.next()?;
2737 let repo = segments.next()?;
2738 if owner.is_empty() || repo.is_empty() {
2739 return None;
2740 }
2741 Some(format!("{host}/{owner}/{repo}"))
2742}
2743
2744pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2765 if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2766 return Ok(());
2767 }
2768 bail!(
2769 "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2770 request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2771 actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2772 verify by hand before treating this as a false positive)"
2773 );
2774}
2775
2776async fn repo_slug(repo: &Path) -> Result<String> {
2787 let out = gh(
2788 repo,
2789 &[
2790 "repo".to_owned(),
2791 "view".to_owned(),
2792 "--json".to_owned(),
2793 "url".to_owned(),
2794 ],
2795 )
2796 .await?;
2797 if !out.0 {
2798 bail!("gh repo view --json url: {}", out.1);
2799 }
2800 #[derive(Debug, Deserialize)]
2801 struct GhRepo {
2802 url: String,
2803 }
2804 let parsed: GhRepo = serde_json::from_str(&out.1)
2805 .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2806 slug_of_repo_url(&parsed.url)
2807 .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2808}
2809
2810pub async fn correct_manual_merge(
2858 state: &mut RunState,
2859 url: &str,
2860) -> Result<(RunStatus, RunStatus)> {
2861 let Some(pr_slug) = slug_of_pr_url(url) else {
2862 bail!(
2863 "could not parse an owner/repo out of {url}; refusing to guess which repository \
2864 this pull request belongs to"
2865 );
2866 };
2867 let run_slug = repo_slug(&state.repo).await?;
2868 ensure_same_repo(&run_slug, &pr_slug)?;
2869 correct_merge(state, url).await
2870}
2871
2872pub(crate) async fn correct_confirmed_external_merge(
2883 state: &mut RunState,
2884 url: &str,
2885) -> Result<(RunStatus, RunStatus)> {
2886 correct_merge(state, url).await
2887}
2888
2889fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2891 let Some(pr) = a.pr.as_ref() else {
2892 return false;
2893 };
2894 if !url.is_empty()
2895 && pr
2896 .url
2897 .trim_end_matches('/')
2898 .eq_ignore_ascii_case(url.trim_end_matches('/'))
2899 {
2900 return true;
2901 }
2902 number > 0
2903 && pr.number == number
2904 && match (a.repo.canonicalize(), repo.canonicalize()) {
2905 (Ok(x), Ok(y)) => x == y,
2906 _ => a.repo == repo,
2907 }
2908}
2909
2910fn rewrite_open_prs(
2924 home: &Path,
2925 decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2926) -> usize {
2927 let now = Timestamp::now();
2928 let mut changed = 0;
2929 for id in crate::run::list_ids_in(&home.join("runs")) {
2930 let Ok(_owner) = crate::pr_head::owner_lock(home, &id) else {
2931 continue;
2932 };
2933 let path = home.join("runs").join(&id).join("run.json");
2934 let Ok(body) = std::fs::read_to_string(&path) else {
2935 continue;
2936 };
2937 let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2938 continue;
2939 };
2940 if !state.status.done()
2941 || state.pr.as_ref().is_none_or(|p| p.state != "open")
2942 || state.liveness_under(home, crate::daemon::is_working_on(home, &id, now))
2943 != crate::run::Liveness::Dead
2944 {
2945 continue;
2946 }
2947 let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2948 continue;
2949 };
2950 if let Some(pr) = state.pr.as_mut() {
2951 pr.state = to.as_str().to_owned();
2952 }
2953 let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2954 state.event(
2955 "land",
2956 format!("recorded {url} as {}: another run settled it", to.as_str()),
2957 );
2958 match state.save_under(home) {
2959 Ok(()) => changed += 1,
2960 Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2961 }
2962 }
2963 changed
2964}
2965
2966pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2971 if to == PrLifecycle::Open {
2972 return;
2973 }
2974 let Some(home) = crate::run::try_home() else {
2975 return;
2976 };
2977 write_pr_state_through_in(&home, run, to);
2978}
2979
2980pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2981 let Some(pr) = run.pr.as_ref() else {
2982 return 0;
2983 };
2984 let (url, number) = (pr.url.clone(), pr.number);
2985 rewrite_open_prs(home, &mut |other| {
2986 (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2987 })
2988}
2989
2990pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2993 let now = Timestamp::now();
2994 let mut out = Vec::new();
2995 for id in crate::run::list_ids_in(&home.join("runs")) {
2996 let path = home.join("runs").join(&id).join("run.json");
2997 let Ok(body) = std::fs::read_to_string(&path) else {
2998 continue;
2999 };
3000 let Ok(state) = serde_json::from_str::<RunState>(&body) else {
3001 continue;
3002 };
3003 if let Some(pr) = state.pr.as_ref()
3004 && state.status.done()
3005 && pr.state == "open"
3006 && !pr.url.is_empty()
3007 && !crate::daemon::is_working_on(home, &id, now)
3008 {
3009 out.push((id, state.repo.clone(), pr.url.clone()));
3010 }
3011 }
3012 out
3013}
3014
3015pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
3018 rewrite_open_prs(home, &mut |s| {
3019 s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
3020 })
3021}
3022
3023pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
3030 let mut known = BTreeMap::new();
3031 let mut failed = 0;
3032 let mut seen = BTreeSet::new();
3033 for (_, repo, url) in stale_open_prs(home) {
3034 if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
3035 continue;
3036 }
3037 match lifecycle(&repo, &url).await {
3038 Ok(state) => {
3039 known.insert(url, state);
3040 }
3041 Err(e) => {
3042 tracing::warn!("repair pr state of {url}: {e:#}");
3043 failed += 1;
3044 }
3045 }
3046 }
3047 (apply_pr_states(home, &known), failed)
3048}
3049
3050async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
3051 match lifecycle(&state.repo, url).await? {
3052 PrLifecycle::Merged => {}
3053 other => bail!(
3054 "{url} is {}, not merged; refusing to record {} as merged on a guess",
3055 other.as_str(),
3056 state.id
3057 ),
3058 }
3059 let before = state.status;
3060 if let Err(e) = land(state, url).await {
3061 state.status = RunStatus::Blocked;
3068 state.event("fold", format!("manual-merge correction failed: {e:#}"));
3069 state.save()?;
3070 return Err(e).context(format!("confirming the merge of {url}"));
3071 }
3072 state.event(
3073 "fold",
3074 "operator recorded this pull request as a manual merge; this run never \
3075 re-entered `land`, so `bump::after_merge` did not run for it - a release \
3076 bump this change might warrant has to be filed by hand",
3077 );
3078 if state.status == RunStatus::Merged {
3081 crate::followup::after_merge(state, url).await;
3082 }
3083 state.save()?;
3084 Ok((before, state.status))
3085}
3086
3087pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
3094 let raw: Vec<GhInline> =
3095 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
3096 let mut out = Vec::new();
3097 for c in raw {
3098 push_if_outstanding(
3099 &mut out,
3100 ReviewComment {
3101 author: c.user.login,
3102 path: c.path,
3103 line: c.line,
3104 body: c.body,
3105 },
3106 );
3107 }
3108 Ok(out)
3109}
3110
3111fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
3117 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
3118 return;
3119 }
3120 if comment.path.is_none() && is_noise(&comment.body) {
3121 return;
3122 }
3123 out.push(comment);
3124}
3125
3126pub fn is_noise(body: &str) -> bool {
3144 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
3145 return true;
3146 }
3147 let mut content = false;
3148 for line in strip_blocks(body).lines() {
3149 let line = unquote(line);
3150 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
3151 continue;
3152 }
3153 content = true;
3154 break;
3155 }
3156 !content
3157}
3158
3159fn strip_blocks(body: &str) -> String {
3161 let mut out = String::with_capacity(body.len());
3162 let mut rest = body;
3163 loop {
3164 let open = ["<!--", "<details>"]
3165 .iter()
3166 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
3167 .min_by_key(|(i, _)| *i);
3168 let Some((at, tag)) = open else {
3169 out.push_str(rest);
3170 return out;
3171 };
3172 out.push_str(&rest[..at]);
3173 let after = &rest[at + tag.len()..];
3174 let close = if tag == "<!--" { "-->" } else { "</details>" };
3175 match after.find(close) {
3176 Some(end) => rest = &after[end + close.len()..],
3177 None => return out,
3179 }
3180 }
3181}
3182
3183fn unquote(line: &str) -> &str {
3185 let mut s = line.trim();
3186 while let Some(rest) = s.strip_prefix('>') {
3187 s = rest.trim_start();
3188 }
3189 s.trim()
3190}
3191
3192fn is_checklist(line: &str) -> bool {
3194 let rest = line
3195 .strip_prefix("- ")
3196 .or_else(|| line.strip_prefix("* "))
3197 .unwrap_or("");
3198 let rest = rest.trim_start();
3199 matches!(
3200 rest.get(..3),
3201 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
3202 )
3203}
3204
3205fn is_decoration(line: &str) -> bool {
3207 line.starts_with('#')
3208 || line.starts_with("[!")
3209 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
3210}
3211
3212fn is_banner(line: &str) -> bool {
3219 let plain = drop_spans(line, "**", "**");
3220 let plain = if plain.contains("](") {
3221 drop_spans(&plain, "[", ")")
3222 } else {
3223 plain
3224 };
3225 !plain.chars().any(char::is_alphanumeric)
3226}
3227
3228fn drop_spans(s: &str, open: &str, close: &str) -> String {
3232 let mut out = String::with_capacity(s.len());
3233 let mut rest = s;
3234 while let Some(at) = rest.find(open) {
3235 out.push_str(&rest[..at]);
3236 let after = &rest[at + open.len()..];
3237 match after.find(close) {
3238 Some(end) => rest = &after[end + close.len()..],
3239 None => return out,
3240 }
3241 }
3242 out.push_str(rest);
3243 out
3244}
3245
3246fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
3264 static LOCKS: std::sync::LazyLock<
3265 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
3266 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
3267 LOCKS
3268 .lock()
3269 .unwrap_or_else(std::sync::PoisonError::into_inner)
3270 .entry(repo.to_path_buf())
3271 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
3272 .clone()
3273}
3274
3275fn repo_label(repo: &Path, pr_url: &str) -> String {
3278 let parts: Vec<&str> = pr_url.split('/').collect();
3279 if let Some(at) = parts.iter().rposition(|p| *p == "pull")
3280 && at >= 2
3281 && !parts[at - 1].is_empty()
3282 && !parts[at - 2].is_empty()
3283 {
3284 return format!("{}/{}", parts[at - 2], parts[at - 1]);
3285 }
3286 repo.file_name()
3287 .map(|n| n.to_string_lossy().into_owned())
3288 .unwrap_or_default()
3289}
3290
3291fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
3295 (pr.checks == Checks::Red).then(|| {
3296 let details = crate::notices::format_list(&pr.failing)
3297 .map(|names| format!(": {names}"))
3298 .unwrap_or_default();
3299 format!(
3300 "Merged {repo_name} PR #{} with red checks{details} ({})",
3301 pr.number, pr.url
3302 )
3303 })
3304}
3305
3306async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
3310 let repo_name = repo_label(&state.repo, &pr.url);
3311 let Some(summary) = red_merge_summary(&repo_name, pr) else {
3312 return;
3313 };
3314 if let Some(rec) = state.pr.as_mut() {
3315 rec.red_at_merge = pr.failing.clone();
3316 }
3317 state.event("land", summary.clone());
3318 crate::notices::raise_with(
3321 crate::notices::merged_red(&state.id, &summary),
3322 &state.config.notify,
3323 );
3324}
3325
3326pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
3333 land_with(state, pr_url, &GhForge).await
3334}
3335
3336trait Forge {
3340 async fn close(&self, repo: &Path, pr_url: &str, comment: &str) -> Result<()> {
3341 let (ok, out) = gh(
3342 repo,
3343 &[
3344 "pr".into(),
3345 "close".into(),
3346 pr_url.into(),
3347 "--comment".into(),
3348 comment.into(),
3349 ],
3350 )
3351 .await?;
3352 if !ok {
3353 bail!("PR closure failed: {out}");
3354 }
3355 Ok(())
3356 }
3357 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
3358 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
3359 async fn poll(&self);
3360 async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
3362 fn now(&self) -> i64;
3364 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3367 #[allow(clippy::too_many_arguments)]
3368 async fn fix(
3369 &self,
3370 state: &mut RunState,
3371 pr: &PrState,
3372 round: usize,
3373 budget: usize,
3374 reason: &str,
3375 logs: &str,
3376 ) -> Result<Fixed>;
3377}
3378
3379struct GhForge;
3380
3381impl Forge for GhForge {
3382 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3383 observe(repo, pr_url).await
3384 }
3385 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3386 gh(repo, argv).await
3387 }
3388 async fn poll(&self) {
3389 tokio::time::sleep(POLL).await;
3390 }
3391 async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3392 let args = [
3393 "run".to_owned(),
3394 "rerun".to_owned(),
3395 run.to_owned(),
3396 "--failed".to_owned(),
3397 ];
3398 let (ok, out) = gh(repo, &args).await?;
3399 if !ok {
3400 bail!("gh run rerun {run}: {out}");
3401 }
3402 Ok(())
3403 }
3404 fn now(&self) -> i64 {
3405 jiff::Timestamp::now().as_second()
3406 }
3407 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3408 required_contexts_of(repo, base).await
3409 }
3410 async fn fix(
3411 &self,
3412 state: &mut RunState,
3413 pr: &PrState,
3414 round: usize,
3415 budget: usize,
3416 reason: &str,
3417 logs: &str,
3418 ) -> Result<Fixed> {
3419 fix_round(state, pr, round, budget, reason, logs).await
3420 }
3421}
3422
3423fn encode_path_segment(s: &str) -> String {
3425 let mut out = String::new();
3426 for b in s.bytes() {
3427 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3428 out.push(b as char);
3429 } else {
3430 let _ = write!(out, "%{b:02X}");
3431 }
3432 }
3433 out
3434}
3435
3436async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3441 let enc = encode_path_segment(base);
3442 let mut all = BTreeSet::new();
3443 let classic = gh(
3446 repo,
3447 &[
3448 "api".to_owned(),
3449 format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3450 ],
3451 )
3452 .await
3453 .ok()?;
3454 if classic.0 {
3455 all.extend(parse_classic_required(&classic.1)?);
3456 } else if !classic.1.contains("404") {
3457 return None;
3458 }
3459 let rules = gh(
3460 repo,
3461 &[
3462 "api".to_owned(),
3463 format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3464 ],
3465 )
3466 .await
3467 .ok()?;
3468 if !rules.0 {
3469 return None;
3470 }
3471 all.extend(parse_ruleset_required(&rules.1)?);
3472 Some(all)
3473}
3474
3475fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3478 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3479 let mut out = BTreeSet::new();
3480 for c in v.get("contexts")?.as_array()? {
3481 out.insert(c.as_str()?.to_owned());
3482 }
3483 for c in v
3484 .get("checks")
3485 .and_then(|c| c.as_array())
3486 .into_iter()
3487 .flatten()
3488 {
3489 if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3490 out.insert(name.to_owned());
3491 }
3492 }
3493 Some(out)
3494}
3495
3496fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3498 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3499 let mut out = BTreeSet::new();
3500 for rule in v.as_array()? {
3501 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3502 continue;
3503 }
3504 let checks = rule
3505 .pointer("/parameters/required_status_checks")?
3506 .as_array()?;
3507 for c in checks {
3508 out.insert(c.get("context")?.as_str()?.to_owned());
3509 }
3510 }
3511 Some(out)
3512}
3513
3514fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3522 awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3523}
3524
3525fn bound_head<'a>(
3535 seen_head: &'a str,
3536 rollup_head: &str,
3537 awaiting: Option<&str>,
3538) -> Option<&'a str> {
3539 if seen_head.is_empty()
3540 || awaiting_new_head(awaiting, seen_head)
3541 || !rollup_head.eq_ignore_ascii_case(seen_head)
3542 {
3543 return None;
3544 }
3545 Some(seen_head)
3546}
3547
3548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3550enum Refused {
3551 Pending,
3553 Recheck,
3557 Final,
3560}
3561
3562fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3565 let Some(after) = after else {
3566 return Refused::Pending;
3568 };
3569 if after.pr.state != PrLifecycle::Open {
3570 return Refused::Final;
3571 }
3572 if !after.head.eq_ignore_ascii_case(observed_head) {
3575 return Refused::Pending;
3576 }
3577 if bound_head(&after.head, &after.rollup_head, None).is_none() {
3579 return Refused::Pending;
3580 }
3581 let state = after.merge_state.to_ascii_uppercase();
3582 if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3583 || state.is_empty()
3584 || state == "UNKNOWN"
3585 {
3586 return Refused::Pending;
3587 }
3588 if rechecked {
3589 Refused::Final
3590 } else {
3591 Refused::Recheck
3592 }
3593}
3594
3595async fn disarm<F: Forge>(
3600 forge: &F,
3601 state: &mut RunState,
3602 repo: &Path,
3603 number: u64,
3604) -> std::result::Result<(), String> {
3605 let argv = disable_automerge_argv(number);
3606 let out = {
3607 let merge_lock = repo_merge_lock(repo);
3608 let _merge_slot = merge_lock.lock().await;
3609 forge.merge(repo, &argv).await
3610 };
3611 match out {
3612 Ok((true, _)) => {
3613 state.land_armed_head = None;
3614 state.event("land", "auto-merge disabled");
3615 state.save().map_err(|e| format!("{e:#}"))?;
3616 Ok(())
3617 }
3618 Ok((false, msg)) => Err(msg),
3619 Err(e) => Err(format!("{e:#}")),
3620 }
3621}
3622
3623async fn stop_disarmed<F: Forge>(
3627 forge: &F,
3628 state: &mut RunState,
3629 repo: &Path,
3630 pr: &PrState,
3631 why: &str,
3632) -> Result<()> {
3633 if state.land_armed_head.is_none() {
3634 return stop(state, repo, pr, why).await;
3635 }
3636 match disarm(forge, state, repo, pr.number).await {
3637 Ok(()) => stop(state, repo, pr, why).await,
3638 Err(e) => {
3639 let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3640 stop(state, repo, pr, &why).await
3641 }
3642 }
3643}
3644
3645fn owner_requested_stop(state: &RunState) -> Result<bool> {
3647 let Some(binding) = &state.land_approval else {
3648 return Ok(false);
3649 };
3650 let q = ask::Questions::open().get(&binding.question)?;
3651 Ok(q.run == state.id
3652 && q.node == APPROVAL_NODE
3653 && q.resolution()
3654 .as_deref()
3655 .is_some_and(crate::merge_decision::stops))
3656}
3657
3658async fn owner_stop<F: Forge>(state: &mut RunState, pr: &mut PrState, forge: &F) -> Result<bool> {
3661 use crate::merge_decision as md;
3662 let Some(binding) = &state.land_approval else {
3663 return Ok(false);
3664 };
3665 let q = ask::Questions::open().get(&binding.question)?;
3666 let Some(choice) = q.resolution().filter(|c| md::stops(c)) else {
3667 return Ok(false);
3668 };
3669 if q.run != state.id || q.node != APPROVAL_NODE {
3670 bail!("stop decision names another run");
3671 }
3672 let target = q.merge_target.clone().unwrap_or_else(|| md::Target {
3673 pr: pr.url.clone(),
3674 head: binding.head.clone(),
3675 language: state.config.graph.language.clone(),
3676 });
3677 if target.pr != pr.url {
3678 bail!("stop decision names another PR");
3679 }
3680 if state.merge_decision.is_none() {
3681 state.merge_decision = Some(md::Decision {
3682 question: q.id.clone(),
3683 target,
3684 choice: choice.clone(),
3685 closed: false,
3686 replacement_run: None,
3687 replacement_pr: None,
3688 });
3689 state.save()?;
3690 }
3691 let repo = state.repo.clone();
3692 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3694 stop(state, &repo, pr, &format!("owner stop requested, but auto-merge could not be disabled: {e}; no repair or replacement started")).await?;
3695 return Ok(true);
3696 }
3697 if choice.eq_ignore_ascii_case(HOLD) {
3698 stop(
3699 state,
3700 &repo,
3701 pr,
3702 "owner held the decision: PR left open; no further repair or merge work",
3703 )
3704 .await?;
3705 return Ok(true);
3706 }
3707 if md::selected_agent(&choice).is_some()
3708 || state
3709 .origin
3710 .as_ref()
3711 .and_then(|o| o.task.as_ref())
3712 .is_some()
3713 {
3714 let owners: Vec<_> = crate::queue::Queue::open()
3715 .list()
3716 .into_iter()
3717 .filter(|task| task.runs.last() == Some(&state.id))
3718 .collect();
3719 let owner = (owners.len() == 1).then(|| &owners[0]);
3720 if !owner.is_some_and(|task| {
3721 task.runs.last() == Some(&state.id)
3722 && (!task
3723 .operator_decided_at
3724 .is_some_and(|at| at > q.answered_at.unwrap_or(q.asked_at))
3725 || task.resumption.as_ref().is_some_and(|release| {
3726 release.question_id.as_deref() == Some(q.id.as_str())
3727 && task.operator_decided_at == Some(release.at)
3728 }))
3729 }) {
3730 stop(state, &repo, pr, "replacement requires the current original queue task owner and an unsuperseded answer; PR left open").await?;
3731 return Ok(true);
3732 }
3733 }
3734 if pr.state == PrLifecycle::Merged {
3735 stop(
3736 state,
3737 &repo,
3738 pr,
3739 "PR already merged; withdrawal/replacement cannot be applied",
3740 )
3741 .await?;
3742 return Ok(true);
3743 }
3744 let comment = format!(
3745 "{MARKER}\nOwner decision on question {}: {}. Previous attempt: `{}`. Original instruction and review history remain in the task record.",
3746 q.id, choice, state.id
3747 );
3748 if pr.state == PrLifecycle::Open {
3749 if let Err(e) = forge.close(&repo, &pr.url, &comment).await {
3750 stop(
3751 state,
3752 &repo,
3753 pr,
3754 &format!(
3755 "owner requested closure but it failed: {e}; task held, no replacement started"
3756 ),
3757 )
3758 .await?;
3759 return Ok(true);
3760 }
3761 }
3762 let confirmed = forge.view(&repo, &pr.url).await?;
3763 if confirmed.pr.state != PrLifecycle::Closed {
3764 stop(
3765 state,
3766 &repo,
3767 pr,
3768 "PR closure was not confirmed; task held, no replacement started",
3769 )
3770 .await?;
3771 return Ok(true);
3772 }
3773 *pr = confirmed.pr;
3774 state.merge_decision.as_mut().unwrap().closed = true;
3775 if let Some(record) = &mut state.pr {
3776 record.state = "closed".into();
3777 }
3778 state.status = RunStatus::Superseded;
3779 state.merge = Some(MergeOutcome {
3780 mode: MergeMode::Pr,
3781 ok: false,
3782 detail: format!("owner decision: {choice}; PR closure confirmed"),
3783 empty: false,
3784 });
3785 state.event(
3786 "land",
3787 format!(
3788 "{} applied: PR closure confirmed; old execution retired",
3789 choice
3790 ),
3791 );
3792 state.save()?;
3793 Ok(true)
3794}
3795
3796async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
3797 crate::pr_head::ensure_owner(state)?;
3798 let reopening_hold = crate::merge_decision::resumable_hold(state);
3799 let repo = state.repo.clone();
3800 let budget = state.config.graph.land_rounds;
3801 let mut round = 0usize;
3802 let mut rebases = 0usize;
3805 let mut waited = Duration::ZERO;
3806 let mut shown: BTreeSet<String> = BTreeSet::new();
3811 let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
3813 let mut rechecked = false;
3815
3816 state.status = RunStatus::Landing;
3824 state.event("land", format!("watching {pr_url}"));
3825 state.save()?;
3826
3827 let mut resumed_armed = state.land_armed_head.is_some();
3831
3832 loop {
3833 let seen = forge.view(&repo, pr_url).await?;
3834 let mut pr = seen.pr.clone();
3835 pr.review_comments.retain(|c| !shown.contains(&c.body));
3836 state.pr = Some(crate::run::PrRecord {
3837 url: pr.url.clone(),
3838 number: pr.number,
3839 state: pr.state.as_str().to_owned(),
3840 checks: pr.checks.as_str().to_owned(),
3841 round,
3842 rounds: budget,
3843 red_at_merge: Vec::new(),
3844 });
3845 state.save()?;
3846
3847 crate::pr_head::ensure_owner(state)?;
3848 if reopening_hold {
3849 anyhow::ensure!(
3850 pr.state == PrLifecycle::Open,
3851 "held PR is no longer open; cannot request a fresh decision"
3852 );
3853 disarm(forge, state, &repo, pr.number)
3856 .await
3857 .map_err(anyhow::Error::msg)?;
3858 let previous = state.merge_decision.take();
3859 let binding = state.land_approval.take();
3860 state.config.graph.land_approval = true;
3861 let subject = merge_subject(
3862 crate::graph::landing_title(state, &seen.title),
3863 &crate::graph::landing_subject_source(state),
3864 );
3865 let contested = contested_to_ask(state);
3866 if let Err(error) =
3867 approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
3868 {
3869 state.merge_decision = previous;
3870 state.land_approval = binding;
3871 return Err(error);
3872 }
3873 state.end_pass_parked(crate::run::ParkCause::Question);
3874 state.parked = true;
3875 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()));
3876 state.save()?;
3877 return Ok(pr);
3878 }
3879 if owner_stop(state, &mut pr, forge).await? {
3880 return Ok(pr);
3881 }
3882
3883 if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
3884 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3889 let why = format!(
3890 "a previous pass may have armed auto-merge and it could not be disabled \
3891 on resume: {e}"
3892 );
3893 stop(state, &repo, &pr, &why).await?;
3894 return Ok(pr);
3895 }
3896 }
3897
3898 if pr.state == PrLifecycle::Open
3903 && !seen.head.is_empty()
3904 && state
3905 .land_armed_head
3906 .as_deref()
3907 .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
3908 {
3909 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3910 let why = format!(
3911 "the head moved while auto-merge was armed and it could not be disabled: {e}"
3912 );
3913 stop(state, &repo, &pr, &why).await?;
3914 return Ok(pr);
3915 }
3916 }
3917
3918 if pr.state == PrLifecycle::Open {
3919 if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
3920 if waited >= WAIT_CEILING {
3921 let want = awaiting_head.as_deref().unwrap_or_default();
3922 let why = format!(
3923 "the pull request's checks were still not about one readable head after \
3924 {} minutes (expected {}, pull request points at {}, checks are for {}); \
3925 someone may have pushed over it",
3926 WAIT_CEILING.as_secs() / 60,
3927 if want.is_empty() { "any" } else { want },
3928 if seen.head.is_empty() {
3929 "nothing readable"
3930 } else {
3931 &seen.head
3932 },
3933 if seen.rollup_head.is_empty() {
3934 "nothing readable"
3935 } else {
3936 &seen.rollup_head
3937 },
3938 );
3939 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3940 return Ok(pr);
3941 }
3942 waited += POLL;
3943 forge.poll().await;
3944 continue;
3945 }
3946 if awaiting_head.take().is_some() {
3949 waited = Duration::ZERO;
3952 }
3953 }
3954
3955 if pr.state == PrLifecycle::Open && state.config.graph.rerun_flaky_checks {
3960 let failing = rerunnable(&seen.contexts);
3961 let now = forge.now();
3962 match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
3963 RerunStep::Rerun(runs) => {
3964 let rec = state.land_reruns.get_or_insert_with(Default::default);
3965 if !rec.head.eq_ignore_ascii_case(&seen.head) {
3966 *rec = LandReruns {
3967 head: seen.head.clone(),
3968 runs: BTreeMap::new(),
3969 };
3970 }
3971 for run in &runs {
3972 rec.runs.insert(run.clone(), now);
3973 }
3974 state.save()?;
3978 let mut any = false;
3979 for run in &runs {
3980 let names = failing
3981 .iter()
3982 .filter(|(_, r)| r.as_deref() == Some(run))
3983 .map(|(l, _)| l.as_str())
3984 .collect::<Vec<_>>()
3985 .join(", ");
3986 match forge.rerun(&repo, run).await {
3987 Ok(()) => {
3988 any = true;
3989 state.event(
3990 "land",
3991 format!("reran failed check(s) {names} (run {run})"),
3992 );
3993 }
3994 Err(e) => state.event(
3996 "land",
3997 format!("could not rerun check(s) {names} (run {run}): {e:#}"),
3998 ),
3999 }
4000 }
4001 state.save()?;
4002 if any {
4003 waited = Duration::ZERO;
4004 forge.poll().await;
4005 continue;
4006 }
4007 }
4008 RerunStep::Grace => {
4009 if waited >= WAIT_CEILING {
4010 let why = format!(
4011 "checks were still failing after a rerun and {} minutes",
4012 WAIT_CEILING.as_secs() / 60
4013 );
4014 stop(state, &repo, &pr, &why).await?;
4015 return Ok(pr);
4016 }
4017 waited += POLL;
4018 forge.poll().await;
4019 continue;
4020 }
4021 RerunStep::Fix => {}
4022 }
4023 }
4024
4025 let step = decide(&pr, round, budget, waited);
4026 let armed_here = state
4031 .land_armed_head
4032 .as_deref()
4033 .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
4034 if armed_here && matches!(step, Step::Merge | Step::Wait) {
4035 if waited >= WAIT_CEILING {
4036 let required = if seen.base.is_empty() {
4037 None
4038 } else {
4039 forge.required_contexts(&repo, &seen.base).await
4040 };
4041 let why = format!(
4042 "auto-merge was armed on {} but the pull request did not merge within {} \
4043 minutes ({})",
4044 seen.head,
4045 WAIT_CEILING.as_secs() / 60,
4046 waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
4047 );
4048 stop_disarmed(forge, state, &repo, &pr, &why).await?;
4049 return Ok(pr);
4050 }
4051 waited += POLL;
4052 forge.poll().await;
4053 continue;
4054 }
4055 if armed_here && !matches!(step, Step::Done { .. }) {
4056 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4059 let why = format!("auto-merge could not be disabled: {e}");
4060 stop(state, &repo, &pr, &why).await?;
4061 return Ok(pr);
4062 }
4063 }
4064 match step {
4065 Step::Wait => {
4066 if waited >= WAIT_CEILING {
4067 let why = format!(
4068 "checks were still running after {} minutes",
4069 WAIT_CEILING.as_secs() / 60
4070 );
4071 stop(state, &repo, &pr, &why).await?;
4072 return Ok(pr);
4073 }
4074 waited += POLL;
4075 forge.poll().await;
4076 }
4077 Step::Done { merged } => {
4078 if merged {
4079 state.pr_merged_head = (!seen.head.is_empty()).then(|| seen.head.clone());
4080 }
4081 if let Some(armed) = state.land_armed_head.take() {
4086 if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
4087 let msg = format!(
4088 "{} merged on {} but the owner approved {armed}; review what landed",
4089 pr.url, seen.head
4090 );
4091 tracing::warn!("{msg}");
4092 state.event("land", msg);
4093 crate::notices::raise_with(
4097 crate::notices::Notice::warn(
4098 &format!("merged-unapproved-head:{}", state.id),
4099 "A pull request merged on a commit the owner did not approve; \
4100 review what landed",
4101 )
4102 .link(crate::notices::Link::Run {
4103 id: state.id.clone(),
4104 }),
4105 &state.config.notify,
4106 );
4107 }
4108 }
4109 state.status = if merged {
4110 RunStatus::Merged
4111 } else {
4112 RunStatus::Ready
4113 };
4114 let detail = if merged {
4115 format!("{} was merged", pr.url)
4116 } else {
4117 format!("{} was closed without merging", pr.url)
4118 };
4119 state.merge = Some(MergeOutcome {
4120 mode: MergeMode::Pr,
4121 ok: merged,
4122 detail: detail.clone(),
4123 empty: false,
4124 });
4125 state.event("land", detail);
4126 state.save()?;
4127 write_pr_state_through(state, pr.state);
4128 return Ok(pr);
4129 }
4130 Step::Merge => {
4131 state.check_delivery_evidence(&seen.head)?;
4132 let subject = merge_subject(
4133 crate::graph::landing_title(state, &seen.title),
4134 &crate::graph::landing_subject_source(state),
4135 );
4136 let contested = contested_to_ask(state);
4142 if !approval_reasons(state).is_empty() {
4143 match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
4144 .await?
4145 {
4146 ApprovalGate::Approved => {}
4147 ApprovalGate::Repair => {
4148 if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4151 state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4152 state.save()?;
4153 }
4154 return Ok(pr);
4155 }
4156 ApprovalGate::Held => {
4157 if owner_stop(state, &mut pr, forge).await? {
4160 return Ok(pr);
4161 }
4162 stop(
4163 state,
4164 &repo,
4165 &pr,
4166 "the owner did not approve the merge (held or unanswered)",
4167 )
4168 .await?;
4169 return Ok(pr);
4170 }
4171 ApprovalGate::Pending => {
4179 state.end_pass_parked(crate::run::ParkCause::Question);
4180 state.parked = true;
4181 state.event(
4182 "land",
4183 "parked awaiting merge approval - resumes once answered",
4184 );
4185 state.save()?;
4186 return Ok(pr);
4187 }
4188 }
4189 }
4190 let observed_head = seen.head.clone();
4201 {
4204 let fresh = forge.view(&repo, pr_url).await.ok();
4205 if !direct_merge_is_safe(
4206 fresh.as_ref(),
4207 &observed_head,
4208 &shown,
4209 round,
4210 budget,
4211 waited,
4212 ) {
4213 if waited >= WAIT_CEILING {
4214 let why = "the pull request did not settle on the approved head \
4215 before it could be merged";
4216 stop(state, &repo, &pr, why).await?;
4217 return Ok(pr);
4218 }
4219 state.event(
4220 "land",
4221 "the pull request changed before merging; looking again",
4222 );
4223 waited += POLL;
4224 forge.poll().await;
4225 continue;
4226 }
4227 }
4228 let argv = merge_argv_at(pr.number, &subject, &observed_head);
4229 let out = {
4230 let merge_lock = repo_merge_lock(&repo);
4231 let _merge_slot = merge_lock.lock().await;
4232 crate::pr_head::ensure_owner(state)?;
4233 if let Some(task_id) = state
4234 .origin
4235 .as_ref()
4236 .and_then(|origin| origin.task.as_ref())
4237 {
4238 let task = crate::queue::Queue::open()
4239 .get(task_id)
4240 .context("cannot verify the task owner immediately before merge")?;
4241 if task.runs.last() != Some(&state.id)
4242 || task.operator_held()
4243 || task.status == crate::queue::TaskStatus::Done
4244 {
4245 bail!(
4246 "task ownership or an operator hold superseded this merge approval"
4247 );
4248 }
4249 }
4250 if let Some(binding) = &state.land_approval {
4251 let current = ask::Questions::open().get(&binding.question)?;
4252 if merge_consent_superseded(state, ¤t)?
4253 || current.run != state.id
4254 || !binding.head.eq_ignore_ascii_case(&observed_head)
4255 || approval(current.resolution().as_deref()) != Approval::Merge
4256 || current.merge_target.as_ref().is_some_and(|t| {
4257 t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4258 })
4259 {
4260 bail!(
4261 "merge decision is stale or no longer authorizes the reviewed head"
4262 );
4263 }
4264 }
4265 forge.merge(&repo, &argv).await?
4266 };
4267 if out.0 {
4268 let confirmed = forge.view(&repo, pr_url).await.is_ok_and(|c| {
4274 if c.pr.state == PrLifecycle::Merged {
4275 state.pr_merged_head = (!c.head.is_empty()).then_some(c.head);
4276 true
4277 } else {
4278 false
4279 }
4280 });
4281 if !confirmed {
4282 if waited >= WAIT_CEILING {
4283 let why = "the merge request succeeded but the pull request \
4284 could not be confirmed merged after waiting";
4285 stop(state, &repo, &pr, why).await?;
4286 return Ok(pr);
4287 }
4288 state.event(
4289 "land",
4290 "merge accepted but the pull request is not confirmed merged yet; waiting",
4291 );
4292 state.save()?;
4293 waited += POLL;
4294 forge.poll().await;
4295 continue;
4296 }
4297 pr.state = PrLifecycle::Merged;
4298 state.status = RunStatus::Merged;
4299 state.merge = Some(MergeOutcome {
4300 mode: MergeMode::Pr,
4301 ok: true,
4302 detail: format!("gh {}", argv.join(" ")),
4303 empty: false,
4304 });
4305 if let Some(pr_record) = state.pr.as_mut() {
4310 pr_record.state = pr.state.as_str().to_owned();
4311 }
4312 state.event("land", format!("merged {} as `{subject}`", pr.url));
4313 announce_red_merge(state, &pr).await;
4314 state.save()?;
4315 write_pr_state_through(state, pr.state);
4316 return Ok(pr);
4317 }
4318 let after_seen = forge.view(&repo, pr_url).await.ok();
4319 let after = after_seen.as_ref().map(|s| s.pr.state);
4320 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4321 state.pr_merged_head = after_seen
4322 .as_ref()
4323 .map(|s| s.head.clone())
4324 .filter(|h| !h.is_empty());
4325 pr.state = PrLifecycle::Merged;
4326 state.status = RunStatus::Merged;
4327 state.merge = Some(outcome);
4328 if let Some(pr_record) = state.pr.as_mut() {
4329 pr_record.state = pr.state.as_str().to_owned();
4330 }
4331 state.event("land", format!("merged {} as `{subject}`", pr.url));
4332 announce_red_merge(state, &pr).await;
4333 state.save()?;
4334 write_pr_state_through(state, pr.state);
4335 return Ok(pr);
4336 }
4337 let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
4338 match verdict {
4339 Refused::Final => {
4340 let merge_state = after_seen
4341 .as_ref()
4342 .map(|s| s.merge_state.as_str())
4343 .filter(|m| !m.is_empty())
4344 .unwrap_or("unknown");
4345 let why = format!(
4349 "the merge was refused: {} (merge state: {merge_state})",
4350 out.1
4351 );
4352 stop(state, &repo, &pr, &why).await?;
4353 return Ok(pr);
4354 }
4355 verdict => {
4356 if waited >= WAIT_CEILING {
4361 let why = format!(
4362 "the merge was still refused after {} minutes: {}",
4363 WAIT_CEILING.as_secs() / 60,
4364 out.1
4365 );
4366 stop(state, &repo, &pr, &why).await?;
4367 return Ok(pr);
4368 }
4369 if verdict == Refused::Recheck {
4370 rechecked = true;
4371 }
4372 state.event(
4373 "land",
4374 "merge refused while the branch policy is not satisfied yet; waiting",
4375 );
4376 state.save()?;
4377 waited += POLL;
4378 forge.poll().await;
4379 }
4380 }
4381 }
4382 Step::Rebase => {
4383 if rebases >= budget {
4389 let why = format!(
4390 "the base moved under this branch {budget} time(s) and it still does \
4391 not merge; rebasing again would only race it"
4392 );
4393 stop(state, &repo, &pr, &why).await?;
4394 return Ok(pr);
4395 }
4396 rebases += 1;
4397 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4398 stop(
4399 state,
4400 &repo,
4401 &pr,
4402 "the pull request conflicts and this run has no winning branch to rebase",
4403 )
4404 .await?;
4405 return Ok(pr);
4406 };
4407 let base = state.base_branch.clone();
4408 state.event(
4409 "land",
4410 format!("{} no longer merges; rebasing onto {base}", pr.url),
4411 );
4412 state.save()?;
4413
4414 let remote = state.config.merge.remote.clone();
4418 git::fetch(&repo, &remote, &base).await.ok();
4419 if owner_stop(state, &mut pr, forge).await? {
4420 return Ok(pr);
4421 }
4422 let scratch = state.dir().join("rebase");
4423 let onto = format!("{remote}/{base}");
4424 let rebased =
4425 match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
4426 Ok(crate::rebase::Rebased::Applied) => Ok(None),
4427 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4428 Err(e) => Err(e),
4429 };
4430 if owner_stop(state, &mut pr, forge).await? {
4431 return Ok(pr);
4432 }
4433 match rebased {
4434 Ok(None) => {
4435 let pushed = {
4436 let merge_lock = repo_merge_lock(&repo);
4437 let _merge_slot = merge_lock.lock().await;
4438 if let Some(head) = &state.pr_head {
4439 git::push_as(
4440 &repo,
4441 &state.config.merge.remote,
4442 &branch,
4443 head,
4444 state
4445 .pr_pushed_head
4446 .as_deref()
4447 .context("missing public head lease")?,
4448 )
4449 .await?
4450 } else {
4451 git::push_pinned(
4452 &repo,
4453 &state.config.merge.remote,
4454 &branch,
4455 &seen.head,
4456 )
4457 .await?
4458 }
4459 };
4460 if !pushed.ok() {
4461 let why = format!(
4462 "rebased {branch} but could not push it: {}",
4463 pushed.stderr.trim()
4464 );
4465 stop(state, &repo, &pr, &why).await?;
4466 return Ok(pr);
4467 }
4468 let head =
4471 match git::rev_parse(&repo, &format!("refs/heads/{branch}")).await {
4472 Ok(head) => head,
4473 Err(e) => {
4474 let why = format!(
4475 "rebased and pushed {branch} but could not read the pushed \
4476 commit: {e:#}"
4477 );
4478 stop(state, &repo, &pr, &why).await?;
4479 return Ok(pr);
4480 }
4481 };
4482 crate::graph::refresh_reviewed_commits(state, &branch).await;
4483 state.pr_pushed_head = Some(head.clone());
4484 awaiting_head = Some(head);
4485 rechecked = false;
4486 state.event("land", format!("rebased {branch} onto {base}"));
4487 state.save()?;
4488 waited = Duration::ZERO;
4491 tokio::time::sleep(POLL).await;
4492 }
4493 Ok(Some(conflict)) => {
4496 let why = format!(
4497 "{} conflicts with {base} and the rebase did not apply: {}",
4498 pr.url,
4499 conflict.chars().take(600).collect::<String>()
4500 );
4501 stop(state, &repo, &pr, &why).await?;
4502 return Ok(pr);
4503 }
4504 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4505 Err(e) => {
4506 let why = format!("could not rebase {branch} onto {base}: {e:#}");
4507 stop(state, &repo, &pr, &why).await?;
4508 return Ok(pr);
4509 }
4510 }
4511 }
4512 Step::GiveUp { reason } => {
4513 stop(state, &repo, &pr, &reason).await?;
4514 return Ok(pr);
4515 }
4516 Step::Fix { reason } => {
4517 round += 1;
4518 waited = Duration::ZERO;
4519 for c in &pr.review_comments {
4520 shown.insert(c.body.clone());
4521 }
4522 state.event("land", format!("round {round}: {reason}"));
4523 state.save()?;
4524
4525 let logs = failing_logs(&repo, &seen.failing_urls).await;
4526 let was_red = pr.checks == Checks::Red;
4527 if owner_stop(state, &mut pr, forge).await? {
4528 return Ok(pr);
4529 }
4530 let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4531 if owner_stop(state, &mut pr, forge).await? {
4532 return Ok(pr);
4533 }
4534 match fixed {
4535 Fixed::Committed { head } => {
4536 state.pr_pushed_head = Some(head.clone());
4539 awaiting_head = Some(head);
4540 rechecked = false;
4541 waited = Duration::ZERO;
4542 forge.poll().await;
4543 }
4544 Fixed::Declined if was_red => {
4545 let why = format!(
4546 "the fixer produced no commit while {} check(s) were failing \
4547 ({}); stopping instead of looping on an unchanged tree",
4548 pr.failing.len(),
4549 pr.failing.join(", ")
4550 );
4551 stop(state, &repo, &pr, &why).await?;
4552 return Ok(pr);
4553 }
4554 Fixed::Declined => state.event(
4559 "land",
4560 format!("round {round}: fixer declined the comments, nothing committed"),
4561 ),
4562 Fixed::Failed(why) => {
4563 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
4564 return Ok(pr);
4565 }
4566 }
4567 state.save()?;
4568 }
4569 }
4570 }
4571}
4572
4573#[derive(Clone)]
4577struct Seen {
4578 pr: PrState,
4579 title: String,
4580 failing_urls: Vec<(String, String)>,
4581 head: String,
4583 rollup_head: String,
4586 merge_state: String,
4589 contexts: Vec<CheckInfo>,
4591 base: String,
4593}
4594
4595#[derive(Clone)]
4597struct CheckInfo {
4598 label: String,
4599 verdict: Verdict,
4600 required: Option<bool>,
4601 run: Option<String>,
4603}
4604
4605#[derive(Debug, Clone, PartialEq, Eq)]
4607enum RerunStep {
4608 Rerun(Vec<String>),
4610 Grace,
4612 Fix,
4614}
4615
4616fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
4620 contexts
4621 .iter()
4622 .filter(|c| c.verdict == Verdict::Fail && c.required != Some(false))
4623 .filter(|c| {
4625 c.run.as_ref().is_none_or(|r| {
4626 !contexts.iter().any(|o| {
4627 matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
4628 && o.run.as_ref() == Some(r)
4629 })
4630 })
4631 })
4632 .map(|c| (c.label.clone(), c.run.clone()))
4633 .collect()
4634}
4635
4636fn rerun_step(
4638 failing: &[(String, Option<String>)],
4639 record: Option<&LandReruns>,
4640 head: &str,
4641 now: i64,
4642) -> RerunStep {
4643 if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
4646 return RerunStep::Fix;
4647 }
4648 let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
4650 let mut fresh: Vec<String> = Vec::new();
4651 let mut recent = false;
4652 for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
4653 match record.and_then(|r| r.runs.get(run)) {
4654 None => {
4655 if !fresh.iter().any(|f| f == run) {
4656 fresh.push(run.to_owned());
4657 }
4658 }
4659 Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
4660 Some(_) => {}
4661 }
4662 }
4663 if !fresh.is_empty() {
4664 RerunStep::Rerun(fresh)
4665 } else if recent {
4666 RerunStep::Grace
4667 } else {
4668 RerunStep::Fix
4669 }
4670}
4671
4672async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
4676 let view = gh(
4677 repo,
4678 &[
4679 "pr".to_owned(),
4680 "view".to_owned(),
4681 pr_url.to_owned(),
4682 "--json".to_owned(),
4683 "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
4684 .to_owned(),
4685 ],
4686 )
4687 .await?;
4688 if !view.0 {
4689 bail!("gh pr view {pr_url}: {}", view.1);
4690 }
4691 let number = parse_pr(&view.1)?.number;
4692 let node = last_commit_node(repo, number).await;
4693 let mut seen = seen_from(&view.1, node.as_deref())?;
4694
4695 let inline = gh(
4696 repo,
4697 &[
4698 "api".to_owned(),
4699 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
4700 ],
4701 )
4702 .await?;
4703 if inline.0 {
4704 match parse_inline_comments(&inline.1) {
4705 Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
4706 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
4709 }
4710 } else {
4711 tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
4712 }
4713 Ok(seen)
4714}
4715
4716fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
4727 let mut pr = parse_pr(view_json)?;
4728 let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
4729
4730 let mut rollup_head = String::new();
4731 let mut failing_urls = Vec::new();
4732 let mut contexts = Vec::new();
4733 let mut checks = Checks::Unknown;
4734 let mut failing = Vec::new();
4735 if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
4736 (checks, failing) = rollup_verdict(&rollup);
4737 failing_urls = rollup
4738 .iter()
4739 .filter(|c| c.verdict() == Verdict::Fail)
4740 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
4741 .collect();
4742 contexts = rollup
4743 .iter()
4744 .map(|c| CheckInfo {
4745 label: c.label(),
4746 verdict: c.verdict(),
4747 required: c.is_required,
4748 run: c.url().and_then(run_of),
4749 })
4750 .collect();
4751 rollup_head = oid;
4752 }
4753 pr.checks = checks;
4754 pr.failing = failing;
4755
4756 Ok(Seen {
4757 pr,
4758 title: raw.title,
4759 failing_urls,
4760 head: raw.head_ref_oid,
4761 rollup_head,
4762 merge_state: raw.merge_state_status,
4763 contexts,
4764 base: raw.base_ref_name,
4765 })
4766}
4767
4768fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
4771 let v: serde_json::Value = serde_json::from_str(json).ok()?;
4772 if v.get("errors").is_some_and(|e| !e.is_null()) {
4773 return None;
4774 }
4775 let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
4776 let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
4777 let contexts = commit.pointer("/statusCheckRollup/contexts");
4778 let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
4779 return Some((oid.to_owned(), Vec::new()));
4781 };
4782 if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
4783 return None;
4784 }
4785 let nodes = contexts.get("nodes")?.as_array()?;
4786 let rollup = nodes
4787 .iter()
4788 .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
4789 .collect::<Result<Vec<_>, _>>()
4790 .ok()?;
4791 Some((oid.to_owned(), rollup))
4792}
4793
4794async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
4797 let out = gh(
4798 repo,
4799 &[
4800 "api".to_owned(),
4801 "graphql".to_owned(),
4802 "-F".to_owned(),
4803 "owner={owner}".to_owned(),
4804 "-F".to_owned(),
4805 "repo={repo}".to_owned(),
4806 "-F".to_owned(),
4807 format!("number={number}"),
4808 "-f".to_owned(),
4809 "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
4810 name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
4811 statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
4812 ... on CheckRun{name status conclusion detailsUrl \
4813 isRequired(pullRequestNumber:$number)} \
4814 ... on StatusContext{context state targetUrl \
4815 isRequired(pullRequestNumber:$number)}}}}}}}}}}"
4816 .to_owned(),
4817 ],
4818 )
4819 .await
4820 .ok()?;
4821 out.0.then_some(out.1)
4822}
4823
4824#[doc(hidden)]
4826#[derive(Debug, PartialEq)]
4827pub enum Fixed {
4828 Committed {
4830 head: String,
4832 },
4833 Declined,
4835 Failed(String),
4837}
4838
4839#[doc(hidden)]
4845pub async fn fix_round(
4846 state: &mut RunState,
4847 pr: &PrState,
4848 round: usize,
4849 budget: usize,
4850 reason: &str,
4851 logs: &str,
4852) -> Result<Fixed> {
4853 let winner = state
4854 .winner()
4855 .cloned()
4856 .context("landing needs a winning candidate; none is recorded on this run")?;
4857 let roles = state
4858 .config
4859 .resolve_roles()
4860 .context("resolve the roster for the fix round")?;
4861 let attempts = crate::fixer::attempts(state, &roles, &winner);
4865 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4866
4867 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
4868 let mut artifacts = agent::artifacts_dir(&state.dir());
4869 if let Some(request) = state
4870 .merge_repairs
4871 .last()
4872 .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
4873 {
4874 artifacts = artifacts.join(format!("merge-repair-{}", request.question));
4875 }
4876 let prompt = if state.config.cache_dir().is_some() {
4877 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
4878 } else {
4879 prompt
4880 };
4881 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
4885 let mut last = None;
4889 for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
4890 if owner_requested_stop(state)? {
4891 return Ok(Fixed::Declined);
4892 }
4893 let mut seat = seat_of(state, &seat_key, &spec.id);
4894 let stem = if i == 0 {
4895 format!("land-{round}")
4896 } else {
4897 format!("land-{round}-{}", spec.id)
4898 };
4899 let out = agent::invoke(
4900 &spec,
4901 &mut seat,
4902 &Invocation {
4903 scope: crate::agent::InvocationScope::Repository,
4904 coordination: state.config.coordination.as_ref(),
4905 fetched_base: state.fetched_base.as_ref(),
4906 cwd: &winner.worktree,
4907 prompt: &prompt,
4908 timeout: Duration::from_secs(state.config.graph.timeout_fix),
4909 allow_write: true,
4910 unsandboxed: false,
4911 sessions: state.config.graph.sessions,
4912 artifacts: &artifacts,
4913 stem: &stem,
4914 run: &state.id,
4915 node: "land",
4916 cache_dir: state.config.cache_dir().as_deref(),
4917 attachments: &[],
4918 writable: &[],
4919 },
4920 )
4921 .await;
4922 state.seats.insert(seat.key.clone(), seat);
4923 if let Some(next) = ids.get(i + 1)
4924 && agent::chain_advances(&out)
4925 {
4926 let (class, why) = crate::fixer::failure_of(&out);
4927 crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
4928 continue;
4929 }
4930 last = Some(out);
4931 break;
4932 }
4933 let out = last.expect("the fixer chain always has an entry");
4934
4935 match out {
4936 Ok(o) if o.quota_exhausted() => {
4937 return Ok(Fixed::Failed(
4938 "rate limited (quota); the fixer could not run".to_owned(),
4939 ));
4940 }
4941 Ok(o) if !o.usable() => {
4942 return Ok(Fixed::Failed(format!(
4943 "the fixer produced nothing usable ({})",
4944 crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
4945 )));
4946 }
4947 Ok(_) => {}
4948 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4949 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
4950 }
4951
4952 if owner_requested_stop(state)? {
4953 return Ok(Fixed::Declined);
4954 }
4955
4956 let wait = crate::repo_lock::WaitPolicy::new(
4959 Duration::from_secs(state.config.graph.timeout_resource_wait),
4960 || false,
4961 );
4962 let coordination = crate::repo_lock::SeatLease::operation_with(
4963 &winner.worktree,
4964 state.config.coordination.as_ref(),
4965 &wait,
4966 )
4967 .await?;
4968 match git::rescue_commit(
4969 &winner.worktree,
4970 &format!("magi: land round {round} fixes (uncommitted work)"),
4971 )
4972 .await
4973 {
4974 Ok(r) => state.note_withheld("land", &r.withheld),
4975 Err(e)
4976 if state
4977 .merge_repairs
4978 .last()
4979 .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
4980 {
4981 return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
4982 }
4983 Err(_) => {}
4984 }
4985 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
4986 drop(coordination);
4987 if after == before {
4988 return Ok(Fixed::Declined);
4989 }
4990
4991 if owner_requested_stop(state)? {
4992 return Ok(Fixed::Declined);
4993 }
4994 crate::pr_head::ensure_owner(state)?;
4995 let remote = state.config.merge.remote.clone();
4996 let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
4997 let push = if state.pr_head.is_some() {
4998 let expected = state
4999 .pr_pushed_head
5000 .as_deref()
5001 .context("missing public head lease")?;
5002 git::push_as(
5003 &winner.worktree,
5004 &remote,
5005 &winner.branch,
5006 &public_head,
5007 expected,
5008 )
5009 .await?
5010 } else {
5011 git::push(&winner.worktree, &remote, &winner.branch).await?
5012 };
5013 if !push.ok() {
5014 return Ok(Fixed::Failed(format!(
5015 "pushing {} to {remote} failed: {}",
5016 public_head, push.stderr
5017 )));
5018 }
5019 state.event(
5020 "land",
5021 format!("round {round}: pushed a fix to {public_head}"),
5022 );
5023 state.pr_pushed_head = Some(after.clone());
5024 state.save()?;
5025 Ok(Fixed::Committed { head: after })
5026}
5027
5028pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
5030 crate::fixer::seat_for(state, key, agent)
5031}
5032
5033pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
5036 let seen = observe(&state.repo, url).await?;
5037 let winner = state.winner().context("no winning branch to repair")?;
5038 if seen.pr.state != PrLifecycle::Open || seen.head != head {
5039 anyhow::bail!(
5040 "PR is closed or its head changed; review the current head before requesting repair again"
5041 );
5042 }
5043 if git::rev_parse(&winner.worktree, "HEAD").await? != head
5044 || !git::is_clean(&winner.worktree).await?
5045 {
5046 anyhow::bail!(
5047 "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
5048 );
5049 }
5050 let view = gh(
5053 &state.repo,
5054 &[
5055 "pr".into(),
5056 "view".into(),
5057 url.into(),
5058 "--json".into(),
5059 "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
5060 ],
5061 )
5062 .await?;
5063 if !view.0 {
5064 bail!("cannot check auto-merge: {}", view.1);
5065 }
5066 let live: serde_json::Value = serde_json::from_str(&view.1)?;
5067 let public = state.pr_branch().unwrap_or(&winner.branch);
5068 if live["state"] != "OPEN"
5069 || live["headRefOid"] != head
5070 || live["headRefName"] != public
5071 || live["baseRefName"] != state.base_branch
5072 || live["isCrossRepository"] != false
5073 {
5074 bail!("PR changed during repair preflight; review its current head explicitly");
5075 }
5076 let auto_merge = live
5077 .get("autoMergeRequest")
5078 .context("cannot read the PR auto-merge reservation; repair held")?;
5079 if !auto_merge.is_null() {
5080 let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
5081 if !out.0 {
5082 bail!("cannot disable auto-merge; repair held: {}", out.1);
5083 }
5084 }
5085 Ok(seen.pr)
5086}
5087
5088fn fix_prompt(
5090 state: &RunState,
5091 pr: &PrState,
5092 round: usize,
5093 budget: usize,
5094 reason: &str,
5095 logs: &str,
5096) -> String {
5097 let mut s = format!(
5098 "Your patch is open as a pull request and it is not landing. Land round \
5099 {round} of {budget}.\n\n\
5100 Pull request: {}\n\n\
5101 What is holding it: {reason}\n\n\
5102 # The task\n\n{}\n",
5103 pr.url, state.instruction
5104 );
5105
5106 if pr.failing.is_empty() {
5107 s.push_str("\n# Failing checks\n\n(none)\n");
5108 } else {
5109 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
5110 if logs.trim().is_empty() {
5111 s.push_str(
5112 "\nNo log could be read; investigate the failure with necessary focused checks.\n",
5113 );
5114 } else {
5115 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
5116 }
5117 }
5118
5119 if pr.review_comments.is_empty() {
5120 s.push_str("\n# Review comments\n\n(none)\n");
5121 } else {
5122 s.push_str("\n# Review comments\n");
5123 for c in &pr.review_comments {
5124 let where_ = match (&c.path, c.line) {
5125 (Some(p), Some(l)) => format!(" ({p}:{l})"),
5126 (Some(p), None) => format!(" ({p})"),
5127 _ => String::new(),
5128 };
5129 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
5130 }
5131 }
5132
5133 s.push_str(
5134 "\n# Rules\n\n\
5135 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
5136 failing test; do not silence a lint with an allow attribute; do not \
5137 stretch a timeout to hide a race. If the check is right, the code is \
5138 wrong.\n\
5139 2. Change nothing the checks and the comments did not raise. A \
5140 drive-by refactor turns a one-line fix into a pull request that \
5141 needs reviewing again.\n\
5142 3. If a comment is wrong, say so with a checkable argument and change \
5143 nothing for it. A declined comment with a reason is a correct \
5144 outcome; a change made to appease a reviewer is not.\n\
5145 4. Commit in this worktree. magi pushes to the pull request's branch \
5146 for you; do not push, merge, or close anything yourself.\n\
5147 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
5148 # Output\n\n\
5149 Say what you changed and why, and what you declined and why.",
5150 );
5151
5152 let language = &state.config.graph.language;
5153 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
5154 let _ = write!(s, "\n\nWrite all prose in {language}.");
5155 }
5156 s.push_str(&crate::prompt::github_rule(
5158 language,
5159 &state.config.graph.github_language,
5160 ));
5161 if let Some(overlay) = state.config.prompts.overlay("fix") {
5162 let _ = write!(s, "\n\n{overlay}");
5163 }
5164 s.push('\n');
5165 s.push_str(crate::prompt::child_run_ownership());
5166 s
5167}
5168
5169async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
5172 let mut out = String::new();
5173 for (name, url) in failing.iter().take(MAX_LOGS) {
5174 let args = match (job_of(url), run_of(url)) {
5175 (Some(job), _) => vec![
5176 "run".to_owned(),
5177 "view".to_owned(),
5178 "--log-failed".to_owned(),
5179 "--job".to_owned(),
5180 job,
5181 ],
5182 (None, Some(run)) => vec![
5183 "run".to_owned(),
5184 "view".to_owned(),
5185 run,
5186 "--log-failed".to_owned(),
5187 ],
5188 (None, None) => continue,
5190 };
5191 let (ok, body) = match gh(repo, &args).await {
5192 Ok(v) => v,
5193 Err(e) => (false, format!("{e:#}")),
5194 };
5195 if !ok && body.trim().is_empty() {
5196 continue;
5197 }
5198 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
5199 }
5200 out
5201}
5202
5203fn job_of(details_url: &str) -> Option<String> {
5206 let after = details_url.split("/job/").nth(1)?;
5207 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5208 (!id.is_empty()).then_some(id)
5209}
5210
5211pub(crate) fn run_of(details_url: &str) -> Option<String> {
5213 let after = details_url.split("/actions/runs/").nth(1)?;
5214 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5215 (!id.is_empty()).then_some(id)
5216}
5217
5218fn stop_comment(run_id: &str, why: &str) -> String {
5222 format!(
5223 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
5224 The branch is untouched and the run is `{run_id}`. Nothing was merged."
5225 )
5226}
5227
5228async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
5233 let body = stop_comment(&state.id, why);
5234 let (_, mut body) = crate::github_text::prepare(state, "", &body);
5235 if !body.contains(MARKER) {
5236 body = format!("{MARKER}\n{body}");
5237 }
5238 let posted = gh(
5239 repo,
5240 &[
5241 "pr".to_owned(),
5242 "comment".to_owned(),
5243 pr.number.to_string(),
5244 "--body".to_owned(),
5245 body,
5246 ],
5247 )
5248 .await;
5249 match posted {
5250 Ok((true, _)) => {}
5251 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
5252 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
5253 }
5254 state.status = RunStatus::Blocked;
5255 state.merge = Some(MergeOutcome {
5256 mode: MergeMode::Pr,
5257 ok: false,
5258 detail: why.to_owned(),
5259 empty: false,
5260 });
5261 state.event("land", format!("stopped: {why}"));
5262 state.save()?;
5263 Ok(())
5264}
5265
5266pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
5282 let out = tokio::process::Command::new("gh")
5283 .args(args)
5284 .current_dir(cwd)
5285 .env_remove("GH_REPO")
5286 .quiet()
5287 .stdin(std::process::Stdio::null())
5288 .output()
5289 .await
5290 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
5291 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
5292 let err = String::from_utf8_lossy(&out.stderr);
5293 if body.trim().is_empty() {
5294 body = err.into_owned();
5295 } else if !err.trim().is_empty() {
5296 body.push_str(&err);
5297 }
5298 Ok((out.status.success(), body.trim().to_owned()))
5299}
5300
5301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5303pub(crate) enum Verdict {
5304 Pass,
5305 Fail,
5306 Pending,
5307 Unknown,
5308}
5309
5310impl Verdict {
5311 pub(crate) fn label(self) -> &'static str {
5313 match self {
5314 Self::Pass => "passed",
5315 Self::Fail => "failed",
5316 Self::Pending => "pending",
5317 Self::Unknown => "unknown",
5318 }
5319 }
5320}
5321
5322#[derive(Debug, Deserialize)]
5323#[serde(rename_all = "camelCase")]
5324struct GhPr {
5325 #[serde(default)]
5326 url: String,
5327 #[serde(default)]
5328 number: u64,
5329 #[serde(default)]
5330 state: String,
5331 #[serde(default)]
5332 title: String,
5333 #[serde(default)]
5334 status_check_rollup: Vec<GhCheck>,
5335 #[serde(default)]
5342 merge_state_status: String,
5343 #[serde(default)]
5347 head_ref_oid: String,
5348 #[serde(default)]
5349 base_ref_name: String,
5350 #[serde(default)]
5351 reviews: Vec<GhReview>,
5352 #[serde(default)]
5353 comments: Vec<GhComment>,
5354}
5355
5356#[derive(Debug, Deserialize)]
5361#[serde(rename_all = "camelCase")]
5362struct GhCheck {
5363 #[serde(default)]
5364 name: Option<String>,
5365 #[serde(default)]
5366 context: Option<String>,
5367 #[serde(default)]
5368 status: Option<String>,
5369 #[serde(default)]
5370 conclusion: Option<String>,
5371 #[serde(default)]
5372 state: Option<String>,
5373 #[serde(default)]
5374 details_url: Option<String>,
5375 #[serde(default)]
5376 target_url: Option<String>,
5377 #[serde(default)]
5380 is_required: Option<bool>,
5381}
5382
5383impl GhCheck {
5384 fn label(&self) -> String {
5386 self.name
5387 .clone()
5388 .or_else(|| self.context.clone())
5389 .unwrap_or_else(|| "(unnamed check)".to_owned())
5390 }
5391
5392 fn url(&self) -> Option<&str> {
5394 self.details_url
5395 .as_deref()
5396 .or(self.target_url.as_deref())
5397 .filter(|u| !u.is_empty())
5398 }
5399
5400 fn verdict(&self) -> Verdict {
5408 if let Some(status) = self.status.as_deref() {
5409 if !status.eq_ignore_ascii_case("COMPLETED") {
5410 return Verdict::Pending;
5411 }
5412 }
5413 let outcome = self
5414 .conclusion
5415 .as_deref()
5416 .or(self.state.as_deref())
5417 .unwrap_or("");
5418 match outcome.to_ascii_uppercase().as_str() {
5419 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
5420 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
5421 | "ACTION_REQUIRED" => Verdict::Fail,
5422 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
5423 Verdict::Pending
5424 }
5425 _ => Verdict::Unknown,
5426 }
5427 }
5428}
5429
5430#[derive(Debug, Deserialize)]
5431struct GhAuthor {
5432 #[serde(default)]
5433 login: String,
5434}
5435
5436#[derive(Debug, Deserialize)]
5437struct GhReview {
5438 #[serde(default)]
5439 author: GhAuthor,
5440 #[serde(default)]
5441 body: String,
5442}
5443
5444#[derive(Debug, Deserialize)]
5445struct GhComment {
5446 #[serde(default)]
5447 author: GhAuthor,
5448 #[serde(default)]
5449 body: String,
5450}
5451
5452#[derive(Debug, Deserialize)]
5453struct GhUser {
5454 #[serde(default)]
5455 login: String,
5456}
5457
5458#[derive(Debug, Deserialize)]
5459struct GhInline {
5460 #[serde(default)]
5461 user: GhUser,
5462 #[serde(default)]
5463 path: Option<String>,
5464 #[serde(default)]
5465 line: Option<u64>,
5466 #[serde(default)]
5467 body: String,
5468}
5469
5470impl Default for GhAuthor {
5471 fn default() -> Self {
5472 Self {
5473 login: "(unknown)".to_owned(),
5474 }
5475 }
5476}
5477
5478impl Default for GhUser {
5479 fn default() -> Self {
5480 Self {
5481 login: "(unknown)".to_owned(),
5482 }
5483 }
5484}
5485
5486#[cfg(test)]
5487pub(crate) mod tests {
5488 use super::*;
5489 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
5490
5491 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
5492 format!(
5493 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
5494 )
5495 }
5496
5497 #[test]
5498 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
5499 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
5500 assert_eq!(
5501 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
5502 Ok(())
5503 );
5504 for (json, why) in [
5505 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
5506 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
5507 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
5508 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
5509 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
5510 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
5511 ] {
5512 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
5513 .unwrap_err()
5514 .why;
5515 assert!(err.contains(why), "{json}: {err}");
5516 }
5517 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
5519 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
5520 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
5521 assert!(
5522 !closable(
5523 &head_json("x", "main", "OPEN", false),
5524 "magi/27b2/A",
5525 "main",
5526 &[]
5527 )
5528 .unwrap_err()
5529 .retry
5530 );
5531 assert!(is_forge_url("https://github.com/o/r.git"));
5532 assert!(is_forge_url("git@github.com:o/r.git"));
5533 assert!(!is_forge_url("/tmp/origin.git"));
5534 assert!(!is_forge_url("C:\\work\\origin.git"));
5535 assert!(!is_forge_url("file:///tmp/origin.git"));
5536 assert!(forge_unavailable(
5537 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
5538 ));
5539 assert!(!forge_unavailable(
5540 "gh pr list failed: error connecting to api.github.com"
5541 ));
5542 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
5543 assert!(
5545 closable(
5546 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
5547 "b",
5548 "main",
5549 &["aaa".to_owned()]
5550 )
5551 .is_err()
5552 );
5553 }
5554
5555 #[test]
5556 fn the_close_comment_names_the_commit_on_the_base() {
5557 let e = crate::already::Evidence {
5558 proof: crate::already::Proof::PatchId,
5559 tip: "1234567890".to_owned(),
5560 commits: vec!["0e368de0000".to_owned()],
5561 };
5562 let c = superseded_comment("main", &e);
5563 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
5564 }
5565
5566 const GREEN_OPEN: &str = r####"{
5568 "url": "https://github.com/yukimemi/magi/pull/10",
5569 "number": 10,
5570 "state": "OPEN",
5571 "mergeStateStatus": "CLEAN",
5572 "statusCheckRollup": [
5573 {
5574 "__typename": "CheckRun",
5575 "conclusion": "SKIPPED",
5576 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
5577 "name": "review",
5578 "status": "COMPLETED",
5579 "workflowName": "claude-review"
5580 },
5581 {
5582 "__typename": "CheckRun",
5583 "conclusion": "SUCCESS",
5584 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
5585 "name": "check (ubuntu-latest)",
5586 "status": "COMPLETED",
5587 "workflowName": "CI"
5588 },
5589 {
5590 "__typename": "CheckRun",
5591 "conclusion": "SUCCESS",
5592 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
5593 "name": "rustfmt",
5594 "status": "COMPLETED",
5595 "workflowName": "CI"
5596 },
5597 {
5598 "__typename": "StatusContext",
5599 "context": "CodeRabbit",
5600 "state": "SUCCESS",
5601 "targetUrl": ""
5602 }
5603 ],
5604 "reviews": [],
5605 "comments": [
5606 {
5607 "author": {
5608 "login": "coderabbitai"
5609 },
5610 "authorAssociation": "NONE",
5611 "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"
5612 }
5613 ]
5614}"####;
5615
5616 const RED_OPEN: &str = r####"{
5618 "url": "https://github.com/yukimemi/magi/pull/9",
5619 "number": 9,
5620 "state": "OPEN",
5621 "mergeStateStatus": "UNSTABLE",
5622 "statusCheckRollup": [
5623 {
5624 "__typename": "CheckRun",
5625 "conclusion": "SUCCESS",
5626 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5627 "name": "check (ubuntu-latest)",
5628 "status": "COMPLETED",
5629 "workflowName": "CI"
5630 },
5631 {
5632 "__typename": "CheckRun",
5633 "conclusion": "SUCCESS",
5634 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5635 "name": "rustfmt",
5636 "status": "COMPLETED",
5637 "workflowName": "CI"
5638 },
5639 {
5640 "__typename": "CheckRun",
5641 "conclusion": "FAILURE",
5642 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5643 "name": "editorconfig",
5644 "status": "COMPLETED",
5645 "workflowName": "CI"
5646 },
5647 {
5648 "__typename": "StatusContext",
5649 "context": "CodeRabbit",
5650 "state": "SUCCESS",
5651 "targetUrl": ""
5652 }
5653 ],
5654 "reviews": [],
5655 "comments": [
5656 {
5657 "author": {
5658 "login": "coderabbitai"
5659 },
5660 "authorAssociation": "NONE",
5661 "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"
5662 }
5663 ]
5664}"####;
5665
5666 const PENDING_OPEN: &str = r####"{
5668 "url": "https://github.com/yukimemi/magi/pull/9",
5669 "number": 9,
5670 "state": "OPEN",
5671 "statusCheckRollup": [
5672 {
5673 "__typename": "CheckRun",
5674 "conclusion": "SUCCESS",
5675 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5676 "name": "check (ubuntu-latest)",
5677 "status": "COMPLETED",
5678 "workflowName": "CI"
5679 },
5680 {
5681 "__typename": "CheckRun",
5682 "conclusion": "SUCCESS",
5683 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5684 "name": "rustfmt",
5685 "status": "COMPLETED",
5686 "workflowName": "CI"
5687 },
5688 {
5689 "__typename": "CheckRun",
5690 "conclusion": null,
5691 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5692 "name": "editorconfig",
5693 "status": "IN_PROGRESS",
5694 "workflowName": "CI"
5695 },
5696 {
5697 "__typename": "StatusContext",
5698 "context": "CodeRabbit",
5699 "state": "SUCCESS",
5700 "targetUrl": ""
5701 }
5702 ],
5703 "reviews": [],
5704 "comments": []
5705}"####;
5706
5707 const MERGED: &str = r####"{
5709 "url": "https://github.com/yukimemi/magi/pull/16",
5710 "number": 16,
5711 "state": "MERGED",
5712 "statusCheckRollup": [
5713 {
5714 "__typename": "CheckRun",
5715 "conclusion": "SUCCESS",
5716 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
5717 "name": "check (ubuntu-latest)",
5718 "status": "COMPLETED",
5719 "workflowName": "CI"
5720 },
5721 {
5722 "__typename": "CheckRun",
5723 "conclusion": "SUCCESS",
5724 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
5725 "name": "review",
5726 "status": "COMPLETED",
5727 "workflowName": "claude-review"
5728 }
5729 ],
5730 "reviews": [],
5731 "comments": []
5732}"####;
5733
5734 const REVIEWED_OPEN: &str = r####"{
5736 "url": "https://github.com/yukimemi/magi/pull/12",
5737 "number": 12,
5738 "state": "OPEN",
5739 "statusCheckRollup": [
5740 {
5741 "__typename": "CheckRun",
5742 "conclusion": "SUCCESS",
5743 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
5744 "name": "check (ubuntu-latest)",
5745 "status": "COMPLETED",
5746 "workflowName": "CI"
5747 },
5748 {
5749 "__typename": "CheckRun",
5750 "conclusion": "SUCCESS",
5751 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
5752 "name": "review",
5753 "status": "COMPLETED",
5754 "workflowName": "claude-review"
5755 }
5756 ],
5757 "reviews": [
5758 {
5759 "author": {
5760 "login": "claude"
5761 },
5762 "state": "COMMENTED",
5763 "body": ""
5764 }
5765 ],
5766 "comments": [
5767 {
5768 "author": {
5769 "login": "coderabbitai"
5770 },
5771 "authorAssociation": "NONE",
5772 "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"
5773 },
5774 {
5775 "author": {
5776 "login": "claude"
5777 },
5778 "authorAssociation": "NONE",
5779 "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"
5780 }
5781 ]
5782}"####;
5783
5784 const INLINE: &str = r####"[
5786 {
5787 "user": {
5788 "login": "claude[bot]"
5789 },
5790 "path": "src/graph.rs",
5791 "line": 231,
5792 "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"
5793 }
5794]"####;
5795
5796 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
5798<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
5799
5800> [!IMPORTANT]
5801> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
5802>
5803> This repository does not receive automatic reviews because it has fewer than 10 stars.
5804>
5805> <details>
5806> <summary>⚙️ Run configuration</summary>
5807>
5808> **Configuration used**: defaults
5809>
5810> **Review profile**: CHILL
5811>
5812> **Plan**: Team
5813>
5814> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
5815>
5816> </details>
5817
5818<!-- end of auto-generated comment: skip review by coderabbit.ai -->
5819
5820<!-- tips_start -->
5821
5822---
5823
5824Thanks 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.
5825
5826<details>
5827<summary>❤️ Share</summary>
5828
5829- [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"####;
5830
5831 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
5833
5834---
5835### Reviewing PR #16
5836
5837- [x] Read AGENTS.md conventions
5838- [x] Review `src/daemon.rs` changes
5839- [x] Review `src/main.rs` changes (new `doctor` reporting)
5840- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
5841- [x] Check test coverage for new behavior
5842- [x] Run verification commands (blocked — see note)
5843- [x] Post findings"####;
5844
5845 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
5847
5848---
5849### Review: `magi review <branch>` — cheap-half-only graph
5850
5851Read 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.
5852
5853**Correctness**
5854
5855- 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"####;
5856
5857 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
5858 PrState {
5859 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
5860 number: 16,
5861 state: PrLifecycle::Open,
5862 checks,
5863 blocking: if matches!(checks, Checks::Red) {
5867 Blocking::Yes
5868 } else {
5869 Blocking::No
5870 },
5871 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
5872 review_comments: (0..comments)
5873 .map(|i| ReviewComment {
5874 author: "coderabbitai".to_owned(),
5875 path: Some("src/graph.rs".to_owned()),
5876 line: Some(231),
5877 body: format!("finding {i}"),
5878 })
5879 .collect(),
5880 }
5881 }
5882
5883 #[test]
5884 fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
5885 use CiExpectation::{Absent, Expected};
5886 for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
5887 let p = pr(checks, &["x"], 0);
5888 for waited in [Duration::ZERO, CHECKS_GRACE] {
5889 assert_eq!(
5890 decide_with(&p, 0, 4, waited, Expected),
5891 decide(&p, 0, 4, waited)
5892 );
5893 }
5894 }
5895 for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
5897 let p = pr(checks, &["x"], 0);
5898 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
5899 assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
5900 }
5901 let mut p = pr(Checks::Unknown, &[], 0);
5903 p.blocking = Blocking::Conflict;
5904 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
5905 p.state = PrLifecycle::Merged;
5907 assert_eq!(
5908 decide_with(&p, 0, 4, Duration::ZERO, Absent),
5909 Step::Done { merged: true }
5910 );
5911 }
5912
5913 #[test]
5914 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
5915 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
5916 assert_eq!(state.number, 10);
5917 assert_eq!(state.state, PrLifecycle::Open);
5918 assert_eq!(state.checks, Checks::Green);
5919 assert!(state.failing.is_empty());
5920 assert!(
5921 state.review_comments.is_empty(),
5922 "the only comment is CodeRabbit's trigger notice: {:?}",
5923 state.review_comments
5924 );
5925 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
5926 }
5927
5928 #[test]
5929 fn a_failing_check_parses_as_red_and_is_named() {
5930 let state = parse_pr(RED_OPEN).expect("red fixture parses");
5931 assert_eq!(state.checks, Checks::Red);
5932 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
5933 let mut blocking = state.clone();
5940 blocking.blocking = Blocking::Yes;
5941 match decide(&blocking, 0, 4, Duration::ZERO) {
5942 Step::Fix { reason } => {
5943 assert!(reason.contains("editorconfig"), "reason: {reason}");
5944 assert!(reason.contains("failing"), "reason: {reason}");
5945 }
5946 other => panic!("expected a fix round, got {other:?}"),
5947 }
5948 }
5949
5950 #[test]
5951 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
5952 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
5953 assert_eq!(state.checks, Checks::Pending);
5954 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
5955 }
5956
5957 #[test]
5958 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
5959 let state = parse_pr(MERGED).expect("merged fixture parses");
5960 assert_eq!(state.state, PrLifecycle::Merged);
5961 assert_eq!(
5962 decide(&state, 0, 4, Duration::ZERO),
5963 Step::Done { merged: true }
5964 );
5965 }
5966
5967 #[test]
5968 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
5969 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
5970 assert_eq!(state.checks, Checks::Green);
5971 let authors: Vec<&str> = state
5972 .review_comments
5973 .iter()
5974 .map(|c| c.author.as_str())
5975 .collect();
5976 assert_eq!(
5977 authors,
5978 vec!["claude"],
5979 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
5980 );
5981 match decide(&state, 0, 4, Duration::ZERO) {
5982 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
5983 other => panic!("expected a fix round, got {other:?}"),
5984 }
5985 }
5986
5987 #[test]
5988 fn inline_review_comments_keep_their_file_and_line() {
5989 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
5990 assert_eq!(comments.len(), 1);
5991 assert_eq!(comments[0].author, "claude[bot]");
5992 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
5993 assert_eq!(comments[0].line, Some(231));
5994 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
5995 }
5996
5997 #[test]
5998 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
5999 assert!(
6000 is_noise(CODERABBIT_TRIGGER),
6001 "CodeRabbit's trigger notice declares itself not a review"
6002 );
6003 assert!(
6004 is_noise(CLAUDE_CHECKLIST),
6005 "a progress checklist asks for nothing"
6006 );
6007 assert!(
6008 !is_noise(CLAUDE_FINDING),
6009 "a review that names a bug is input, not noise"
6010 );
6011
6012 let mut clean = pr(Checks::Green, &[], 0);
6013 clean.review_comments.push(ReviewComment {
6014 author: "coderabbitai".to_owned(),
6015 path: None,
6016 line: None,
6017 body: CODERABBIT_TRIGGER.to_owned(),
6018 });
6019 clean.review_comments.retain(|c| !is_noise(&c.body));
6020 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
6021
6022 let mut found = pr(Checks::Green, &[], 0);
6023 found.review_comments.push(ReviewComment {
6024 author: "claude".to_owned(),
6025 path: None,
6026 line: None,
6027 body: CLAUDE_FINDING.to_owned(),
6028 });
6029 found.review_comments.retain(|c| !is_noise(&c.body));
6030 assert!(matches!(
6031 decide(&found, 0, 4, Duration::ZERO),
6032 Step::Fix { .. }
6033 ));
6034 }
6035
6036 #[test]
6037 fn the_policy_table_holds_for_every_combination_that_matters() {
6038 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
6039 (
6040 "pending checks are waited for, even on the last round",
6041 pr(Checks::Pending, &[], 0),
6042 4,
6043 4,
6044 Duration::ZERO,
6045 Step::Wait,
6046 ),
6047 (
6048 "red checks are fixed",
6049 pr(Checks::Red, &["editorconfig"], 0),
6050 0,
6051 4,
6052 Duration::ZERO,
6053 Step::Fix {
6054 reason: "1 check(s) failing: editorconfig".to_owned(),
6055 },
6056 ),
6057 (
6058 "green with comments is fixed, not merged",
6059 pr(Checks::Green, &[], 2),
6060 1,
6061 4,
6062 Duration::ZERO,
6063 Step::Fix {
6064 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
6065 .to_owned(),
6066 },
6067 ),
6068 (
6069 "green and clean merges",
6070 pr(Checks::Green, &[], 0),
6071 3,
6072 4,
6073 Duration::ZERO,
6074 Step::Merge,
6075 ),
6076 (
6077 "an unreadable rollup is waited on while the grace lasts",
6078 pr(Checks::Unknown, &[], 0),
6079 0,
6080 4,
6081 Duration::ZERO,
6082 Step::Wait,
6083 ),
6084 (
6085 "an unreadable rollup is never merged once the grace is spent",
6086 pr(Checks::Unknown, &[], 0),
6087 0,
6088 4,
6089 CHECKS_GRACE,
6090 Step::GiveUp {
6091 reason: "no check status is readable on the pull request after 3 minute(s); \
6092 refusing to merge on a guess"
6093 .to_owned(),
6094 },
6095 ),
6096 ];
6097 for (what, state, round, budget, waited, want) in cases {
6098 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
6099 }
6100 }
6101
6102 #[test]
6103 fn the_forge_verdict_survives_the_round_trip_from_gh() {
6104 let green = parse_pr(GREEN_OPEN).expect("parse");
6108 assert_eq!(green.blocking, Blocking::No);
6109 let red = parse_pr(RED_OPEN).expect("parse");
6110 assert_eq!(
6111 red.blocking,
6112 Blocking::No,
6113 "`UNSTABLE` is mergeable: the red check is one nobody requires"
6114 );
6115 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
6116 let quiet =
6118 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
6119 assert_eq!(quiet.blocking, Blocking::Unsaid);
6120 }
6121
6122 #[test]
6123 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
6124 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
6130 nonblocking.blocking = Blocking::No;
6131 assert_eq!(
6132 decide(&nonblocking, 0, 4, Duration::ZERO),
6133 Step::Merge,
6134 "the forge says nothing is in the way, so nothing is"
6135 );
6136
6137 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
6139 blocking.blocking = Blocking::Yes;
6140 assert!(matches!(
6141 decide(&blocking, 0, 4, Duration::ZERO),
6142 Step::Fix { .. }
6143 ));
6144
6145 let mut commented = pr(Checks::Red, &["coverage"], 1);
6148 commented.blocking = Blocking::No;
6149 assert!(matches!(
6150 decide(&commented, 0, 4, Duration::ZERO),
6151 Step::Fix { .. }
6152 ));
6153
6154 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
6156 unsaid.blocking = Blocking::Unsaid;
6157 assert!(matches!(
6158 decide(&unsaid, 0, 4, Duration::ZERO),
6159 Step::Fix { .. }
6160 ));
6161 }
6162
6163 #[test]
6164 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
6165 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
6166 red.blocking = Blocking::No;
6167 assert_eq!(
6168 decide(&red, 0, 4, Duration::ZERO),
6169 Step::Merge,
6170 "announcing must not change the decision"
6171 );
6172 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
6173 assert!(said.contains("yukimemi/magi"), "{said}");
6174 assert!(said.contains("#16"), "{said}");
6175 assert!(
6176 said.contains("https://github.com/yukimemi/magi/pull/16"),
6177 "{said}"
6178 );
6179 assert!(
6180 said.contains("test (windows-latest)") && said.contains("coverage"),
6181 "{said}"
6182 );
6183
6184 let green = pr(Checks::Green, &["stale"], 0);
6186 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
6187 }
6188
6189 #[test]
6190 fn red_merge_notices_omit_empty_check_details() {
6191 for checks in [vec![], vec!["", " "]] {
6192 let red = pr(Checks::Red, &checks, 0);
6193 let summary = red_merge_summary("owner/repo", &red).unwrap();
6194 let notice = crate::notices::merged_red("run-1", &summary);
6195 assert_eq!(
6196 notice.message,
6197 format!("Merged owner/repo PR #16 with red checks ({})", red.url)
6198 );
6199 assert_eq!(notice.severity, crate::notices::Severity::Warn);
6200 }
6201 let red = pr(Checks::Red, &["", " test ", " "], 0);
6202 let summary = red_merge_summary("owner/repo", &red).unwrap();
6203 assert!(summary.contains("with red checks: test ("), "{summary}");
6204 }
6205
6206 #[test]
6207 fn the_repo_label_comes_from_the_pull_request_url() {
6208 let p = Path::new("/tmp/checkout");
6209 assert_eq!(
6210 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
6211 "yukimemi/magi"
6212 );
6213 assert_eq!(repo_label(p, "not a url"), "checkout");
6214 }
6215
6216 #[test]
6217 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
6218 let mut conflicted = pr(Checks::Green, &[], 0);
6223 conflicted.blocking = Blocking::Conflict;
6224 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
6225
6226 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
6230 red.blocking = Blocking::Conflict;
6231 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
6232
6233 let mut merged = pr(Checks::Red, &[], 0);
6235 merged.blocking = Blocking::Conflict;
6236 merged.state = PrLifecycle::Merged;
6237 assert_eq!(
6238 decide(&merged, 0, 4, Duration::ZERO),
6239 Step::Done { merged: true }
6240 );
6241 }
6242
6243 #[test]
6244 fn the_forge_verdict_is_read_off_merge_state_status() {
6245 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
6248 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
6249 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
6250 }
6251 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
6252 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
6253 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
6254 for quiet in ["", "UNKNOWN"] {
6257 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
6258 assert!(Blocking::of(quiet).stops_a_merge());
6259 }
6260 }
6261
6262 #[test]
6263 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
6264 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
6265 let jj = "could not determine current branch: failed to run git: not on any branch";
6267
6268 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
6269 .expect("the forge says merged, so it merged");
6270 assert!(landed.ok);
6271 assert!(
6272 landed.detail.contains("but the pull request is merged"),
6273 "the record must not read as a clean success: {}",
6274 landed.detail
6275 );
6276 assert!(
6277 landed.detail.contains("not on any branch"),
6278 "and it must keep what the command actually said: {}",
6279 landed.detail
6280 );
6281
6282 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
6284 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
6285 assert!(merged_after_all(&argv, jj, None).is_none());
6287 }
6288
6289 #[test]
6290 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
6291 let mut state = pr(Checks::Red, &["editorconfig"], 3);
6292 state.state = PrLifecycle::Closed;
6293 assert_eq!(
6294 decide(&state, 0, 4, Duration::ZERO),
6295 Step::Done { merged: false },
6296 "a human closing the pull request ends the loop, whatever CI says"
6297 );
6298 }
6299
6300 #[test]
6301 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
6302 let red = decide(
6303 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
6304 4,
6305 4,
6306 Duration::ZERO,
6307 );
6308 match red {
6309 Step::GiveUp { reason } => {
6310 assert!(reason.contains("editorconfig"), "reason: {reason}");
6311 assert!(reason.contains("test (macos)"), "reason: {reason}");
6312 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
6313 }
6314 other => panic!("expected a give-up, got {other:?}"),
6315 }
6316
6317 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
6318 match commented {
6319 Step::GiveUp { reason } => {
6320 assert!(reason.contains("unresolved"), "reason: {reason}");
6321 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
6322 }
6323 other => panic!("expected a give-up, got {other:?}"),
6324 }
6325 }
6326
6327 #[test]
6328 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
6329 let candidate_commit = "magi: candidate A (uncommitted work)";
6330 let subject = merge_subject(candidate_commit, "add retries to the uploader");
6331 let argv = merge_argv(16, &subject);
6332
6333 assert!(argv.contains(&"--squash".to_owned()));
6334 assert!(argv.contains(&"--delete-branch".to_owned()));
6335 assert!(argv.contains(&"--subject".to_owned()));
6336 assert_eq!(
6337 argv.last().map(String::as_str),
6338 Some("add retries to the uploader"),
6339 "the subject must not be the candidate commit message"
6340 );
6341 assert_ne!(subject, candidate_commit);
6342 }
6343
6344 #[test]
6345 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
6346 assert_eq!(
6347 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
6348 "feat: a queue, an unattended loop, and a phone UI"
6349 );
6350 assert_eq!(
6351 merge_subject("", "# port the retry logic\n\ndetails"),
6352 "port the retry logic",
6353 "an empty title falls back to the task's first line, heading marks stripped"
6354 );
6355 }
6356
6357 #[test]
6358 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
6359 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
6360 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
6361 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
6362 assert_eq!(job_of("https://coderabbit.ai/status"), None);
6363 assert_eq!(run_of(""), None);
6364 }
6365
6366 #[test]
6367 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
6368 let mut out = Vec::new();
6369 push_if_outstanding(
6370 &mut out,
6371 ReviewComment {
6372 author: "yukimemi".to_owned(),
6373 path: None,
6374 line: None,
6375 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
6376 },
6377 );
6378 assert!(out.is_empty());
6379 }
6380
6381 fn run_state() -> RunState {
6385 let mut state = RunState::new(
6386 std::path::PathBuf::from("/repo/magi"),
6387 "main".to_owned(),
6388 "abcdef1234".to_owned(),
6389 "add retries to the uploader".to_owned(),
6390 crate::config::Config::default(),
6391 );
6392 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
6397 let nanos = std::time::SystemTime::now()
6398 .duration_since(std::time::UNIX_EPOCH)
6399 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
6400 state.id = format!(
6401 "20261004-{nanos:06}-{:04x}",
6402 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
6403 );
6404 state
6405 }
6406
6407 fn green_pr() -> PrState {
6408 PrState {
6409 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
6410 number: 42,
6411 state: PrLifecycle::Open,
6412 checks: Checks::Green,
6413 blocking: Blocking::No,
6415 failing: Vec::new(),
6416 review_comments: vec![ReviewComment {
6417 author: "coderabbitai".to_owned(),
6418 path: Some("src/land.rs".to_owned()),
6419 line: Some(212),
6420 body: "this branch never checks the exit code".to_owned(),
6421 }],
6422 }
6423 }
6424
6425 #[test]
6426 fn land_fixer_defers_review_and_verification_without_a_cache() {
6427 let state = run_state();
6428 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6429 assert!(p.contains(crate::prompt::child_run_ownership()), "{p}");
6430 assert!(p.contains("Commit in this worktree"), "{p}");
6431 }
6432
6433 #[test]
6434 fn github_facing_land_text_is_english_whatever_the_language() {
6435 let mut state = run_state();
6436 state.config.graph.language = "ja".to_owned();
6437 let comment = stop_comment(&state.id, "checks are still red");
6438 assert!(crate::github_text::check("", &comment).is_empty());
6439 assert!(comment.is_ascii(), "{comment}");
6440 assert!(comment.starts_with(MARKER));
6441
6442 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6443 let ja_at = p.find("Write all prose in ja").unwrap();
6444 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
6445 assert!(ja_at < rule_at, "{p}");
6446 assert!(p.contains("stays in Japanese"), "{p}");
6447
6448 state.config.graph.language = "en".to_owned();
6449 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6450 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
6451 assert!(!p.contains("does not apply"), "{p}");
6452 }
6453
6454 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
6455
6456 fn panel() -> String {
6457 approval_panel(
6458 &run_state(),
6459 &green_pr(),
6460 NUMSTAT,
6461 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
6462 &[
6463 "land: ask before merging".to_owned(),
6464 "land: colour the diff".to_owned(),
6465 ],
6466 "feat: merge approval from the phone",
6467 )
6468 }
6469
6470 #[test]
6471 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
6472 let html = panel();
6473 for needle in [
6474 "42",
6475 "main",
6476 "src/land.rs",
6477 "src/web.rs",
6478 "assets/logo.png",
6479 "feat: merge approval from the phone",
6480 "land: ask before merging",
6481 "land: colour the diff",
6482 "coderabbitai",
6483 "this branch never checks the exit code",
6484 "green",
6485 ] {
6486 assert!(html.contains(needle), "the panel must state `{needle}`");
6487 }
6488 }
6489
6490 fn winning_candidate(summary: &str) -> Candidate {
6493 Candidate {
6494 index: 0,
6495 label: 'A',
6496 agent: "opus".to_owned(),
6497 branch: "magi/x/A".to_owned(),
6498 worktree: PathBuf::from("/wt/A"),
6499 summary: summary.to_owned(),
6500 stat: String::new(),
6501 files: 1,
6502 commits: 1,
6503 empty: false,
6504 failed: None,
6505 verified_noop: None,
6506 duration_ms: 0,
6507 folded: false,
6508 }
6509 }
6510
6511 fn uncontested_tally() -> Tally {
6512 Tally {
6513 first_choice: BTreeMap::from([('A', 1)]),
6514 borda: BTreeMap::new(),
6515 winner: 'A',
6516 rankings: 1,
6517 unanimous_initial: true,
6518 deliberated: false,
6519 changed_votes: 0,
6520 unanimous_final: true,
6521 tie_break: None,
6522 judges: 1,
6523 present: 1,
6524 quorum: 1,
6525 met_quorum: true,
6526 uncontested: None,
6527 }
6528 }
6529
6530 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
6531 ReviewRecord {
6532 attempts: 0,
6533 reviewer,
6534 agent: agent.to_owned(),
6535 summary: summary.to_owned(),
6536 findings: Vec::new(),
6537 vote: None,
6538 failed: None,
6539 duration_ms: 0,
6540 }
6541 }
6542
6543 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
6544 let answered = reviews.len();
6545 ReviewRound {
6546 round,
6547 head: "abc1234".to_owned(),
6548 verified_head: None,
6549 verified_at: None,
6550 reviews,
6551 e2e: Vec::new(),
6552 verify_retried: false,
6553 e2e_deferred: false,
6554 e2e_defer_reason: None,
6555 fix: None,
6556 blocking: 0,
6557 answered,
6558 expected: answered,
6559 clean: true,
6560 progressed: false,
6561 vote_split: false,
6562 reconsideration: Vec::new(),
6563 verdict: None,
6564 }
6565 }
6566
6567 #[test]
6568 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
6569 let en = panel();
6570 assert!(en.contains("Task"), "{en}");
6571 assert!(en.contains("add retries to the uploader"), "{en}");
6572
6573 let mut state = run_state();
6574 state.config.graph.language = "ja".to_owned();
6575 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6576 assert!(ja.contains("タスク"), "{ja}");
6577 assert!(
6578 ja.contains("add retries to the uploader"),
6579 "the task itself is not translated: {ja}"
6580 );
6581 }
6582
6583 #[test]
6584 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
6585 let html = panel();
6589 assert!(!html.contains("What changed"), "{html}");
6590 assert!(!html.contains("Review verdict"), "{html}");
6591 }
6592
6593 #[test]
6594 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
6595 let mut state = run_state();
6596 state.candidates = vec![winning_candidate("")];
6597 state.tally = Some(uncontested_tally());
6598 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6599 assert!(
6600 !html.contains("What changed"),
6601 "an empty summary must not render an empty box: {html}"
6602 );
6603 }
6604
6605 #[test]
6606 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
6607 let mut state = run_state();
6608 state.candidates = vec![winning_candidate(
6609 "Added a retry loop around the uploader PUT call.",
6610 )];
6611 state.tally = Some(uncontested_tally());
6612 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6613 assert!(en.contains("What changed"), "{en}");
6614 assert!(
6615 en.contains("Added a retry loop around the uploader PUT call."),
6616 "{en}"
6617 );
6618
6619 state.config.graph.language = "ja".to_owned();
6620 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6621 assert!(ja.contains("変更内容"), "{ja}");
6622 assert!(
6623 ja.contains("Added a retry loop around the uploader PUT call."),
6624 "{ja}"
6625 );
6626 }
6627
6628 #[test]
6629 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
6630 let mut state = run_state();
6631 state.reviews = vec![
6632 review_round(
6633 1,
6634 vec![review_record(1, "alpha", "found a race, sent back")],
6635 ),
6636 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
6637 ];
6638 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6639 assert!(en.contains("Review verdict"), "{en}");
6640 assert!(en.contains("race is fixed, clean"), "{en}");
6641 assert!(
6642 !en.contains("found a race, sent back"),
6643 "only the round that actually cleared the merge should show: {en}"
6644 );
6645
6646 state.config.graph.language = "ja".to_owned();
6647 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6648 assert!(ja.contains("レビューの結論"), "{ja}");
6649 assert!(ja.contains("レビュアー"), "{ja}");
6650 assert!(ja.contains("race is fixed, clean"), "{ja}");
6651 }
6652
6653 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
6659 ReviewRecord {
6660 attempts: 0,
6661 reviewer,
6662 agent: agent.to_owned(),
6663 summary: String::new(),
6664 findings: Vec::new(),
6665 vote: None,
6666 failed: Some(reason.to_owned()),
6667 duration_ms: 0,
6668 }
6669 }
6670
6671 #[test]
6672 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
6673 let mut state = run_state();
6674 state.reviews = vec![review_round(
6675 1,
6676 vec![
6677 review_record(1, "alpha", "clean, nothing to add"),
6678 unanswered_review_record(2, "beta", "timed out"),
6679 ],
6680 )];
6681 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6682 assert!(en.contains("clean, nothing to add"), "{en}");
6683 assert!(
6684 en.contains("produced no answer: timed out"),
6685 "a seat that never answered must say so, not render a blank box: {en}"
6686 );
6687 assert!(
6688 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
6689 "no reviewer box may be left empty: {en}"
6690 );
6691
6692 state.config.graph.language = "ja".to_owned();
6693 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6694 assert!(ja.contains("回答なし: timed out"), "{ja}");
6695 }
6696
6697 #[test]
6698 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
6699 let html = panel();
6700 assert!(!html.contains("<script"), "no script survives the csp");
6701 assert!(!html.contains("<form"), "form-action is 'none'");
6702 let pr = green_pr();
6703 assert_eq!(
6704 html.matches("http").count(),
6705 html.matches(pr.url.as_str()).count(),
6706 "the only http url in the panel is the pull request's own link"
6707 );
6708 assert!(
6709 html.contains("target=\"_blank\""),
6710 "the PR link must open in a new tab so it escapes the iframe"
6711 );
6712 assert!(
6713 html.contains("rel=\"noopener noreferrer\""),
6714 "external links opening in new tabs must carry noopener noreferrer"
6715 );
6716 }
6717
6718 #[test]
6719 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
6720 let html = panel();
6721 assert!(
6722 html.contains(">+</span>"),
6723 "an added line carries a `+` in the gutter, not only a background"
6724 );
6725 assert!(
6726 html.contains(">-</span>"),
6727 "a removed line carries a `-` in the gutter, not only a background"
6728 );
6729 assert!(
6730 html.contains(">new line</span>"),
6731 "the marker is moved to the gutter, so the body is printed once without it"
6732 );
6733 }
6734
6735 #[test]
6736 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
6737 let total = DIFF_MAX_LINES + 100;
6738 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
6739 let html = approval_panel(
6740 &run_state(),
6741 &green_pr(),
6742 NUMSTAT,
6743 &diff,
6744 &[],
6745 "feat: something long",
6746 );
6747 assert!(
6748 html.contains(&format!("100 of {total} diff lines omitted")),
6749 "the note must say exactly how much was cut"
6750 );
6751 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
6752 assert!(
6753 !html.contains(&format!("line {DIFF_MAX_LINES}")),
6754 "nothing past the threshold is rendered"
6755 );
6756 assert!(
6757 html.contains("/repo/magi"),
6758 "the note says where the rest is"
6759 );
6760 }
6761
6762 #[test]
6763 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
6764 let html = approval_panel(
6765 &run_state(),
6766 &green_pr(),
6767 "1\t2\tsrc/<b>&\"x\"'.rs",
6768 "",
6769 &[],
6770 "subject",
6771 );
6772 assert!(html.contains("src/<b>&"x"'.rs"));
6773 assert!(
6774 !html.contains("<b>"),
6775 "an agent-influenced path must never become markup"
6776 );
6777 }
6778
6779 #[tokio::test]
6780 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
6781 let a = std::path::PathBuf::from("/repo/a");
6782 let b = std::path::PathBuf::from("/repo/b");
6783
6784 let held = repo_merge_lock(&a).lock_owned().await;
6785
6786 assert!(
6789 repo_merge_lock(&a).try_lock().is_err(),
6790 "a second merge into the same repository must not proceed concurrently"
6791 );
6792
6793 assert!(
6797 repo_merge_lock(&b).try_lock().is_ok(),
6798 "a different repository's merge lock must be independent"
6799 );
6800
6801 drop(held);
6802 assert!(
6803 repo_merge_lock(&a).try_lock().is_ok(),
6804 "the lock is released once the holder is done"
6805 );
6806 }
6807
6808 pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
6809 let mut state = landing_state();
6810 let mut rec = review_record(1, "alpha", "precise rejection reason");
6811 rec.vote = Some(if reject {
6812 crate::verdict::ReviewVote::Reject
6813 } else {
6814 crate::verdict::ReviewVote::Approve
6815 });
6816 if major {
6817 rec.findings = contested().findings;
6818 }
6819 let mut round = review_round(1, vec![rec]);
6820 round.clean = false;
6821 round.blocking = usize::from(major);
6822 state.reviews.push(round);
6823 state.config.graph.review_rounds = 1;
6824 state
6825 }
6826
6827 #[test]
6828 fn approval_policy_controls_are_independent_and_keep_all_reasons() {
6829 for every in [false, true] {
6830 for contested in [false, true] {
6831 for major in [false, true] {
6832 for reject in [false, true] {
6833 let mut state = repair_state(major, reject);
6834 state.config.graph.land_approval = every;
6835 state.config.graph.hold_contested_merge = contested;
6836 let reasons = approval_reasons(&state);
6837 assert_eq!(reasons.contains(&ApprovalReason::EveryMerge), every);
6838 assert_eq!(
6839 reasons.contains(&ApprovalReason::OpenBlockingFindings),
6840 contested && major
6841 );
6842 assert_eq!(
6843 reasons.contains(&ApprovalReason::FinalReject),
6844 contested && reject
6845 );
6846 assert_eq!(
6847 reasons.len(),
6848 usize::from(every)
6849 + usize::from(contested && major)
6850 + usize::from(contested && reject)
6851 );
6852 }
6853 }
6854 }
6855 }
6856 }
6857
6858 #[test]
6859 fn approval_policy_and_rendering_follow_reconsideration_in_both_directions() {
6860 use crate::verdict::ReviewVote::{Approve, Reject};
6861 for (initial_reject, final_vote) in [(true, Approve), (false, Reject)] {
6862 let mut state = repair_state(false, initial_reject);
6863 state.reviews[0]
6864 .reconsideration
6865 .push(crate::run::ReviewRevoteRecord {
6866 reviewer: 1,
6867 agent: "alpha".into(),
6868 vote: Some(final_vote),
6869 reason: "changed after reading the panel <evidence>".into(),
6870 failed: None,
6871 });
6872 assert_eq!(
6873 approval_reasons(&state).contains(&ApprovalReason::FinalReject),
6874 final_vote == Reject
6875 );
6876 for language in ["en", "ja"] {
6877 state.config.graph.language = language.into();
6878 let evidence = approval_evidence(&state);
6879 assert!(evidence.contains(&format!("→ {}", final_vote.label())));
6880 assert!(evidence.contains("changed after reading the panel <evidence>"));
6881 let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
6882 assert!(html.contains("changed after reading the panel <evidence>"));
6883 assert!(!html.contains("<evidence>"));
6884 }
6885 state.reviews[0].reconsideration[0].vote = None;
6886 state.reviews[0].reconsideration[0].failed = Some("timed out".into());
6887 assert_eq!(
6888 approval_reasons(&state).contains(&ApprovalReason::FinalReject),
6889 initial_reject
6890 );
6891 assert!(approval_evidence(&state).contains("timed out"));
6892 }
6893 }
6894
6895 #[tokio::test]
6896 async fn approval_merge_paths_apply_controls_to_major_and_reject_independently() {
6897 for every in [false, true] {
6898 for contested in [false, true] {
6899 for (major, reject) in [(false, false), (false, true), (true, false), (true, true)]
6900 {
6901 let mut state = repair_state(major, reject);
6902 state.config.graph.land_approval = every;
6903 state.config.graph.hold_contested_merge = contested;
6904 let forge = Scripted::new(
6905 vec![seen("abc", Checks::Green, "CLEAN", false)],
6906 vec![(true, "")],
6907 );
6908 land_with(&mut state, URL, &forge).await.unwrap();
6909 let expected = every || (contested && (major || reject));
6910 assert_eq!(state.land_approval.is_some(), expected);
6911 assert_eq!(forge.calls().contains(&"merge"), !expected);
6912 if expected {
6913 assert!(state.parked);
6914 }
6915 }
6916 }
6917 }
6918 }
6919
6920 #[tokio::test]
6921 async fn approval_snapshot_exposes_minor_findings_and_survives_disabled_controls() {
6922 let mut state = repair_state(false, true);
6923 state.reviews[0].clean = true;
6924 let mut finding = contested().findings.remove(0);
6925 finding.severity = crate::verdict::Severity::Minor;
6926 state.reviews[0].reviews[0].findings.push(finding);
6927 assert!(state.open_findings().is_empty());
6928 let mut pr = green_pr();
6929 pr.review_comments.clear();
6930 for language in ["en", "ja"] {
6931 state.config.graph.language = language.into();
6932 let html = approval_panel(&state, &pr, "", "", &[], "subject");
6933 assert!(html.contains("[R3-1-1] minor"));
6934 assert!(html.contains(if language == "ja" {
6935 "内部指摘の解決を意味するものではありません"
6936 } else {
6937 "This does not mean internal findings are resolved"
6938 }));
6939 assert!(
6940 html.find(if language == "ja" {
6941 "承認が必要な理由:"
6942 } else {
6943 "Approval required because:"
6944 })
6945 .unwrap()
6946 < html.find("[R3-1-1]").unwrap()
6947 );
6948 }
6949 state.config.graph.language = "en".into();
6950 let observed = seen("abc", Checks::Green, "CLEAN", false);
6951 assert_eq!(
6952 approval_gate(&mut state, &observed.pr, "subject", None, "abc")
6953 .await
6954 .unwrap(),
6955 ApprovalGate::Pending
6956 );
6957 let id = state.land_approval.as_ref().unwrap().question.clone();
6958 let q = ask::Questions::open().get(&id).unwrap();
6959 assert!(q.detail.starts_with("Approval required because:"));
6960 assert!(q.detail.contains("final reviewer reject"));
6961 assert!(q.detail.contains("[R3-1-1] minor"));
6962 assert!(
6963 q.detail
6964 .contains("No adoption or resolution evidence recorded")
6965 );
6966 state.config.graph.land_approval = false;
6967 state.config.graph.hold_contested_merge = false;
6968 state.reviews.clear();
6969 assert_eq!(approval_reasons(&state), [ApprovalReason::ExistingDecision]);
6970 assert!(deputy_brief(&q, Some(&state)).contains("final reviewer reject"));
6971 assert!(deputy_brief(&q, None).contains("[R3-1-1] minor"));
6972 let forge = Scripted::new(vec![observed], vec![]);
6973 land_with(&mut state, URL, &forge).await.unwrap();
6974 assert!(!forge.calls().contains(&"merge"));
6975 assert_eq!(state.land_approval.as_ref().unwrap().question, id);
6976 assert_eq!(ask::Questions::open().get(&id).unwrap().detail, q.detail);
6977 }
6978
6979 #[tokio::test]
6980 async fn approval_evidence_keeps_all_findings_and_bounds_unicode_prose() {
6981 let mut state = repair_state(false, true);
6982 state.config.graph.land_approval = false;
6983 let long_reason = format!("{}REASON_TAIL", "拒否🦀".repeat(200));
6984 let long_detail = format!("{}DETAIL_TAIL", "詳細🦀".repeat(200));
6985 let long_title = format!("{}TITLE_TAIL", "題🦀".repeat(100));
6986 state.reviews[0].reviews[0].summary = long_reason.clone();
6987 for i in 1..=10 {
6988 let mut finding = contested().findings.remove(0);
6989 finding.id = format!("R1-1-{i}");
6990 finding.severity = if i == 9 {
6991 crate::verdict::Severity::Major
6992 } else {
6993 crate::verdict::Severity::Minor
6994 };
6995 finding.title = long_title.clone();
6996 finding.detail = long_detail.clone();
6997 state.reviews[0].reviews[0].findings.push(finding);
6998 }
6999 state.reviews[0].fix = Some(crate::run::FixRecord {
7000 agent: "fixer".into(),
7001 addressed: vec!["R1-1-9".into()],
7002 rejected: vec![crate::verdict::Rejection {
7003 id: "R1-1-10".into(),
7004 why: long_reason.clone(),
7005 }],
7006 notes: long_detail.clone(),
7007 committed: false,
7008 failed: None,
7009 duration_ms: 0,
7010 continuation: None,
7011 });
7012 assert_eq!(
7013 approval_reasons(&state),
7014 [
7015 ApprovalReason::OpenBlockingFindings,
7016 ApprovalReason::FinalReject
7017 ]
7018 );
7019 let excerpt = approval_excerpt(&long_reason, 300);
7020 assert_eq!(excerpt.chars().count(), 300);
7021 assert!(excerpt.ends_with('…'));
7022 let boundary = "拒".repeat(300);
7023 assert_eq!(approval_excerpt(&boundary, 300), boundary);
7024 for language in ["en", "ja"] {
7025 state.config.graph.language = language.into();
7026 for revote in [
7027 None,
7028 Some(crate::verdict::ReviewVote::Reject),
7029 Some(crate::verdict::ReviewVote::Approve),
7030 ] {
7031 state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
7032 state.reviews[0].reconsideration.clear();
7033 if let Some(vote) = revote {
7034 state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Approve);
7035 state.reviews[0]
7036 .reconsideration
7037 .push(crate::run::ReviewRevoteRecord {
7038 reviewer: 1,
7039 agent: "alpha".into(),
7040 vote: Some(vote),
7041 reason: long_reason.clone(),
7042 failed: None,
7043 });
7044 }
7045 let evidence = approval_evidence(&state);
7046 let final_vote = revote.unwrap_or(crate::verdict::ReviewVote::Reject);
7047 assert!(evidence.contains(&format!("#1 (alpha): {}", final_vote.label())));
7048 assert!(approval_reasons(&state).contains(&ApprovalReason::OpenBlockingFindings));
7049 assert_eq!(
7050 approval_reasons(&state).contains(&ApprovalReason::FinalReject),
7051 final_vote == crate::verdict::ReviewVote::Reject
7052 );
7053 assert!(evidence.contains(&excerpt));
7054 assert!(evidence.contains("[R1-1-8] minor"));
7055 assert!(evidence.contains("[R1-1-9] major"));
7056 assert!(evidence.contains("[R1-1-10] minor"));
7057 for tail in ["REASON_TAIL", "DETAIL_TAIL", "TITLE_TAIL"] {
7058 assert!(!evidence.contains(tail));
7059 }
7060 assert!(evidence.contains(if language == "ja" {
7061 "採用申告(解決未確認)"
7062 } else {
7063 "claimed addressed (resolution unconfirmed)"
7064 }));
7065 assert!(evidence.contains(if language == "ja" {
7066 "不採用理由"
7067 } else {
7068 "declined"
7069 }));
7070 let major_evidence = evidence
7071 .split("[R1-1-9] major")
7072 .nth(1)
7073 .unwrap()
7074 .split("[R1-1-10]")
7075 .next()
7076 .unwrap();
7077 assert!(major_evidence.contains(if language == "ja" {
7078 "採用申告(解決未確認)"
7079 } else {
7080 "claimed addressed (resolution unconfirmed)"
7081 }));
7082 let last_evidence = evidence.split("[R1-1-10] minor").nth(1).unwrap();
7083 assert!(last_evidence.contains(if language == "ja" {
7084 "不採用理由"
7085 } else {
7086 "declined"
7087 }));
7088 let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
7089 assert!(html.contains(&esc(&evidence)));
7090 assert_eq!(
7091 approval_gate(&mut state, &green_pr(), "subject", None, "abc")
7092 .await
7093 .unwrap(),
7094 ApprovalGate::Pending
7095 );
7096 let id = &state.land_approval.take().unwrap().question;
7097 let q = ask::Questions::open().get(id).unwrap();
7098 assert!(q.detail.starts_with(&evidence));
7099 }
7100 state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
7101 }
7102 }
7103
7104 #[test]
7105 fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
7106 for (major, reject) in [(true, false), (false, true), (true, true)] {
7107 let state = repair_state(major, reject);
7108 assert!(repair_available(&state));
7109 assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
7110 }
7111 assert!(!repair_available(&repair_state(false, false)));
7112 let mut clean = repair_state(true, false);
7113 clean.reviews[0].clean = true;
7114 assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
7115 assert_eq!(approval(Some(REPAIR)), Approval::Repair);
7116 }
7117
7118 #[tokio::test]
7119 async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
7120 let mut state = repair_state(true, false);
7121 let pr = green_pr();
7122 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7123 .await
7124 .unwrap();
7125 let store = ask::Questions::open();
7126 let id = state.land_approval.as_ref().unwrap().question.clone();
7127 store
7129 .update(&id, |q| {
7130 q.choices = vec![APPROVE.into(), HOLD.into()];
7131 Ok(())
7132 })
7133 .unwrap();
7134 let upgraded = store.get(&id).unwrap();
7135 assert_eq!(upgraded.choices, approval_choices(&state));
7136 assert!(!upgraded.actions.contains_key(HOLD));
7137 assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
7138 assert!(
7139 store
7140 .list()
7141 .iter()
7142 .any(|q| q.id == id && q.choices == upgraded.choices)
7143 );
7144 assert!(
7145 crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
7146 );
7147 assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
7148 store
7149 .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
7150 .unwrap();
7151 store
7154 .update(&id, |q| {
7155 q.actions.clear();
7156 Ok(())
7157 })
7158 .unwrap();
7159 assert_eq!(
7160 store.get(&id).unwrap().chosen_action(),
7161 Some(&ask::ChoiceAction::Resume {
7162 run: state.id.clone()
7163 })
7164 );
7165 let binding = state.land_approval.clone();
7166 assert_eq!(
7167 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7168 .await
7169 .unwrap(),
7170 ApprovalGate::Repair
7171 );
7172 assert!(state.land_approval.is_none());
7173 assert_eq!(state.merge_repairs.len(), 1);
7174 let request = &state.merge_repairs[0];
7175 assert_eq!(request.question, id);
7176 assert_eq!(request.head, "abc");
7177 assert_eq!(request.pr, pr.url);
7178 assert_eq!(
7179 request.reviews[0].reviews[0].summary,
7180 "precise rejection reason"
7181 );
7182 assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
7183 assert_eq!(
7184 request.reviews[0].reviews[0].findings[0].detail,
7185 state.reviews[0].reviews[0].findings[0].detail
7186 );
7187 state.land_approval = binding;
7189 assert_eq!(
7190 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7191 .await
7192 .unwrap(),
7193 ApprovalGate::Repair
7194 );
7195 assert_eq!(state.merge_repairs.len(), 1);
7196 state.land_approval = None;
7197 state.config.graph.land_approval = false;
7198 state.config.graph.hold_contested_merge = false;
7199 state.reviews.clear();
7200 assert_eq!(approval_reasons(&state), [ApprovalReason::AfterRepair]);
7201 assert_eq!(
7202 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7203 .await
7204 .unwrap(),
7205 ApprovalGate::Pending
7206 );
7207 let fresh_id = &state.land_approval.as_ref().unwrap().question;
7208 assert_ne!(fresh_id, &id);
7209 let fresh = store.get(fresh_id).unwrap();
7210 assert!(
7211 fresh
7212 .detail
7213 .contains("fresh owner approval after merge repair")
7214 );
7215 assert!(fresh.detail.contains("No review recorded"));
7216 assert!(!fresh.detail.contains("precise rejection reason"));
7217 }
7218
7219 #[tokio::test]
7220 async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
7221 for choice in [REPAIR, HOLD] {
7222 let mut state = repair_state(false, true);
7223 let observed = seen("abc", Checks::Green, "CLEAN", false);
7224 approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
7225 .await
7226 .unwrap();
7227 let id = state.land_approval.as_ref().unwrap().question.clone();
7228 ask::Questions::open()
7229 .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
7230 .unwrap();
7231 let forge = Scripted::new(vec![observed], vec![(true, "")]);
7232 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7233 .await
7234 .unwrap();
7235 assert!(
7236 forge
7237 .argvs()
7238 .iter()
7239 .all(|a| a.iter().any(|s| s == "--disable-auto"))
7240 );
7241 assert!(!forge.calls().contains(&"fix"));
7242 assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
7243 assert_ne!(state.status, RunStatus::Merged);
7244 }
7245 }
7246
7247 #[test]
7248 fn only_the_merge_choice_merges_and_silence_holds() {
7249 let table = [
7250 (None, Approval::Hold),
7251 (Some("merge"), Approval::Merge),
7252 (Some(" merge\n"), Approval::Merge),
7253 (Some("hold"), Approval::Hold),
7254 (Some(""), Approval::Hold),
7255 (Some("yes"), Approval::Hold),
7256 ];
7257 for (answer, want) in table {
7258 assert_eq!(
7259 approval(answer),
7260 want,
7261 "answer {answer:?} must resolve to {want:?}"
7262 );
7263 }
7264 }
7265
7266 #[tokio::test]
7267 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
7268 let mut state = landing_state();
7269 state.config.graph.land_approval = true;
7270 let pr = green_pr();
7271
7272 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
7273 .await
7274 .unwrap();
7275 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
7276 assert!(
7277 !state.parked,
7278 "approval_gate itself never sets `parked`; only its caller does"
7279 );
7280
7281 let store = ask::Questions::open();
7282 let filed: Vec<_> = store
7283 .list()
7284 .into_iter()
7285 .filter(|q| q.run == state.id)
7286 .collect();
7287 assert_eq!(filed.len(), 1, "exactly one question is filed");
7288 assert_eq!(filed[0].node, APPROVAL_NODE);
7289 assert_eq!(filed[0].choices, approval_choices(&state));
7290 assert!(filed[0].status.open());
7291
7292 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
7296 .await
7297 .unwrap();
7298 assert_eq!(again, ApprovalGate::Pending);
7299 let still_one = store
7300 .list()
7301 .into_iter()
7302 .filter(|q| q.run == state.id)
7303 .count();
7304 assert_eq!(
7305 still_one, 1,
7306 "asking twice must not double-file the question"
7307 );
7308 }
7309
7310 #[tokio::test]
7311 async fn approving_the_existing_question_is_read_back_as_approved() {
7312 crate::run::pin_test_home();
7313 let mut state = run_state();
7314 state.config.graph.land_approval = true;
7315 let pr = green_pr();
7316 assert_eq!(
7317 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7318 .await
7319 .unwrap(),
7320 ApprovalGate::Pending
7321 );
7322
7323 let store = ask::Questions::open();
7324 let mut q = store
7325 .list()
7326 .into_iter()
7327 .find(|q| q.run == state.id)
7328 .expect("filed above");
7329 let talks = crate::talk::Talks::open();
7330 let cfg = crate::config::Config {
7331 agents: vec![crate::config::AgentSpec {
7332 id: "mock".into(),
7333 kind: crate::config::AgentKind::Command,
7334 model: None,
7335 command: vec!["true".into()],
7336 extra_args: Vec::new(),
7337 env: Default::default(),
7338 prompt_delivery: None,
7339 }],
7340 ..crate::config::Config::default()
7341 };
7342 let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
7343 crate::consult::begin(&store, &talks, &q, &talk).unwrap();
7344 crate::talk::drain(&mut talk, &talks).unwrap();
7345 assert_eq!(
7346 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7347 .await
7348 .unwrap(),
7349 ApprovalGate::Pending,
7350 "consultation cannot approve or hold the merge"
7351 );
7352 q = store.get(&q.id).unwrap();
7353 assert!(q.answer.is_none());
7354 talk.turns.push(crate::talk::Turn {
7355 sender: None,
7356 breaks: Some(Vec::new()),
7357 messages: Vec::new(),
7358 posted_by: None,
7359 who: crate::talk::Who::Operator,
7360 body: "merge abc".into(),
7361 at: Timestamp::now(),
7362 attachments: Vec::new(),
7363 usage: None,
7364 });
7365 talks.put(&mut talk).unwrap();
7366 crate::consult::validate_answer(
7367 &q,
7368 &talks,
7369 &talk.id,
7370 crate::queue::CHAT_NODE,
7371 APPROVE,
7372 Some("merge abc"),
7373 )
7374 .unwrap();
7375 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
7376 store.put(&mut q).unwrap();
7377
7378 assert_eq!(
7379 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7380 .await
7381 .unwrap(),
7382 ApprovalGate::Approved
7383 );
7384 }
7385
7386 #[tokio::test]
7387 async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
7388 crate::run::pin_test_home();
7389 let store = ask::Questions::open();
7390 for via_list in [false, true] {
7391 let mut state = repair_state(false, true);
7392 let pr = green_pr();
7393 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7394 .await
7395 .unwrap();
7396 let id = state.land_approval.as_ref().unwrap().question.clone();
7397 store
7398 .update(&id, |q| {
7399 q.actions.insert(
7400 HOLD.to_owned(),
7401 ask::ChoiceAction::Resume {
7402 run: state.id.clone(),
7403 },
7404 );
7405 Ok(())
7406 })
7407 .unwrap();
7408 let q = if via_list {
7409 store.list().into_iter().find(|q| q.id == id).unwrap()
7410 } else {
7411 store.get(&id).unwrap()
7412 };
7413 assert!(!q.actions.contains_key(HOLD));
7414 store
7415 .update(&id, |q| {
7416 assert!(!q.actions.contains_key(HOLD));
7418 assert!(!upgrade_repair_choice(q, &state));
7419 q.answer(ask::Answer::Choice(HOLD.to_owned()))
7420 })
7421 .unwrap();
7422 assert!(store.get(&id).unwrap().chosen_action().is_none());
7423 assert_eq!(
7424 approval_gate(&mut state, &pr, "feat: x", None, "abc")
7425 .await
7426 .unwrap(),
7427 ApprovalGate::Held
7428 );
7429 }
7430 }
7431
7432 #[tokio::test]
7433 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
7434 crate::run::pin_test_home();
7435 let store = ask::Questions::open();
7436
7437 let mut held_state = run_state();
7438 held_state.config.graph.land_approval = true;
7439 let pr = green_pr();
7440 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
7441 .await
7442 .unwrap();
7443 let mut q = store
7444 .list()
7445 .into_iter()
7446 .find(|q| q.run == held_state.id)
7447 .expect("filed above");
7448 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
7449 store.put(&mut q).unwrap();
7450 assert_eq!(
7451 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
7452 .await
7453 .unwrap(),
7454 ApprovalGate::Held
7455 );
7456
7457 let mut abandoned_state = run_state();
7458 abandoned_state.config.graph.land_approval = true;
7459 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
7460 .await
7461 .unwrap();
7462 let mut q = store
7463 .list()
7464 .into_iter()
7465 .find(|q| q.run == abandoned_state.id)
7466 .expect("filed above");
7467 q.abandon("no answer within the timeout");
7468 store.put(&mut q).unwrap();
7469 assert_eq!(
7470 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
7471 .await
7472 .unwrap(),
7473 ApprovalGate::Held,
7474 "silence must never merge"
7475 );
7476 }
7477
7478 fn contested() -> ContestedHandoff {
7479 let finding = |id: &str, n: u32| crate::verdict::Finding {
7480 id: id.to_owned(),
7481 severity: crate::verdict::Severity::Major,
7482 file: Some("src/a.rs".to_owned()),
7483 line: Some(n),
7484 title: format!("problem {id}"),
7485 detail: String::new(),
7486 };
7487 ContestedHandoff {
7488 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
7489 rejecters: vec![(1, "alpha".to_owned())],
7490 }
7491 }
7492
7493 #[test]
7494 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
7495 let mut state = run_state();
7496 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
7497 state.contested_handoff = Some(contested());
7498 assert!(contested_to_ask(&state).is_some());
7499 state.config.graph.hold_contested_merge = false;
7500 assert!(
7501 contested_to_ask(&state).is_none(),
7502 "the switch restores today"
7503 );
7504 }
7505
7506 #[test]
7507 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
7508 let q = ask::Question::new(
7509 "run-1".to_owned(),
7510 APPROVAL_NODE.to_owned(),
7511 "land".to_owned(),
7512 "Merge?".to_owned(),
7513 String::new(),
7514 vec![APPROVE.to_owned(), HOLD.to_owned()],
7515 );
7516 let none = deputy_brief(&q, None);
7517 assert!(none.contains("could not be read"), "{none}");
7518 assert!(none.contains("Silence is a hold"), "{none}");
7519
7520 let mut state = run_state();
7521 state.pr = Some(crate::run::PrRecord {
7522 url: "https://example.test/pull/7".to_owned(),
7523 number: 7,
7524 state: "open".to_owned(),
7525 checks: "green".to_owned(),
7526 round: 0,
7527 rounds: 3,
7528 red_at_merge: Vec::new(),
7529 });
7530 state.contested_handoff = Some(contested());
7531 let b = deputy_brief(&q, Some(&state));
7532 assert!(b.contains("https://example.test/pull/7"), "{b}");
7533 assert!(b.contains("not recorded in this legacy question"), "{b}");
7534 assert!(!b.contains("This question was filed although"));
7535 state.contested_handoff = None;
7536 assert!(deputy_brief(&q, Some(&state)).contains("not recorded in this legacy question"));
7537 }
7538
7539 #[test]
7540 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
7541 for lang in ["en", "ja"] {
7542 let mut cfg = crate::config::Config::default();
7543 cfg.graph.language = lang.to_owned();
7544 let w = words(&cfg.graph.language);
7545 let text = w.approval_detail(
7546 "https://github.com/yukimemi/magi/pull/42",
7547 "main",
7548 "feat: x",
7549 Some(&contested()),
7550 );
7551 assert!(text.contains("pull/42"), "{text}");
7552 assert!(
7553 text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
7554 "{text}"
7555 );
7556 assert!(text.contains("R3-1-5"), "{text}");
7557 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
7558 assert!(text.contains("2"), "the rest are counted: {text}");
7559 assert!(text.contains("#1 (alpha)"), "{text}");
7560 }
7561 let plain = words("en").approval_detail("u", "main", "s", None);
7562 assert!(!plain.contains("reject"), "{plain}");
7563 }
7564
7565 #[tokio::test]
7566 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
7567 crate::run::pin_test_home();
7568 let mut state = run_state();
7569 state.config.graph.land_approval = false;
7570 state.contested_handoff = Some(contested());
7571 let pr = green_pr();
7572 let c = contested_to_ask(&state);
7573 assert_eq!(
7574 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
7575 .await
7576 .unwrap(),
7577 ApprovalGate::Pending,
7578 "silence is a hold"
7579 );
7580 let store = ask::Questions::open();
7581 let filed: Vec<_> = store
7582 .list()
7583 .into_iter()
7584 .filter(|q| q.run == state.id)
7585 .collect();
7586 assert_eq!(filed.len(), 1);
7587 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
7588
7589 assert_eq!(
7590 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
7591 .await
7592 .unwrap(),
7593 ApprovalGate::Pending
7594 );
7595 let mut q = store
7596 .list()
7597 .into_iter()
7598 .find(|q| q.run == state.id)
7599 .unwrap();
7600 assert_eq!(q.id, filed[0].id, "the same question after a resume");
7601 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
7602 store.put(&mut q).unwrap();
7603 assert_eq!(
7604 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
7605 .await
7606 .unwrap(),
7607 ApprovalGate::Approved
7608 );
7609 }
7610
7611 #[test]
7612 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
7613 let rows = parse_numstat(NUMSTAT);
7614 assert_eq!(
7615 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
7616 ["src/web.rs", "src/land.rs", "assets/logo.png"]
7617 );
7618 assert_eq!(rows[2].added, None, "a binary file has no line counts");
7619 }
7620 #[test]
7621 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
7622 let mut state = run_state();
7626 state.config.graph.language = "ja".to_owned();
7627 let pr = green_pr();
7628 let commits = ["c1".to_owned()];
7629
7630 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
7631 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
7632 assert!(ja.contains("squash されるコミット"), "{ja}");
7633 assert!(ja.contains("レビューコメント"), "{ja}");
7634 assert!(ja.contains("差分"), "{ja}");
7635 assert!(
7636 !ja.contains("Commits being squashed"),
7637 "no English left over"
7638 );
7639
7640 let w = words("ja");
7641 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
7642 assert!(
7643 w.approval_detail("http://x/1", "main", "feat: x", None)
7644 .contains("パネル")
7645 );
7646
7647 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
7649 assert!(ja.contains("feat: x"), "nor is the merge subject");
7650
7651 state.config.graph.language = "en".to_owned();
7654 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
7655 assert!(en.contains("Commits being squashed"), "{en}");
7656 assert_eq!(words("Klingon").html_lang, "en");
7657 }
7658
7659 #[test]
7663 fn pick_open_pr_classifies_by_count_and_base() {
7664 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
7665 assert_eq!(
7666 pick_open_pr(one, "main").unwrap(),
7667 OpenPr::One {
7668 url: "https://x/pull/58".into(),
7669 title: "t".into()
7670 }
7671 );
7672 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
7673 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
7674 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
7675 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
7676 assert_eq!(
7677 pick_open_pr(two, "main").unwrap(),
7678 OpenPr::Many(vec!["u1".into(), "u2".into()])
7679 );
7680 let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
7682 assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
7683 assert!(pick_open_pr("not json", "main").is_err());
7684 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
7686 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
7687 }
7688
7689 #[test]
7690 fn pick_merged_pr_picks_the_unique_match() {
7691 let json = r#"[
7692 {"url": "https://github.com/o/r/pull/42", "number": 42,
7693 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
7694 ]"#;
7695 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7696 let found = pick_merged_pr(json, "main", created_at)
7697 .expect("valid json")
7698 .expect("one unambiguous match");
7699 assert_eq!(found.url, "https://github.com/o/r/pull/42");
7700 assert_eq!(found.number, 42);
7701 }
7702
7703 #[test]
7707 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
7708 let json = r#"[
7709 {"url": "https://github.com/o/r/pull/42", "number": 42,
7710 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
7711 {"url": "https://github.com/o/r/pull/43", "number": 43,
7712 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
7713 ]"#;
7714 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7715 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7716 }
7717
7718 #[test]
7722 fn pick_merged_pr_ignores_a_different_base_branch() {
7723 let json = r#"[
7724 {"url": "https://github.com/o/r/pull/42", "number": 42,
7725 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
7726 ]"#;
7727 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7728 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7729 }
7730
7731 #[test]
7734 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
7735 let json = r#"[
7736 {"url": "https://github.com/o/r/pull/42", "number": 42,
7737 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
7738 ]"#;
7739 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7740 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7741 }
7742
7743 #[test]
7744 fn slug_of_pr_url_reads_host_owner_and_repo() {
7745 assert_eq!(
7746 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
7747 Some("github.com/yukimemi/shun")
7748 );
7749 }
7750
7751 #[test]
7752 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
7753 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
7754 assert_eq!(slug_of_pr_url("not a url at all"), None);
7755 assert_eq!(slug_of_pr_url("https://github.com"), None);
7756 }
7757
7758 #[test]
7759 fn slug_of_repo_url_reads_host_owner_and_repo() {
7760 assert_eq!(
7761 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
7762 Some("github.com/yukimemi/magi")
7763 );
7764 assert_eq!(slug_of_repo_url("https://github.com"), None);
7765 }
7766
7767 #[test]
7768 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
7769 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
7770 .expect("same repo, different case");
7771 }
7772
7773 #[test]
7778 fn ensure_same_repo_refuses_a_different_repo() {
7779 let err =
7780 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
7781 let msg = format!("{err:#}");
7782 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
7783 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
7784 }
7785
7786 #[test]
7791 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
7792 let err = ensure_same_repo(
7793 "github.com/yukimemi/magi",
7794 "github.example.com/yukimemi/magi",
7795 )
7796 .unwrap_err();
7797 let msg = format!("{err:#}");
7798 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
7799 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
7800 }
7801
7802 #[tokio::test]
7807 async fn find_external_merge_returns_none_without_a_winner() {
7808 let state = RunState::new(
7809 PathBuf::from("/no/such/repo"),
7810 "main".to_owned(),
7811 "0000000000000000000000000000000000000000".to_owned(),
7812 "irrelevant".to_owned(),
7813 crate::config::Config::default(),
7814 );
7815 assert_eq!(find_external_merge(&state).await.unwrap(), None);
7816 }
7817
7818 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
7819 let mut run = RunState::new(
7820 PathBuf::from(repo),
7821 "main".to_owned(),
7822 "abcdef1234".to_owned(),
7823 "x".to_owned(),
7824 crate::config::Config::default(),
7825 );
7826 run.id = id.to_owned();
7827 run.status = status;
7828 run.driver_exited = true;
7829 run.pr = Some(crate::run::PrRecord {
7830 number: url.rsplit('/').next().unwrap().parse().unwrap(),
7831 url: url.to_owned(),
7832 state: state.to_owned(),
7833 checks: "red".to_owned(),
7834 round: 0,
7835 rounds: 2,
7836 red_at_merge: Vec::new(),
7837 });
7838 run.save_under(home).unwrap();
7839 }
7840
7841 fn recorded(home: &Path, id: &str) -> String {
7842 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
7843 serde_json::from_str::<RunState>(&body)
7844 .unwrap()
7845 .pr
7846 .unwrap()
7847 .state
7848 }
7849
7850 const PR: &str = "https://github.com/o/r/pull/7";
7851
7852 #[test]
7853 fn write_through_updates_predecessors_and_siblings_only() {
7854 let tmp = tempfile::tempdir().unwrap();
7855 let h = tmp.path();
7856 pr_run(
7857 h,
7858 "20261004-100000-aaaa",
7859 "/repo/r",
7860 RunStatus::Superseded,
7861 PR,
7862 "open",
7863 );
7864 pr_run(
7865 h,
7866 "20261004-100100-bbbb",
7867 "/repo/r",
7868 RunStatus::Blocked,
7869 PR,
7870 "open",
7871 );
7872 pr_run(
7874 h,
7875 "20261004-100200-cccc",
7876 "/repo/r",
7877 RunStatus::Landing,
7878 PR,
7879 "open",
7880 );
7881 pr_run(
7883 h,
7884 "20261004-100300-dddd",
7885 "/repo/other",
7886 RunStatus::Blocked,
7887 "https://github.com/o/other/pull/7",
7888 "open",
7889 );
7890 pr_run(
7892 h,
7893 "20261004-100400-eeee",
7894 "/repo/r",
7895 RunStatus::Blocked,
7896 "https://github.com/o/r/pull/8",
7897 "open",
7898 );
7899 pr_run(
7900 h,
7901 "20261004-100500-ffff",
7902 "/repo/r",
7903 RunStatus::Merged,
7904 PR,
7905 "open",
7906 );
7907 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
7908
7909 assert_eq!(
7910 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
7911 2
7912 );
7913 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
7914 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
7915 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
7916 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
7917 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
7918 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
7920 assert_eq!(
7922 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
7923 0
7924 );
7925 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
7926 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
7927 }
7928
7929 #[test]
7930 fn lifecycle_updates_preserve_live_and_unknown_terminal_drivers() {
7931 let home = tempfile::tempdir().unwrap();
7932 let live_id = "20261004-100000-aaaa";
7933 let unknown_id = "20261004-100100-bbbb";
7934 for id in [live_id, unknown_id] {
7935 pr_run(home.path(), id, "repo", RunStatus::Ready, PR, "open");
7936 let mut s = RunState::load_under(id, home.path()).unwrap();
7937 s.driver_exited = false;
7938 if id == live_id {
7939 s.driver_pid = Some(std::process::id());
7940 s.driver_started_at = crate::proc::process_started_at(std::process::id());
7941 }
7942 s.save_under(home.path()).unwrap();
7943 }
7944 let known = BTreeMap::from([(PR.to_owned(), PrLifecycle::Merged)]);
7945 assert_eq!(apply_pr_states(home.path(), &known), 0);
7946 for id in [live_id, unknown_id] {
7947 assert_eq!(recorded(home.path(), id), "open");
7948 }
7949 }
7950
7951 #[test]
7952 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
7953 let tmp = tempfile::tempdir().unwrap();
7954 let h = tmp.path();
7955 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
7956 pr_run(
7957 h,
7958 "20261004-100000-aaaa",
7959 "/repo/r",
7960 RunStatus::Superseded,
7961 &url(1),
7962 "open",
7963 );
7964 pr_run(
7965 h,
7966 "20261004-100100-bbbb",
7967 "/repo/r",
7968 RunStatus::Blocked,
7969 &url(2),
7970 "open",
7971 );
7972 pr_run(
7973 h,
7974 "20261004-100200-cccc",
7975 "/repo/r",
7976 RunStatus::Ready,
7977 &url(3),
7978 "open",
7979 );
7980 pr_run(
7981 h,
7982 "20261004-100300-dddd",
7983 "/repo/r",
7984 RunStatus::Ready,
7985 &url(4),
7986 "open",
7987 );
7988 pr_run(
7989 h,
7990 "20261004-100400-eeee",
7991 "/repo/r",
7992 RunStatus::Implementing,
7993 &url(1),
7994 "open",
7995 );
7996 assert_eq!(stale_open_prs(h).len(), 4);
7997
7998 let mut known = BTreeMap::new();
7999 known.insert(url(1), PrLifecycle::Merged);
8000 known.insert(url(2), PrLifecycle::Closed);
8001 known.insert(url(3), PrLifecycle::Open);
8002 assert_eq!(apply_pr_states(h, &known), 2);
8004 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
8005 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
8006 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
8007 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
8008 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
8009 assert_eq!(apply_pr_states(h, &known), 0);
8010 }
8011
8012 use std::collections::VecDeque;
8015 use std::sync::Mutex;
8016
8017 struct Scripted {
8020 views: Mutex<VecDeque<Seen>>,
8021 merges: Mutex<VecDeque<(bool, String)>>,
8022 fix: Mutex<Option<Fixed>>,
8023 log: Mutex<Vec<&'static str>>,
8024 argvs: Mutex<Vec<Vec<String>>>,
8025 required: Mutex<Option<BTreeSet<String>>>,
8026 merged: Mutex<bool>,
8029 queued: Mutex<bool>,
8030 unreadable_after_merge: Mutex<bool>,
8032 reruns: Mutex<Vec<String>>,
8034 rerun_fails: Mutex<bool>,
8035 clock: Mutex<i64>,
8037 }
8038
8039 impl Scripted {
8040 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
8041 Self {
8042 views: Mutex::new(views.into()),
8043 merges: Mutex::new(
8044 merges
8045 .into_iter()
8046 .map(|(ok, m)| (ok, m.to_owned()))
8047 .collect(),
8048 ),
8049 fix: Mutex::new(None),
8050 log: Mutex::new(Vec::new()),
8051 argvs: Mutex::new(Vec::new()),
8052 required: Mutex::new(None),
8053 merged: Mutex::new(false),
8054 queued: Mutex::new(false),
8055 unreadable_after_merge: Mutex::new(false),
8056 reruns: Mutex::new(Vec::new()),
8057 rerun_fails: Mutex::new(false),
8058 clock: Mutex::new(1_000),
8059 }
8060 }
8061 fn argvs(&self) -> Vec<Vec<String>> {
8062 self.argvs.lock().unwrap().clone()
8063 }
8064 fn calls(&self) -> Vec<&'static str> {
8065 self.log.lock().unwrap().clone()
8066 }
8067 }
8068
8069 impl Forge for Scripted {
8070 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
8071 self.log.lock().unwrap().push("view");
8072 if *self.unreadable_after_merge.lock().unwrap()
8073 && !self.argvs.lock().unwrap().is_empty()
8074 {
8075 anyhow::bail!("forge unreachable");
8076 }
8077 let mut v = self.views.lock().unwrap();
8078 let mut seen = if v.len() > 1 {
8079 v.pop_front().unwrap()
8080 } else {
8081 v[0].clone()
8082 };
8083 if *self.merged.lock().unwrap() {
8084 seen.pr.state = PrLifecycle::Merged;
8085 }
8086 Ok(seen)
8087 }
8088 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
8089 self.log.lock().unwrap().push("merge");
8090 self.argvs.lock().unwrap().push(argv.to_vec());
8091 let out = self
8092 .merges
8093 .lock()
8094 .unwrap()
8095 .pop_front()
8096 .expect("unscripted merge");
8097 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
8098 {
8099 *self.merged.lock().unwrap() = true;
8100 }
8101 Ok(out)
8102 }
8103 async fn poll(&self) {
8104 self.log.lock().unwrap().push("poll");
8105 *self.clock.lock().unwrap() += POLL.as_secs() as i64;
8106 }
8107 async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
8108 self.log.lock().unwrap().push("rerun");
8109 self.reruns.lock().unwrap().push(run.to_owned());
8110 if *self.rerun_fails.lock().unwrap() {
8111 anyhow::bail!("gh run rerun: no");
8112 }
8113 Ok(())
8114 }
8115 fn now(&self) -> i64 {
8116 *self.clock.lock().unwrap()
8117 }
8118 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
8119 self.required.lock().unwrap().clone()
8120 }
8121 async fn fix(
8122 &self,
8123 _state: &mut RunState,
8124 _pr: &PrState,
8125 _round: usize,
8126 _budget: usize,
8127 _reason: &str,
8128 _logs: &str,
8129 ) -> Result<Fixed> {
8130 self.log.lock().unwrap().push("fix");
8131 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
8132 }
8133 }
8134
8135 const REFUSED: &str =
8136 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
8137
8138 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
8139 let mut pr = green_pr();
8140 pr.checks = checks;
8141 pr.blocking = Blocking::of(merge_state);
8142 if !comments {
8143 pr.review_comments.clear();
8144 }
8145 Seen {
8146 pr,
8147 title: "feat: x".to_owned(),
8148 failing_urls: Vec::new(),
8149 head: head.to_owned(),
8150 rollup_head: head.to_owned(),
8151 merge_state: merge_state.to_owned(),
8152 contexts: Vec::new(),
8153 base: "main".to_owned(),
8154 }
8155 }
8156
8157 fn landing_state() -> RunState {
8158 crate::run::pin_test_home();
8159 let mut state = run_state();
8160 state.config.graph.land_approval = false;
8161 state
8162 }
8163
8164 #[test]
8165 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
8166 assert!(!awaiting_new_head(None, "aaa"));
8167 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
8168 assert!(awaiting_new_head(Some("abc123"), "def456"));
8169 assert!(awaiting_new_head(Some("abc123"), ""));
8170 }
8171
8172 #[test]
8173 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
8174 let open = |c, m: &str| seen("a", c, m, false);
8175 let table = [
8176 (None, false, Refused::Pending),
8177 (
8178 Some(open(Checks::Pending, "BLOCKED")),
8179 false,
8180 Refused::Pending,
8181 ),
8182 (
8183 Some(open(Checks::Unknown, "BLOCKED")),
8184 false,
8185 Refused::Pending,
8186 ),
8187 (
8188 Some(open(Checks::Green, "UNKNOWN")),
8189 false,
8190 Refused::Pending,
8191 ),
8192 (Some(open(Checks::Green, "")), false, Refused::Pending),
8193 (
8194 Some(open(Checks::Green, "BLOCKED")),
8195 false,
8196 Refused::Recheck,
8197 ),
8198 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
8199 ];
8200 for (after, rechecked, want) in table {
8201 assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
8202 }
8203 let mut closed = open(Checks::Green, "CLEAN");
8204 closed.pr.state = PrLifecycle::Closed;
8205 assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
8206 }
8207
8208 #[tokio::test]
8209 async fn a_normal_landing_merges_on_the_first_look() {
8210 let mut state = landing_state();
8211 let forge = Scripted::new(
8212 vec![seen("a", Checks::Green, "CLEAN", false)],
8213 vec![(true, "")],
8214 );
8215 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8216 .await
8217 .unwrap();
8218 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
8220 assert_eq!(state.status, RunStatus::Merged);
8221 }
8222
8223 #[tokio::test]
8224 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
8225 let mut state = landing_state();
8226 let forge = Scripted::new(
8227 vec![seen("a", Checks::Green, "CLEAN", false)],
8228 std::iter::repeat_n((true, ""), 100).collect(),
8229 );
8230 *forge.queued.lock().unwrap() = true;
8231 let task = async {
8232 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8233 .await
8234 .unwrap();
8235 };
8236 task.await;
8239 assert_ne!(state.status, RunStatus::Merged);
8240 }
8241
8242 #[tokio::test]
8243 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
8244 let mut state = landing_state();
8245 let forge = Scripted::new(
8246 vec![seen("a", Checks::Green, "CLEAN", false)],
8247 std::iter::repeat_n((true, ""), 100).collect(),
8248 );
8249 *forge.unreadable_after_merge.lock().unwrap() = true;
8250 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8251 .await
8252 .ok();
8253 assert_ne!(state.status, RunStatus::Merged);
8254 }
8255
8256 #[tokio::test]
8257 async fn a_task_public_head_is_awaited_on_land_entry() {
8258 let mut state = landing_state();
8259 state.pr_head = Some("magi/task-abcd-1234".into());
8260 state.pr_pushed_head = Some("new".into());
8261 let forge = Scripted::new(
8262 vec![
8263 seen("old", Checks::Green, "CLEAN", false),
8264 seen("new", Checks::Green, "CLEAN", false),
8265 ],
8266 vec![(true, "")],
8267 );
8268 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8269 .await
8270 .unwrap();
8271 assert_eq!(
8272 forge.calls(),
8273 ["view", "poll", "view", "view", "merge", "view"]
8274 );
8275 assert_eq!(state.status, RunStatus::Merged);
8276 }
8277
8278 #[tokio::test]
8279 async fn a_superseded_public_head_owner_cannot_reenter_land() {
8280 let mut state = landing_state();
8281 state.pr_taken_over_by = Some("next".into());
8282 let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
8283 assert!(
8284 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8285 .await
8286 .is_err()
8287 );
8288 assert!(forge.calls().is_empty());
8289 }
8290
8291 #[tokio::test]
8292 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
8293 let mut state = landing_state();
8294 let forge = Scripted::new(
8295 vec![
8296 seen("old", Checks::Green, "CLEAN", true),
8297 seen("old", Checks::Green, "CLEAN", true),
8299 seen("new", Checks::Pending, "BLOCKED", true),
8300 seen("new", Checks::Green, "CLEAN", true),
8301 ],
8302 vec![(true, "")],
8303 );
8304 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
8305 head: "NEW".to_owned(),
8306 });
8307 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8308 .await
8309 .unwrap();
8310 assert_eq!(
8311 forge.calls(),
8312 [
8313 "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
8314 "view"
8315 ]
8316 );
8317 assert_eq!(state.status, RunStatus::Merged);
8318 }
8319
8320 #[tokio::test]
8321 async fn a_head_that_never_arrives_stops_naming_both_commits() {
8322 let mut state = landing_state();
8323 let forge = Scripted::new(
8324 vec![
8325 seen("old", Checks::Green, "CLEAN", true),
8326 seen("someone-elses", Checks::Green, "CLEAN", true),
8327 ],
8328 vec![],
8329 );
8330 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
8331 head: "mine".to_owned(),
8332 });
8333 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8334 .await
8335 .unwrap();
8336 assert!(!forge.calls().contains(&"merge"));
8337 let why = state.merge.as_ref().unwrap().detail.clone();
8338 assert!(
8339 why.contains("mine") && why.contains("someone-elses"),
8340 "{why}"
8341 );
8342 assert_eq!(state.status, RunStatus::Blocked);
8343 }
8344
8345 #[tokio::test]
8346 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
8347 let mut state = landing_state();
8348 let forge = Scripted::new(
8349 vec![
8350 seen("a", Checks::Green, "CLEAN", false),
8351 seen("a", Checks::Green, "CLEAN", false),
8352 seen("a", Checks::Pending, "BLOCKED", false),
8353 seen("a", Checks::Pending, "BLOCKED", false),
8354 seen("a", Checks::Green, "CLEAN", false),
8355 ],
8356 vec![(false, REFUSED), (true, "")],
8357 );
8358 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8359 .await
8360 .unwrap();
8361 assert_eq!(
8362 forge.calls(),
8363 [
8364 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
8365 "view"
8366 ]
8367 );
8368 assert_eq!(state.status, RunStatus::Merged);
8369 }
8370
8371 #[tokio::test]
8372 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
8373 let mut state = landing_state();
8374 let forge = Scripted::new(
8375 vec![
8376 seen("a", Checks::Green, "CLEAN", false),
8377 seen("a", Checks::Green, "BLOCKED", false),
8378 ],
8379 vec![(false, REFUSED), (false, REFUSED)],
8380 );
8381 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8382 .await
8383 .unwrap();
8384 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
8386 let why = state.merge.as_ref().unwrap().detail.clone();
8387 assert!(
8388 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
8389 "{why}"
8390 );
8391 assert_eq!(state.status, RunStatus::Blocked);
8392 }
8393
8394 #[test]
8395 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
8396 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
8397 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
8398 assert_eq!(bound_head("old", "old", Some("new")), None);
8400 assert_eq!(bound_head("new", "old", Some("new")), None);
8402 assert_eq!(bound_head("new", "old", None), None);
8403 assert_eq!(bound_head("", "", None), None);
8405 assert_eq!(bound_head("", "", Some("new")), None);
8406 assert_eq!(bound_head("abc", "", None), None);
8407 }
8408
8409 fn view_json(head: &str) -> String {
8410 format!(
8411 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
8412 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
8413 "reviews":[],"comments":[]}}"#
8414 )
8415 }
8416
8417 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
8418 format!(
8419 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
8420 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
8421 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
8422 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
8423 )
8424 }
8425
8426 #[test]
8427 fn rollup_is_bound_to_the_commit_in_the_same_node() {
8428 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
8431 assert_eq!(s.rollup_head, "old");
8432 assert_eq!(s.pr.checks, Checks::Red);
8433 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
8434 assert_eq!(s.failing_urls.len(), 1);
8435 }
8436
8437 #[test]
8438 fn checks_come_from_the_node_not_the_view() {
8439 let view = view_json("new").replace(
8440 r#""reviews""#,
8441 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
8442 );
8443 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
8444 assert_eq!(s.pr.checks, Checks::Green);
8445 assert!(s.pr.failing.is_empty());
8446 assert_eq!(
8447 bound_head(&s.head, &s.rollup_head, Some("new")),
8448 Some("new")
8449 );
8450 }
8451
8452 #[test]
8453 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
8454 for node in [
8455 None,
8456 Some("not json".to_owned()),
8457 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
8458 Some(node_json("new", "SUCCESS", true)),
8459 ] {
8460 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
8461 assert!(s.rollup_head.is_empty());
8462 assert_eq!(s.pr.checks, Checks::Unknown);
8463 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
8464 }
8465 }
8466
8467 #[test]
8468 fn the_merge_command_is_pinned_to_the_observed_head() {
8469 let argv = merge_argv_at(7, "feat: x", "deadbeef");
8470 let at = argv
8471 .iter()
8472 .position(|a| a == "--match-head-commit")
8473 .unwrap();
8474 assert_eq!(argv[at + 1], "deadbeef");
8475 }
8476
8477 #[tokio::test]
8478 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
8479 let mut state = landing_state();
8480 let mut stale = seen("new", Checks::Red, "CLEAN", false);
8483 stale.rollup_head = "old".to_owned();
8484 let forge = Scripted::new(
8485 vec![seen("old", Checks::Green, "CLEAN", true), stale],
8486 vec![],
8487 );
8488 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
8489 head: "new".to_owned(),
8490 });
8491 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8492 .await
8493 .unwrap();
8494 assert!(!forge.calls().contains(&"merge"));
8495 assert_eq!(state.status, RunStatus::Blocked);
8496 let why = state.merge.as_ref().unwrap().detail.clone();
8497 assert!(why.contains("new") && why.contains("old"), "{why}");
8498 }
8499
8500 #[test]
8501 fn a_refusal_read_against_another_commits_checks_is_pending() {
8502 let mut after = seen("a", Checks::Green, "BLOCKED", false);
8503 after.rollup_head = "old".to_owned();
8504 assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
8505 }
8506
8507 #[tokio::test]
8508 async fn a_matching_head_with_red_non_required_checks_still_merges() {
8509 let mut state = landing_state();
8510 let forge = Scripted::new(
8511 vec![seen("a", Checks::Red, "CLEAN", false)],
8512 vec![(true, "")],
8513 );
8514 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8515 .await
8516 .unwrap();
8517 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
8518 assert_eq!(state.status, RunStatus::Merged);
8519 }
8520
8521 #[tokio::test]
8522 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
8523 let mut state = landing_state();
8524 let forge = Scripted::new(
8525 vec![
8526 seen("a", Checks::Green, "CLEAN", false),
8527 seen("a", Checks::Green, "CLEAN", false),
8528 seen("b", Checks::Green, "BLOCKED", false),
8530 seen("b", Checks::Green, "CLEAN", false),
8531 ],
8532 vec![(false, REFUSED), (true, "")],
8533 );
8534 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8535 .await
8536 .unwrap();
8537 assert_eq!(
8538 forge.calls(),
8539 [
8540 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
8541 ]
8542 );
8543 assert_eq!(state.status, RunStatus::Merged);
8544 }
8545
8546 fn merged_view(head: &str) -> Seen {
8547 let mut m = seen(head, Checks::Green, "CLEAN", false);
8548 m.pr.state = PrLifecycle::Merged;
8549 m
8550 }
8551
8552 fn has(argv: &[String], flag: &str) -> bool {
8553 argv.iter().any(|a| a == flag)
8554 }
8555
8556 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
8557 let at = argv.iter().position(|a| a == flag)?;
8558 argv.get(at + 1).map(String::as_str)
8559 }
8560
8561 const URL: &str = "https://github.com/o/r/pull/42";
8562
8563 #[tokio::test]
8564 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
8565 let mut state = landing_state();
8566 let forge = Scripted::new(
8567 vec![
8568 seen("abc", Checks::Green, "CLEAN", false),
8569 seen("abc", Checks::Green, "CLEAN", false),
8570 ],
8571 vec![(true, "")],
8572 );
8573 land_with(&mut state, URL, &forge).await.unwrap();
8574 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
8575 let argv = &forge.argvs()[0];
8576 assert!(has(argv, "--squash") && has(argv, "--subject"));
8577 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
8578 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
8579 assert_eq!(state.status, RunStatus::Merged);
8580 assert!(state.land_armed_head.is_none());
8581 }
8582
8583 #[tokio::test]
8584 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
8585 let mut state = landing_state();
8586 state.land_armed_head = Some("a".to_owned());
8587 let forge = Scripted::new(
8588 vec![seen("a", Checks::Green, "CLEAN", false)],
8589 vec![(true, ""), (true, "")],
8590 );
8591 land_with(&mut state, URL, &forge).await.unwrap();
8592 let argvs = forge.argvs();
8593 assert!(has(&argvs[0], "--disable-auto"));
8594 assert!(!has(&argvs[1], "--auto"));
8595 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
8596 assert_eq!(state.status, RunStatus::Merged);
8597 assert!(state.land_armed_head.is_none());
8598 }
8599
8600 #[tokio::test]
8601 async fn an_approval_never_carries_over_to_a_new_head() {
8602 crate::run::pin_test_home();
8603 let mut state = run_state();
8604 state.config.graph.land_approval = true;
8605 let pr = green_pr();
8606 let store = ask::Questions::open();
8607
8608 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
8609 .await
8610 .unwrap();
8611 let mut q = store
8612 .list()
8613 .into_iter()
8614 .find(|q| q.run == state.id)
8615 .unwrap();
8616 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8617 store.put(&mut q).unwrap();
8618 assert_eq!(
8619 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
8620 .await
8621 .unwrap(),
8622 ApprovalGate::Approved,
8623 "the same head keeps its approval"
8624 );
8625
8626 assert_eq!(
8628 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
8629 .await
8630 .unwrap(),
8631 ApprovalGate::Pending
8632 );
8633 let all: Vec<_> = store
8634 .list()
8635 .into_iter()
8636 .filter(|q| q.run == state.id)
8637 .collect();
8638 assert_eq!(all.len(), 2);
8639
8640 state.land_approval = None;
8642 assert_eq!(
8643 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
8644 .await
8645 .unwrap(),
8646 ApprovalGate::Pending
8647 );
8648 let open = store
8649 .list()
8650 .into_iter()
8651 .filter(|q| q.run == state.id && q.status.open())
8652 .count();
8653 assert_eq!(open, 1, "the superseded question was retired");
8654 }
8655
8656 #[tokio::test]
8657 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
8658 let mut state = landing_state();
8659 let forge = Scripted::new(
8660 vec![
8661 seen("a", Checks::Green, "CLEAN", false),
8662 seen("b", Checks::Green, "CLEAN", false),
8664 ],
8665 vec![(true, "")],
8666 );
8667 land_with(&mut state, URL, &forge).await.unwrap();
8668 let argvs = forge.argvs();
8669 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
8670 assert!(!has(&argvs[0], "--auto"));
8671 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
8672 assert_eq!(state.status, RunStatus::Merged);
8673 }
8674
8675 fn passing(label: &str) -> CheckInfo {
8676 CheckInfo {
8677 label: label.to_owned(),
8678 verdict: Verdict::Pass,
8679 required: Some(false),
8680 run: None,
8681 }
8682 }
8683
8684 fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
8685 (label.to_owned(), run.map(str::to_owned))
8686 }
8687
8688 fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
8689 LandReruns {
8690 head: head.to_owned(),
8691 runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
8692 }
8693 }
8694
8695 #[test]
8696 fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
8697 let f = [failing("test (macos)", Some("7"))];
8698 assert_eq!(
8699 rerun_step(&f, None, "h", 100),
8700 RerunStep::Rerun(vec!["7".to_owned()])
8701 );
8702 let rec = reran("h", &[("7", 100)]);
8703 let grace = crate::release_watch::RERUN_GRACE;
8704 assert_eq!(
8705 rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
8706 RerunStep::Grace
8707 );
8708 assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
8709 }
8710
8711 #[test]
8712 fn a_failure_without_a_run_id_goes_to_the_fixer() {
8713 let f = [failing("a", Some("7")), failing("ci/external", None)];
8714 assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
8715 assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
8716 }
8717
8718 #[test]
8719 fn a_moved_head_forgets_the_reruns() {
8720 let f = [failing("a", Some("7"))];
8721 let rec = reran("old", &[("7", 100)]);
8722 assert_eq!(
8723 rerun_step(&f, Some(&rec), "new", 101),
8724 RerunStep::Rerun(vec!["7".to_owned()])
8725 );
8726 }
8727
8728 #[test]
8729 fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
8730 let f = [
8731 failing("a", Some("7")),
8732 failing("b", Some("7")),
8733 failing("c", Some("8")),
8734 ];
8735 assert_eq!(
8736 rerun_step(&f, None, "h", 0),
8737 RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
8738 );
8739 let rec = reran("h", &[("7", 0)]);
8740 assert_eq!(
8741 rerun_step(&f, Some(&rec), "h", 1),
8742 RerunStep::Rerun(vec!["8".to_owned()])
8743 );
8744 }
8745
8746 #[test]
8747 fn only_blocking_failures_are_candidates_for_a_rerun() {
8748 let check = |label: &str, verdict, required, run: &str| CheckInfo {
8749 label: label.to_owned(),
8750 verdict,
8751 required,
8752 run: Some(run.to_owned()),
8753 };
8754 let got = rerunnable(&[
8755 check("passed", Verdict::Pass, Some(true), "1"),
8756 check("optional", Verdict::Fail, Some(false), "2"),
8757 check("required", Verdict::Fail, Some(true), "3"),
8758 check("unknown", Verdict::Fail, None, "4"),
8759 ]);
8760 let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
8761 assert_eq!(labels, ["required", "unknown"]);
8762 }
8763
8764 fn red(head: &str, run: Option<&str>) -> Seen {
8765 let mut s = seen(head, Checks::Red, "BLOCKED", false);
8766 s.pr.failing = vec!["test (macos)".to_owned()];
8767 s.contexts = vec![CheckInfo {
8768 label: "test (macos)".to_owned(),
8769 verdict: Verdict::Fail,
8770 required: Some(true),
8771 run: run.map(str::to_owned),
8772 }];
8773 s
8774 }
8775
8776 const RERUN_PR: &str = "https://github.com/o/r/pull/42";
8777
8778 #[tokio::test]
8779 async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
8780 let mut state = landing_state();
8781 let forge = Scripted::new(
8782 vec![
8783 red("a", Some("7")),
8784 seen("a", Checks::Pending, "BLOCKED", false),
8785 seen("a", Checks::Green, "CLEAN", false),
8786 ],
8787 vec![(true, "")],
8788 );
8789 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8790 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8791 assert!(!forge.calls().contains(&"fix"));
8792 assert_eq!(state.status, RunStatus::Merged);
8793 assert_eq!(state.pr.as_ref().unwrap().round, 0);
8794 let argvs = forge.argvs();
8795 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
8796 assert!(
8797 state
8798 .events
8799 .iter()
8800 .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
8801 );
8802 }
8803
8804 #[tokio::test]
8805 async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
8806 let mut state = landing_state();
8807 let forge = Scripted::new(
8808 vec![
8809 red("a", Some("7")),
8810 seen("a", Checks::Green, "CLEAN", false),
8811 ],
8812 vec![(true, "")],
8813 );
8814 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8815 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8817 assert_eq!(forge.reruns.lock().unwrap().len(), 1);
8818 let calls = forge.calls();
8819 let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
8820 let fix_at = calls.iter().position(|c| *c == "fix");
8821 assert!(fix_at.is_none_or(|f| f > rerun_at));
8822 }
8823
8824 #[tokio::test]
8825 async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
8826 let mut state = landing_state();
8827 let mut v = red("a", Some("7"));
8828 v.pr.checks = Checks::Pending;
8829 v.contexts.push(CheckInfo {
8830 label: "slow".to_owned(),
8831 verdict: Verdict::Pending,
8832 required: Some(true),
8833 run: Some("8".to_owned()),
8834 });
8835 let forge = Scripted::new(
8836 vec![v, seen("a", Checks::Green, "CLEAN", false)],
8837 vec![(true, "")],
8838 );
8839 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8840 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8841 assert_eq!(state.status, RunStatus::Merged);
8842 }
8843
8844 #[test]
8845 fn a_run_with_a_job_still_going_is_not_rerun_yet() {
8846 let mut v = red("a", Some("7"));
8847 v.contexts.push(CheckInfo {
8848 label: "same run, still going".to_owned(),
8849 verdict: Verdict::Pending,
8850 required: Some(true),
8851 run: Some("7".to_owned()),
8852 });
8853 assert!(rerunnable(&v.contexts).is_empty());
8854 }
8855
8856 #[tokio::test]
8857 async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
8858 let mut state = landing_state();
8859 state.config.graph.land_rounds = 0;
8860 let forge = Scripted::new(
8861 vec![
8862 red("a", Some("7")),
8863 seen("a", Checks::Green, "CLEAN", false),
8864 ],
8865 vec![(true, "")],
8866 );
8867 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8868 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8869 assert_eq!(state.status, RunStatus::Merged);
8870 assert!(!forge.calls().contains(&"fix"));
8871 assert_eq!(state.pr.as_ref().unwrap().round, 0);
8872 }
8873
8874 #[tokio::test]
8875 async fn the_last_fix_rounds_new_head_gets_its_rerun() {
8876 let mut state = landing_state();
8877 state.config.graph.land_rounds = 1;
8878 let forge = Scripted::new(
8879 vec![
8880 seen("a", Checks::Green, "CLEAN", true),
8881 red("b", Some("7")),
8882 seen("b", Checks::Green, "CLEAN", false),
8883 ],
8884 vec![(true, "")],
8885 );
8886 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
8887 head: "b".to_owned(),
8888 });
8889 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8890 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8891 assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
8892 assert_eq!(state.pr.as_ref().unwrap().round, 1);
8893 assert_eq!(state.status, RunStatus::Merged);
8894 assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
8895 assert_eq!(
8896 value_of(&forge.argvs()[0], "--match-head-commit"),
8897 Some("b")
8898 );
8899 }
8900
8901 #[tokio::test]
8902 async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
8903 let mut state = landing_state();
8904 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8905 *forge.rerun_fails.lock().unwrap() = true;
8906 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8907 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8908 assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
8909 assert_eq!(
8910 forge.calls().iter().filter(|c| **c == "fix").count(),
8911 1,
8912 "{:?}",
8913 forge.calls()
8914 );
8915 }
8916
8917 #[tokio::test]
8918 async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
8919 let mut state = landing_state();
8920 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8921 state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
8923 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8924 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8925 assert!(forge.reruns.lock().unwrap().is_empty());
8926 }
8927
8928 #[tokio::test]
8929 async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
8930 let mut state = landing_state();
8931 state.config.graph.rerun_flaky_checks = false;
8932 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8933 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8934 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8935 assert!(forge.reruns.lock().unwrap().is_empty());
8936 assert!(forge.calls().contains(&"fix"));
8937
8938 let mut state = landing_state();
8939 let forge = Scripted::new(vec![red("a", None)], vec![]);
8940 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8941 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8942 assert!(forge.reruns.lock().unwrap().is_empty());
8943 assert!(forge.calls().contains(&"fix"));
8944 }
8945
8946 fn names(xs: &[&str]) -> BTreeSet<String> {
8947 xs.iter().map(|x| (*x).to_owned()).collect()
8948 }
8949
8950 #[test]
8951 fn a_required_check_the_rollup_never_listed_is_named() {
8952 let req = names(&["build"]);
8953 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
8954 assert!(why.contains("never reported: build"), "{why}");
8955 assert!(!why.contains("probably waiting for a review"), "{why}");
8956 }
8957
8958 #[test]
8959 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
8960 let why = waiting_on("BLOCKED", &[passing("review")], None);
8961 assert!(why.contains("could not be read"), "{why}");
8962 assert!(!why.contains("probably waiting for a review"), "{why}");
8963 }
8964
8965 #[test]
8966 fn all_required_reported_keeps_the_review_guess() {
8967 let req = names(&["build"]);
8968 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
8969 assert!(why.contains("probably waiting for a review"), "{why}");
8970 assert!(!why.contains("never reported"), "{why}");
8971 }
8972
8973 #[test]
8974 fn required_names_match_the_rollup_ignoring_case_only() {
8975 let req = names(&["Build"]);
8976 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
8977 assert!(!why.contains("never reported"), "{why}");
8978 }
8979
8980 #[test]
8981 fn required_contexts_are_read_from_protection_and_rulesets() {
8982 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
8983 assert_eq!(
8984 parse_classic_required(classic),
8985 Some(names(&["build", "lint"]))
8986 );
8987 let rules = r#"[{"type":"pull_request","parameters":{}},
8988 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
8989 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
8990 assert_eq!(parse_ruleset_required("nope"), None);
8991 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
8992 }
8993
8994 #[test]
8995 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
8996 let shown = BTreeSet::new();
8997 let ok = seen("a", Checks::Green, "CLEAN", false);
8998 let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
8999 assert!(guard(Some(&ok)));
9000 assert!(!guard(None));
9001 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
9002 let mut stale = ok.clone();
9003 stale.rollup_head = "old".to_owned();
9004 assert!(!guard(Some(&stale)));
9005 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
9006 assert!(!guard(Some(&merged_view("a"))));
9007 }
9008
9009 #[test]
9010 fn the_rollup_node_carries_whether_each_check_is_required() {
9011 let node = node_json("new", "SUCCESS", false)
9012 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
9013 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
9014 assert_eq!(s.contexts.len(), 1);
9015 assert_eq!(s.contexts[0].required, Some(true));
9016 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
9017 assert_eq!(s.contexts[0].required, None);
9018 }
9019
9020 #[tokio::test]
9021 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
9022 let mut state = landing_state();
9023 state.land_armed_head = Some("a".to_owned());
9024 let forge = Scripted::new(
9025 vec![seen("a", Checks::Green, "CLEAN", true)],
9026 vec![(false, "disable exploded")],
9027 );
9028 land_with(&mut state, URL, &forge).await.unwrap();
9029 assert!(!forge.calls().contains(&"fix"));
9030 assert_eq!(state.status, RunStatus::Blocked);
9031 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
9032 let why = state.merge.as_ref().unwrap().detail.clone();
9033 assert!(why.contains("disable exploded"), "{why}");
9034 }
9035}