From 21807ab41a130f645b4eb05a53ca68667ccd7512 Mon Sep 17 00:00:00 2001 From: Justin <9146678+brickfrog@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:37:09 -0500 Subject: [PATCH 1/4] fix: fail closed on unknown audit default branch --- src/server/handler_poll_delivery.mbt | 7 +- src/tools/pr.mbt | 30 +++++++ src/tools/pr_merged_completion_hooks.mbt | 6 ++ src/tools/pr_merged_completion_hooks_test.mbt | 78 +++++++++++++++++++ src/tools/pr_test.mbt | 16 ++++ src/tools/wave_state.mbt | 9 ++- 6 files changed, 144 insertions(+), 2 deletions(-) diff --git a/src/server/handler_poll_delivery.mbt b/src/server/handler_poll_delivery.mbt index 55e71cb4..f2a5e9d9 100644 --- a/src/server/handler_poll_delivery.mbt +++ b/src/server/handler_poll_delivery.mbt @@ -2329,10 +2329,14 @@ async fn ServerState::refresh_tracked_pr_audit_receipt_states( if tracked_prs.length() == 0 { return } - let default_branch = @tools.detect_default_branch_with_capture( + let default_branch_result = @tools.detect_default_branch_result_with_capture( self.config.project_dir, capture, ) + let default_branch = match default_branch_result { + Ok(branch) => branch + Err(_) => "main" + } self.cached_default_branch = default_branch for tracked in tracked_prs { match self.poller.get(tracked.pr_number) { @@ -2346,6 +2350,7 @@ async fn ServerState::refresh_tracked_pr_audit_receipt_states( capture, read_file, path_exists, + default_branch_result=Some(default_branch_result), ) self.poller.restore({ ..current, audit_receipt_state, }) } diff --git a/src/tools/pr.mbt b/src/tools/pr.mbt index 58b4155d..e100e194 100644 --- a/src/tools/pr.mbt +++ b/src/tools/pr.mbt @@ -718,6 +718,36 @@ pub async fn detect_default_branch_with_capture( } } +///| +/// Detect the repo's default branch while preserving git lookup failures. +/// This is for callers whose decision must fail closed when the branch is unknown. +pub async fn detect_default_branch_result_with_capture( + project_dir : String, + capture : async (@workspace.Command) -> (Int, String), +) -> Result[String, Unit] { + let cmd : @workspace.Command = { + program: "git", + args: ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], + workdir: Some(project_dir), + } + let (exit_code, output) = capture(cmd) + if exit_code != 0 { + return Err(()) + } + let trimmed = output.trim().to_owned() + let prefix = "origin/" + let branch = if trimmed.has_prefix(prefix) { + trimmed[prefix.length():].to_owned() + } else { + trimmed + } + if branch == "" { + Err(()) + } else { + Ok(branch) + } +} + ///| /// Detect the repo's default branch via git symbolic-ref. /// Falls back to "main" if detection fails. diff --git a/src/tools/pr_merged_completion_hooks.mbt b/src/tools/pr_merged_completion_hooks.mbt index 942ea0a1..6be38eae 100644 --- a/src/tools/pr_merged_completion_hooks.mbt +++ b/src/tools/pr_merged_completion_hooks.mbt @@ -189,7 +189,13 @@ pub async fn audit_receipt_advisory_state_for_tracked_pr( capture : async (@workspace.Command) -> (Int, String), read_file : (String) -> String, path_exists : (String) -> Bool, + default_branch_result? : Result[String, Unit]? = None, ) -> @types.AuditReceiptState { + let default_branch = match default_branch_result { + Some(Ok(branch)) => branch + Some(Err(_)) => return @types.AuditReceiptState::Pending(head_sha~) + None => default_branch + } if !file_pr_audit_receipt_required(parent_branch, default_branch) { return @types.AuditReceiptState::NotRequired } diff --git a/src/tools/pr_merged_completion_hooks_test.mbt b/src/tools/pr_merged_completion_hooks_test.mbt index a6ee986a..64da8530 100644 --- a/src/tools/pr_merged_completion_hooks_test.mbt +++ b/src/tools/pr_merged_completion_hooks_test.mbt @@ -31,6 +31,84 @@ fn hooks_test_receipt_for_tree( receipt.to_json().stringify() + "\n" } +///| +async fn hooks_test_default_branch_failure_capture( + cmd : @workspace.Command, +) -> (Int, String) { + @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + (1, "error: git unavailable\n") + } else { + (-1, "") + } +} + +///| +async fn hooks_test_default_branch_master_capture( + cmd : @workspace.Command, +) -> (Int, String) { + @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + (0, "origin/master\n") + } else { + (-1, "") + } +} + +///| +async test "audit receipt advisory is pending when default branch detection fails" { + let head = "abcdef1234567890abcdef1234567890abcdef12" + let default_branch = detect_default_branch_result_with_capture( + "/repo", hooks_test_default_branch_failure_capture, + ) + let state = audit_receipt_advisory_state_for_tracked_pr( + "/repo", + "feature/base", + "main", + head, + 100L, + async fn(_) { + @async.sleep(0) + fail("audit capture must not run when default branch is unknown") + (0, "") + }, + hooks_test_gate_state_reader(1L), + hooks_test_gate_path_exists, + default_branch_result=Some(default_branch), + ) + assert_eq(state, @types.AuditReceiptState::Pending(head_sha=head)) +} + +///| +async test "audit receipt advisory uses resolved default branch on success" { + let head = "abcdef1234567890abcdef1234567890abcdef12" + let tree = "9999999999999999999999999999999999999999" + let default_branch = detect_default_branch_result_with_capture( + "/repo", hooks_test_default_branch_master_capture, + ) + let state = audit_receipt_advisory_state_for_tracked_pr( + "/repo", + "master", + "main", + head, + 100L, + async fn(cmd : @workspace.Command) -> (Int, String) { + @async.sleep(0) + if cmd.program == "git" { + (0, tree + "\n") + } else { + (0, hooks_test_receipt_for_tree(tree, 0)) + } + }, + hooks_test_gate_state_reader(1L), + hooks_test_gate_path_exists, + default_branch_result=Some(default_branch), + ) + assert_eq(state, @types.AuditReceiptState::Valid) +} + ///| async test "audit_receipt_advisory_state_for_tracked_pr maps gate and receipt states" { let head = "abcdef1234567890abcdef1234567890abcdef12" diff --git a/src/tools/pr_test.mbt b/src/tools/pr_test.mbt index 3137015a..bf58b21e 100644 --- a/src/tools/pr_test.mbt +++ b/src/tools/pr_test.mbt @@ -467,6 +467,22 @@ async test "detect_default_branch_with_capture falls back to main on failure" { assert_eq(branch, "main") } +///| +async test "detect_default_branch_result_with_capture returns branch on success" { + let branch = detect_default_branch_result_with_capture( + "/repo", mock_detect_origin_master, + ) + assert_eq(branch, Ok("master")) +} + +///| +async test "detect_default_branch_result_with_capture exposes failure" { + let branch = detect_default_branch_result_with_capture( + "/repo", mock_detect_failure, + ) + assert_eq(branch, Err(())) +} + ///| fn mock_is_gh_pr_view(cmd : @workspace.Command) -> Bool { ( diff --git a/src/tools/wave_state.mbt b/src/tools/wave_state.mbt index c931b5e7..9e524872 100644 --- a/src/tools/wave_state.mbt +++ b/src/tools/wave_state.mbt @@ -1174,7 +1174,13 @@ pub async fn interpret_wave_state( None => (None, Some("thread state not fetched")) } } - let default_branch = detect_default_branch_with_capture(project_dir, capture) + let default_branch_result = detect_default_branch_result_with_capture( + project_dir, capture, + ) + let default_branch = match default_branch_result { + Ok(branch) => branch + Err(_) => "main" + } let audit_receipt_map : Map[Int, @types.AuditReceiptState] = {} for pr_number in pr_numbers { match poller_state { @@ -1192,6 +1198,7 @@ pub async fn interpret_wave_state( capture, read_file, path_exists, + default_branch_result=Some(default_branch_result), ), ) None => () From da7fc5292690ab628023651ecea4645df8a37033 Mon Sep 17 00:00:00 2001 From: Justin <9146678+brickfrog@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:48:19 -0500 Subject: [PATCH 2/4] test: cover automerge branch detection failure --- src/server/handler_test.mbt | 122 ++++++++++++++++++++++++++++++++++++ src/tools/pr_test.mbt | 10 ++- 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/src/server/handler_test.mbt b/src/server/handler_test.mbt index 8ac1966c..89a9d4ab 100644 --- a/src/server/handler_test.mbt +++ b/src/server/handler_test.mbt @@ -2931,6 +2931,128 @@ async test "automerge sweep resolves default branch before audit gate readiness" assert_false(delivery_log.contains("automerge attempt pr=102")) } +///| +async test "automerge sweep blocks when default branch detection fails" { + let pr_number = 103 + let label = "sweep-audit-default-branch-failure" + let (state, _, project_dir) = blocked_gh_automerge_fixture( + label, + pr_number, + review_mode=@types.ReviewMode::Iterative, + ) + assert_true( + @sys.write_file_sync( + project_dir + "/.choir/state/audit-gate.json", + "{\"enabled_at_unix\":1}\n", + ), + ) + match state.poller.get(pr_number) { + Some(tracked) => + state.poller.restore({ + ..tracked, + parent_branch: "trunk", + last_sha: "sha-" + label, + unresolved_thread_ids: [], + unresolved_thread_ids_fetched: true, + }) + None => fail("expected tracked PR") + } + let mut merge_seen = false + let runner = async fn(cmds : Array[@workspace.Command]) { + @async.sleep(0) + let statuses = [] + for cmd in cmds { + if cmd.program == "gh" && + cmd.args.length() >= 3 && + cmd.args[0] == "pr" && + cmd.args[1] == "merge" { + merge_seen = true + } + statuses.push(0) + } + statuses + } + let mut default_branch_checks = 0 + let capture = async fn(cmd : @workspace.Command) -> (Int, String) { + @async.sleep(0) + let repo_view_json = + #|{"owner":{"login":"brickfrog"},"name":"choir"} + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + default_branch_checks = default_branch_checks + 1 + return (1, "error: git unavailable\n") + } + if ( + cmd.program == "gh" && + cmd.args == ["repo", "view", "--json", "owner,name"] + ) || + ( + cmd.program == "timeout" && + cmd.args == ["30s", "gh", "repo", "view", "--json", "owner,name"] + ) { + return (0, repo_view_json) + } + if cmd.program == "gh" && + cmd.args.length() >= 2 && + cmd.args[0] == "api" && + cmd.args[1].has_suffix("/reviews") { + return ( + 0, "[{\"state\":\"APPROVED\",\"user\":{\"login\":\"copilot-pull-request-reviewer[bot]\"},\"submitted_at\":\"2026-05-10T00:00:00Z\"}]", + ) + } + if cmd.args.contains("graphql") { + return ( + 0, "{\"data\":{\"repository\":{\"pullRequest\":{\"reviewThreads\":{\"nodes\":[]}}}}}", + ) + } + if cmd.program == "git" && + cmd.args == ["rev-parse", "sha-" + label + "^{tree}"] { + return (0, "tree-" + label + "\n") + } + if cmd.program == "cat" { + return (1, "") + } + (0, "") + } + state.process_poller_tick_with_test( + 500L, + 900L, + async fn(_pr_number, _skip_issue_comment_fetch) { + @async.sleep(0) + ( + true, + @poller.ReviewState::Approved, + "sha-" + label, + @poller.CiRollupStatus::Success, + "", + true, + false, + @poller.Mergeable::Unknown, + @poller.MergeStateStatus::Unknown, + None, + ) + }, + None, + "", + runner~, + capture~, + automerge_capture=capture, + audit_receipt_exists=@sys.path_entry_exists, + audit_receipt_read=@sys.read_file, + ) + assert_true(default_branch_checks > 0) + assert_false(merge_seen) + match state.poller.get(pr_number) { + Some(current) => assert_eq(current.last_automerge_attempt_sha, "") + None => fail("default-branch failure must keep the PR tracked") + } + let delivery_log = @sys.read_file(project_dir + "/.choir/logs/delivery.log") + assert_false( + delivery_log.contains("automerge sweep attempt pr=103 sha=sha-" + label), + ) + assert_false(delivery_log.contains("automerge attempt pr=103")) +} + ///| async test "automerge sweep runs after queued parent rows are delivered" { let pr_number = 98 diff --git a/src/tools/pr_test.mbt b/src/tools/pr_test.mbt index bf58b21e..f48ac5d8 100644 --- a/src/tools/pr_test.mbt +++ b/src/tools/pr_test.mbt @@ -472,7 +472,10 @@ async test "detect_default_branch_result_with_capture returns branch on success" let branch = detect_default_branch_result_with_capture( "/repo", mock_detect_origin_master, ) - assert_eq(branch, Ok("master")) + match branch { + Ok(value) => assert_eq(value, "master") + Err(_) => fail("expected default branch detection to succeed") + } } ///| @@ -480,7 +483,10 @@ async test "detect_default_branch_result_with_capture exposes failure" { let branch = detect_default_branch_result_with_capture( "/repo", mock_detect_failure, ) - assert_eq(branch, Err(())) + match branch { + Ok(value) => fail("expected default branch detection to fail: " + value) + Err(_) => () + } } ///| From 7b9a76fadcf103b2173c86368613357399d44341 Mon Sep 17 00:00:00 2001 From: Justin <9146678+brickfrog@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:55:50 -0500 Subject: [PATCH 3/4] fix: close remaining audit branch bypasses --- src/server/handler_automerge.mbt | 7 +++++- src/server/handler_poll_delivery.mbt | 34 +++++++++++++++++++++++----- src/tools/pr.mbt | 2 +- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/server/handler_automerge.mbt b/src/server/handler_automerge.mbt index e3b941f3..03a180ec 100644 --- a/src/server/handler_automerge.mbt +++ b/src/server/handler_automerge.mbt @@ -396,10 +396,14 @@ pub async fn maybe_trigger_automerge_for_pending_event( ) None => [] } - let default_branch = @tools.detect_default_branch_with_capture( + let default_branch_result = @tools.detect_default_branch_result_with_capture( state.config.project_dir, capture, ) + let default_branch = match default_branch_result { + Ok(branch) => branch + Err(_) => "main" + } let audit_receipt_state = @tools.audit_receipt_advisory_state_for_tracked_pr( state.config.project_dir, tracked.parent_branch, @@ -409,6 +413,7 @@ pub async fn maybe_trigger_automerge_for_pending_event( capture, read_file, path_exists, + default_branch_result=Some(default_branch_result), ) let decision = @poller.merge_policy_decision_for_tracked_pr( tracked, diff --git a/src/server/handler_poll_delivery.mbt b/src/server/handler_poll_delivery.mbt index f2a5e9d9..f0dc897f 100644 --- a/src/server/handler_poll_delivery.mbt +++ b/src/server/handler_poll_delivery.mbt @@ -2435,10 +2435,20 @@ async fn deliver_pending_poll_events( if retried_events.length() > 0 { state.request_goal_judge_tick() } - let default_branch = if pending_events.length() > 0 { - @tools.detect_default_branch_with_capture(state.config.project_dir, capture) + let default_branch_result = if pending_events.length() > 0 { + Some( + @tools.detect_default_branch_result_with_capture( + state.config.project_dir, + capture, + ), + ) } else { - "" + None + } + let default_branch = match default_branch_result { + Some(Ok(branch)) => branch + Some(Err(_)) => "main" + None => "" } for i = 0; i < pending_events.length(); i = i + 1 { let event = pending_events[i] @@ -2512,6 +2522,7 @@ async fn deliver_pending_poll_events( capture, audit_receipt_read, audit_receipt_exists, + default_branch_result~, ) } None => @types.AuditReceiptState::NotRequired @@ -2852,10 +2863,20 @@ async fn ServerState::sweep_tracked_prs_for_automerge( for tracked in self.poller.tracked.values() { tracked_prs.push(tracked) } - let default_branch = if tracked_prs.length() > 0 { - @tools.detect_default_branch_with_capture(self.config.project_dir, capture) + let default_branch_result = if tracked_prs.length() > 0 { + Some( + @tools.detect_default_branch_result_with_capture( + self.config.project_dir, + capture, + ), + ) } else { - "" + None + } + let default_branch = match default_branch_result { + Some(Ok(branch)) => branch + Some(Err(_)) => "main" + None => "" } for tracked in tracked_prs { let current = match self.poller.get(tracked.pr_number) { @@ -2884,6 +2905,7 @@ async fn ServerState::sweep_tracked_prs_for_automerge( capture, read_file, path_exists, + default_branch_result~, ) let gate_ready = @poller.tl_tracked_pr_merge_gate_snapshot_ready( current, diff --git a/src/tools/pr.mbt b/src/tools/pr.mbt index e100e194..ac0f96f4 100644 --- a/src/tools/pr.mbt +++ b/src/tools/pr.mbt @@ -742,7 +742,7 @@ pub async fn detect_default_branch_result_with_capture( trimmed } if branch == "" { - Err(()) + Ok("main") } else { Ok(branch) } From 70d660b4f19abd8cc72161c89734cbacf54d6a74 Mon Sep 17 00:00:00 2001 From: Justin <9146678+brickfrog@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:30:08 -0500 Subject: [PATCH 4/4] fix: fail closed on unknown merge default branch --- src/bin/choir/uds_server.mbt | 4 + src/server/handler_test.mbt | 64 ++++++++++++++ src/server/server_test.mbt | 4 + src/tools/merge_pr.mbt | 24 ++++- src/tools/merge_pr_test.mbt | 165 +++++++++++++++++++++++++++++++++++ src/tools/pr.mbt | 2 +- src/tools/pr_test.mbt | 22 +++++ 7 files changed, 283 insertions(+), 2 deletions(-) diff --git a/src/bin/choir/uds_server.mbt b/src/bin/choir/uds_server.mbt index d9135001..f1cad838 100644 --- a/src/bin/choir/uds_server.mbt +++ b/src/bin/choir/uds_server.mbt @@ -3604,6 +3604,10 @@ async test "heartbeat tick loop triggers automerge sweep path via process_poller } let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } let repo_view_json = #|{"owner":{"login":"brickfrog"},"name":"choir"} if ( diff --git a/src/server/handler_test.mbt b/src/server/handler_test.mbt index 89a9d4ab..2a7b8acf 100644 --- a/src/server/handler_test.mbt +++ b/src/server/handler_test.mbt @@ -1156,6 +1156,10 @@ async test "maybe_trigger_automerge_for_pending_event reuses merge_pr path" { } let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } let repo_view_json = #|{"owner":{"login":"brickfrog"},"name":"choir"} let review_threads_json = @@ -1277,6 +1281,10 @@ async test "maybe_trigger_automerge_for_pending_event satisfies non-native moon } let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } let repo_view_json = "{\"owner\":{\"login\":\"brickfrog\"},\"name\":\"choir\"}" let review_threads_json = "{\"data\":{\"repository\":{\"pullRequest\":{\"reviewThreads\":{\"nodes\":[]}}}}}" if cmd.args.contains("repo") && cmd.args.contains("view") { @@ -1362,6 +1370,10 @@ async test "maybe_trigger_automerge_for_pending_event treats NoChecks as verify- } let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } let repo_view_json = "{\"owner\":{\"login\":\"brickfrog\"},\"name\":\"choir\"}" let review_threads_json = "{\"data\":{\"repository\":{\"pullRequest\":{\"reviewThreads\":{\"nodes\":[]}}}}}" if cmd.args.contains("repo") && cmd.args.contains("view") { @@ -1647,6 +1659,10 @@ async test "refresh_pr_with pending events fires automerge after fixes and CI cl let mut review_gate_calls = 0 let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } let repo_view_json = #|{"owner":{"login":"brickfrog"},"name":"choir"} let blocked_threads_json = @@ -2091,6 +2107,10 @@ async test "quiet poller tick automerge sweep records attempted head on success" } let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } let repo_view_json = #|{"owner":{"login":"brickfrog"},"name":"choir"} if ( @@ -2176,6 +2196,10 @@ async test "quiet poller tick automerge sweep retries reviewerless ready PR" { } let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } let repo_view_json = #|{"owner":{"login":"brickfrog"},"name":"choir"} if ( @@ -2305,6 +2329,10 @@ async test "quiet poller tick automerge sweep finalizes stale-role tracked leaf } let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } let repo_view_json = #|{"owner":{"login":"brickfrog"},"name":"choir"} if ( @@ -2462,6 +2490,10 @@ async test "quiet poller tick automerge sweep records attempted head on failure" } let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } let repo_view_json = #|{"owner":{"login":"brickfrog"},"name":"choir"} if ( @@ -2616,6 +2648,10 @@ async test "maybe_trigger_automerge_for_pending_event waits while audit receipt } let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } let repo_view_json = #|{"owner":{"login":"brickfrog"},"name":"choir"} if ( @@ -2739,6 +2775,10 @@ async test "maybe_trigger_automerge_for_pending_event waits while audit findings } let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } let repo_view_json = #|{"owner":{"login":"brickfrog"},"name":"choir"} if ( @@ -3097,6 +3137,10 @@ async test "automerge sweep runs after queued parent rows are delivered" { let mut graphql_calls = 0 let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } let repo_view_json = #|{"owner":{"login":"brickfrog"},"name":"choir"} if ( @@ -3189,6 +3233,10 @@ async test "blocked gh notify auto-merges when latest poller snapshot is clear" } let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } let repo_view_json = #|{"owner":{"login":"brickfrog"},"name":"choir"} let review_threads_json = @@ -3267,6 +3315,10 @@ async test "blocked gh notify honors single-pass review mode for fixes pushed" { } let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } let repo_view_json = #|{"owner":{"login":"brickfrog"},"name":"choir"} let review_threads_json = @@ -3342,6 +3394,10 @@ async test "blocked gh notify uses tracked review mode over global config" { } let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } if cmd.args.contains("repo") && cmd.args.contains("view") { return (0, "{\"owner\":{\"login\":\"brickfrog\"},\"name\":\"choir\"}") } @@ -6023,6 +6079,10 @@ async test "quiet poller tick successful auto resolve retry re-enters automerge let mut review_gate_calls = 0 let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } let repo_view_json = #|{"owner":{"login":"brickfrog"},"name":"choir"} let unresolved_thread_json = @@ -6295,6 +6355,10 @@ async test "quiet poller tick never-fetched retry clear re-enters automerge pipe let mut review_gate_calls = 0 let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } let repo_view_json = #|{"owner":{"login":"brickfrog"},"name":"choir"} let clear_threads_json = diff --git a/src/server/server_test.mbt b/src/server/server_test.mbt index 3d88b219..40d297c4 100644 --- a/src/server/server_test.mbt +++ b/src/server/server_test.mbt @@ -1150,6 +1150,10 @@ async test "merge_pr after restart finalizes offline recovered owner and updates } let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } // AUDIT: Hardcoded APPROVED response is a fixture precondition, not the // assertion target. This test verifies owner-lineage recovery across a // restart when the original PR owner is offline; the approval is the diff --git a/src/tools/merge_pr.mbt b/src/tools/merge_pr.mbt index 61ab739f..44bcfa16 100644 --- a/src/tools/merge_pr.mbt +++ b/src/tools/merge_pr.mbt @@ -28,6 +28,7 @@ pub(all) enum MergePrAuditReceiptValidation { pub(all) enum MergePrAuditGateStatus { MergeAuditGateReceiptBlocked(MergePrAuditReceiptValidation) MergeAuditGateHeadUnavailable + MergeAuditGateDefaultBranchUnavailable } derive(Debug, Eq) ///| @@ -64,6 +65,7 @@ fn MergePrAuditGateStatus::to_wire_string( MergeAuditReceiptTreeUnavailable(_) => "receipt_tree_unavailable" } MergeAuditGateHeadUnavailable => "head_unavailable" + MergeAuditGateDefaultBranchUnavailable => "default_branch_unavailable" } } @@ -141,6 +143,12 @@ pub fn merge_pr_audit_gate_policy_block_message( pr_number.to_string() + " head SHA from tracked poller state. Wait for a poller snapshot with last_sha before merging.", ) + MergeAuditGateDefaultBranchUnavailable => + Some( + "policy blocked: audit receipt gate cannot determine the repository default branch for PR #" + + pr_number.to_string() + + ". Wait for git default-branch detection to succeed before merging.", + ) MergeAuditGateReceiptBlocked(MergeAuditReceiptValid) => None MergeAuditGateReceiptBlocked(validation) => { let summary = merge_pr_audit_receipt_validation_summary(validation) @@ -2283,7 +2291,21 @@ pub async fn interpret_merge_pr( ps.write_state_file(dir) }, ) -> @types.Response { - let default_branch = detect_default_branch_with_capture(project_dir, capture) + let default_branch_result = detect_default_branch_result_with_capture( + project_dir, capture, + ) + let default_branch = match default_branch_result { + Ok(branch) => branch + Err(_) => + return @types.Response::Err( + merge_pr_audit_block_error( + project_dir, + merge_args.pr_number, + MergeAuditGateDefaultBranchUnavailable, + append_file, + ).message(), + ) + } match interpret_merge_pr_preflight( merge_args.pr_number, diff --git a/src/tools/merge_pr_test.mbt b/src/tools/merge_pr_test.mbt index 8ea97fd6..c3f4b11c 100644 --- a/src/tools/merge_pr_test.mbt +++ b/src/tools/merge_pr_test.mbt @@ -689,6 +689,115 @@ async test "interpret_merge_pr missing audit receipt policy-blocks on master def ignore(@sys.rm_rf(project_dir)) } +///| +async test "interpret_merge_pr blocks when default branch detection fails" { + let project_dir = "/tmp/choir-mpr-default-branch-failure-" + + @sys.get_pid().to_string() + let head = "abcdef1234567890abcdef1234567890abcdef12" + let ps = @poller.PollerState::new() + ps.track_recovered( + 95, + "main.leaf", + "feature/audit-gate", + "trunk", + "https://github.com/o/r/pull/95", + 200L, + @poller.ReviewState::Approved, + "", + head, + @poller.CiRollupStatus::Success, + true, + ) + let logs : Array[String] = [] + let hook_paths : Array[String] = [] + let mut runner_calls = 0 + let resp = interpret_merge_pr( + { + pr_number: 95, + parent_branch: "trunk", + caller_id: "main.tl", + review_mode: Some(@types.ReviewMode::SinglePass), + }, + project_dir, + Some(ps), + None, + None, + async fn(cmds : Array[@workspace.Command]) -> Array[Int] { + @async.sleep(0) + runner_calls = runner_calls + 1 + let statuses = [] + for _ in cmds { + statuses.push(0) + } + statuses + }, + async fn(cmd : @workspace.Command) -> (Int, String) { + @async.sleep(0) + if cmd.program == "gh" && + cmd.args.length() >= 2 && + cmd.args[0] == "api" && + cmd.args[1].has_suffix("/reviews") { + return (0, "[{\"state\":\"APPROVED\"}]") + } + if cmd.program == "timeout" && cmd.args.contains("repo") { + return (0, "{\"owner\":{\"login\":\"o\"},\"name\":\"r\"}") + } + if cmd.program == "timeout" && cmd.args.contains("graphql") { + return ( + 0, "{\"data\":{\"repository\":{\"pullRequest\":{\"reviewThreads\":{\"nodes\":[]}}}}}", + ) + } + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (1, "error: git unavailable\n") + } + if cmd.program == "git" && + cmd.args == ["rev-parse", "--abbrev-ref", "HEAD"] { + return (0, "feature/audit-gate\n") + } + if cmd.program == "cat" { + return (1, "") + } + (0, "") + }, + fn(_path, content) { + logs.push(content) + true + }, + fn(path) { path.has_suffix("/.choir/state/audit-gate.json") }, + hook_capture=async fn(path : String, _input_json : String, _cwd : String?) { + @async.sleep(0) + hook_paths.push(path) + (0, "") + }, + read_file=fn(path) { + if path.has_suffix("/.choir/state/audit-gate.json") { + "{\"enabled_at_unix\":100}\n" + } else { + "" + } + }, + task_contract_reader=merge_pr_test_empty_contract_reader, + ) + match resp { + @types.Response::Err(msg) => { + assert_true(msg.contains("default branch")) + assert_true(msg.contains("PR #95")) + } + @types.Response::Ok(_) => + fail("expected merge_pr to block when default branch detection fails") + } + assert_eq(runner_calls, 0) + assert_eq(hook_paths.length(), 0) + assert_true( + logs.iter().any(fn(line) { line.contains("outcome=audit_block") }), + ) + assert_true( + logs.iter().any(fn(line) { line.contains("default_branch_unavailable") }), + ) + ignore(@sys.rm_rf(project_dir)) +} + ///| async test "interpret_merge_pr audit receipt remains valid when commit changes but tree matches" { let project_dir = "/tmp/choir-mpr-audit-tree-reuse-" + @@ -827,6 +936,10 @@ async test "interpret_merge_pr rejects caller parent branch mismatch before merg }, async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } if cmd.program == "gh" && cmd.args.length() >= 2 && cmd.args[0] == "api" && @@ -918,6 +1031,10 @@ async test "interpret_merge_pr audit gate fails closed without poller state" { }, async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } if cmd.program == "git" && cmd.args == ["remote", "get-url", "origin"] { return (0, "https://github.com/o/r.git\n") } @@ -994,6 +1111,10 @@ async test "interpret_merge_pr audit gate fails closed for untracked PR" { }, async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } if cmd.program == "git" && cmd.args == ["remote", "get-url", "origin"] { return (0, "https://github.com/o/r.git\n") } @@ -1091,6 +1212,10 @@ async test "interpret_merge_pr main audit gate merges only matching audited head }, async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } if cmd.program == "gh" && cmd.args.length() >= 2 && cmd.args[0] == "api" && @@ -1695,6 +1820,10 @@ async fn mock_preflight_capture_no_unresolved_threads( cmd : @workspace.Command, ) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } if cmd.program == "git" && cmd.args == ["remote", "get-url", "origin"] { return (0, "https://github.com/o/r.git\n") } @@ -2249,6 +2378,10 @@ async fn mock_preflight_capture_one_unresolved_thread( cmd : @workspace.Command, ) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } if cmd.program == "git" && cmd.args == ["remote", "get-url", "origin"] { return (0, "https://github.com/o/r.git\n") } @@ -2890,6 +3023,10 @@ async fn mock_merge_host_capture_for_merge( cmd : @workspace.Command, ) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } if cmd.program == "git" && cmd.args == ["remote", "get-url", "origin"] { return (0, "https://github.com/o/r.git\n") } @@ -3965,6 +4102,10 @@ async test "interpret_merge_pr feature main closes wave and body beads then dele let ran : Array[@workspace.Command] = [] let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } if cmd.program == "gh" && cmd.args.length() >= 2 && cmd.args[0] == "api" && @@ -4133,6 +4274,10 @@ async test "interpret_merge_pr feature main with no issue ids does not call bd c let ran : Array[@workspace.Command] = [] let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } if cmd.program == "gh" && cmd.args.length() >= 2 && cmd.args[0] == "api" && @@ -4226,6 +4371,10 @@ async test "interpret_merge_pr leaf merge removes worktree and deletes local bra let ran : Array[@workspace.Command] = [] let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } if cmd.program == "gh" && cmd.args.length() >= 2 && cmd.args[0] == "api" && @@ -4324,6 +4473,10 @@ async test "dispatch merge_pr routes injected append_file and path_exists" { let path_checks : Array[String] = [] let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } if cmd.program == "gh" && cmd.args.length() >= 2 && cmd.args[0] == "api" && @@ -4401,6 +4554,10 @@ async test "interpret_merge_pr leaf merge skips missing worktree and still delet let ran : Array[@workspace.Command] = [] let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } if cmd.program == "gh" && cmd.args.length() >= 2 && cmd.args[0] == "api" && @@ -4508,6 +4665,10 @@ async test "interpret_merge_pr leaf cleanup refuses supervisor tracked agent" { let logs : Array[String] = [] let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } if cmd.program == "gh" && cmd.args.length() >= 2 && cmd.args[0] == "api" && @@ -4632,6 +4793,10 @@ async test "interpret_merge_pr leaf cleanup trusts dev lifecycle over stale supe let logs : Array[String] = [] let capture = async fn(cmd : @workspace.Command) -> (Int, String) { @async.sleep(0) + if cmd.program == "git" && + cmd.args == ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"] { + return (0, "origin/main\n") + } if cmd.program == "gh" && cmd.args.length() >= 2 && cmd.args[0] == "api" && diff --git a/src/tools/pr.mbt b/src/tools/pr.mbt index ac0f96f4..e100e194 100644 --- a/src/tools/pr.mbt +++ b/src/tools/pr.mbt @@ -742,7 +742,7 @@ pub async fn detect_default_branch_result_with_capture( trimmed } if branch == "" { - Ok("main") + Err(()) } else { Ok(branch) } diff --git a/src/tools/pr_test.mbt b/src/tools/pr_test.mbt index f48ac5d8..6c9fe83e 100644 --- a/src/tools/pr_test.mbt +++ b/src/tools/pr_test.mbt @@ -489,6 +489,28 @@ async test "detect_default_branch_result_with_capture exposes failure" { } } +///| +async fn mock_detect_empty_output(cmd : @workspace.Command) -> (Int, String) { + @async.sleep(0) + if is_symbolic_ref_command(cmd) { + (0, " \n") + } else { + (-1, "") + } +} + +///| +async test "detect_default_branch_result_with_capture exposes empty output" { + let branch = detect_default_branch_result_with_capture( + "/repo", mock_detect_empty_output, + ) + match branch { + Ok(value) => + fail("expected empty default branch detection to fail: " + value) + Err(_) => () + } +} + ///| fn mock_is_gh_pr_view(cmd : @workspace.Command) -> Bool { (