From bb50ac3b24f39c5fc8c7f67c03ab2ea34543c111 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:40:06 -0700 Subject: [PATCH] fix: block stale and unstable merge states Treat GitHub BEHIND and UNSTABLE merge states as non-ready so the queue reports the real blocker instead of ready-to-merge status. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/agent_merge_queue/cli.py | 7 ++++++ tests/test_cli.py | 44 +++++++++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index 7e8d198..2041997 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -818,12 +818,15 @@ def classify( elif self.queue_state != "queued": blocked.append("queue authorization was revoked") + required_checks_passed = bool(config.required_checks) for name in config.required_checks: status = self.checks.get(name) if status == "failed": blocked.append(f"{name} failed") + required_checks_passed = False elif status != "passed": waiting.append(f"{name} is not complete") + required_checks_passed = False for verdict in self.review_verdicts: if verdict.state == "blocked": @@ -833,10 +836,14 @@ def classify( if self.mergeable == "CONFLICTING" or self.merge_state == "DIRTY": blocked.append("pull request conflicts with main") + elif self.merge_state == "BEHIND": + blocked.append("GitHub reports the pull request head ref is out of date") elif self.merge_state in {"BLOCKED", "DRAFT"}: blocked.append( f"GitHub reports the pull request merge state as {self.merge_state}" ) + elif self.merge_state == "UNSTABLE" and not required_checks_passed: + waiting.append("GitHub reports non-passing commit status") elif self.merge_state == "UNKNOWN" or self.mergeable != "MERGEABLE": waiting.append("GitHub is still computing mergeability") diff --git a/tests/test_cli.py b/tests/test_cli.py index e03cc5c..491756a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1717,20 +1717,48 @@ def test_review_verdicts_are_classified_generically(self) -> None: self.assertEqual(value.state, "blocked") self.assertIn("one finding", value.reasons) - def test_github_blocked_state_fails_closed_but_mergeable_states_do_not( - self, - ) -> None: + def test_clean_merge_state_is_ready_when_other_policy_passes(self) -> None: + clean = entry(1) + clean.merge_state = "CLEAN" + clean.classify(CONFIG) + self.assertEqual(clean.state, "ready") + self.assertEqual(clean.reasons, []) + + def test_has_hooks_merge_state_is_ready_when_other_policy_passes(self) -> None: + has_hooks = entry(1) + has_hooks.merge_state = "HAS_HOOKS" + has_hooks.classify(CONFIG) + self.assertEqual(has_hooks.state, "ready") + self.assertEqual(has_hooks.reasons, []) + + def test_github_blocked_state_fails_closed(self) -> None: blocked = entry(1) blocked.merge_state = "BLOCKED" blocked.classify(CONFIG) self.assertEqual(blocked.state, "blocked") self.assertIn("merge state as BLOCKED", blocked.reasons[-1]) - for index, state in enumerate(("BEHIND", "HAS_HOOKS", "UNSTABLE"), start=2): - value = entry(index) - value.merge_state = state - value.classify(CONFIG) - self.assertEqual(value.state, "ready", state) + def test_behind_merge_state_is_blocked(self) -> None: + behind = entry(2) + behind.merge_state = "BEHIND" + behind.classify(CONFIG) + self.assertEqual(behind.state, "blocked") + self.assertIn("head ref is out of date", behind.reasons[-1]) + + def test_unstable_merge_state_waits_without_required_check_proof(self) -> None: + unstable = entry(3) + unstable.merge_state = "UNSTABLE" + unstable.checks = {} + unstable.classify(CONFIG) + self.assertEqual(unstable.state, "waiting") + self.assertIn("non-passing commit status", unstable.reasons[-1]) + + def test_unstable_merge_state_allows_exact_required_check_success(self) -> None: + unstable = entry(4) + unstable.merge_state = "UNSTABLE" + unstable.classify(CONFIG) + self.assertEqual(unstable.state, "ready") + self.assertEqual(unstable.reasons, []) def test_overlap_groups_are_connected_components(self) -> None: groups = overlap_groups(