diff --git a/.gitea/scripts/gitea-merge-queue.py b/.gitea/scripts/gitea-merge-queue.py index 46b0482ad..e3e57ad2e 100644 --- a/.gitea/scripts/gitea-merge-queue.py +++ b/.gitea/scripts/gitea-merge-queue.py @@ -407,7 +407,23 @@ def process_once(*, dry_run: bool = False) -> int: "deferring to next tick" ) return 0 - merge_pull(pr_number, dry_run=dry_run) + try: + merge_pull(pr_number, dry_run=dry_run) + except ApiError as exc: + # Merge API errors (405 permission denied, 422 hook block, etc.) + # are NOT transient — retrying will not help. Surface the error + # on the PR immediately so it is visible without digging into + # workflow logs, and fail the workflow so it is distinguishable + # from a successful-no-op tick. + post_comment( + pr_number, + f"merge-queue: MERGE FAILED — {exc}. " + "This is a non-transient error (permission or hook issue). " + "See SEV-1 internal#487.", + dry_run=dry_run, + ) + sys.stderr.write(f"::error::PR #{pr_number} merge failed: {exc}\n") + return 2 # distinct exit code so workflow run shows failure return 0 return 0 diff --git a/.gitea/scripts/sop-checklist.py b/.gitea/scripts/sop-checklist.py index efd62e9c7..02407b096 100644 --- a/.gitea/scripts/sop-checklist.py +++ b/.gitea/scripts/sop-checklist.py @@ -830,9 +830,18 @@ def main(argv: list[str] | None = None) -> int: # one membership lookup per team. team_member_cache: dict[tuple[str, int], bool | None] = {} + def _required_teams_for(slug: str) -> list[str] | None: + """Look up required_teams for a slug from checklist items OR N/A gates.""" + if slug in items_by_slug: + return items_by_slug[slug]["required_teams"] + if slug in na_gates: + return na_gates[slug].get("required_teams", []) + return None + def probe(slug: str, users: list[str]) -> list[str]: - item = items_by_slug[slug] - team_names: list[str] = item["required_teams"] + team_names = _required_teams_for(slug) + if team_names is None: + raise KeyError(f"slug '{slug}' not found in items or N/A gates") # Resolve names → ids. NOTE: orgs/{org}/teams/search may not be # available — fall back to the list endpoint. team_ids: list[int] = [] diff --git a/.gitea/scripts/tests/test_gitea_merge_queue.py b/.gitea/scripts/tests/test_gitea_merge_queue.py index b01c6da22..747bf233a 100644 --- a/.gitea/scripts/tests/test_gitea_merge_queue.py +++ b/.gitea/scripts/tests/test_gitea_merge_queue.py @@ -1,6 +1,7 @@ import importlib.util import sys from pathlib import Path +from unittest.mock import patch SCRIPT = Path(__file__).resolve().parents[1] / "gitea-merge-queue.py" @@ -118,3 +119,54 @@ def test_merge_decision_updates_stale_pr_before_merge(): assert decision.ready is False assert decision.action == "update" + + +def test_merge_failure_returns_nonzero_and_posts_comment(monkeypatch): + """When merge_pull raises ApiError (e.g. HTTP 405 permission denied), + process_once returns exit code 2 (non-zero) and posts a comment on the PR. + This distinguishes merge-permission errors from successful-no-op ticks.""" + captured_comment = {} + + def fake_post_comment(pr_number, body, *, dry_run): + captured_comment["pr_number"] = pr_number + captured_comment["body"] = body + + # Replace functions directly on the module object so process_once() + # (which looks them up by name at call time) picks up the fakes. + mq.list_queued_issues = lambda: [{ + "number": 42, + "created_at": "2026-05-17T00:00:00Z", + "labels": [{"name": "merge-queue"}], + "pull_request": {}, + }] + mq.get_pull = lambda n: { + "state": "open", + "base": {"ref": "main", "repo_id": 1}, + "head": {"sha": "headsha", "repo_id": 1}, + "merge_base": "abc123def", + } + mq.get_pull_commits = lambda n: [{"sha": "headsha"}] + mq.get_branch_head = lambda branch: "abc123def" + mq.get_combined_status = lambda sha: { + "state": "success", + "statuses": [{"context": "CI / all-required (push)", "status": "success"}], + } + mq.latest_statuses_by_context = lambda s: { + "CI / all-required (pull_request)": {"status": "success"}, + "sop-checklist / all-items-acked (pull_request)": {"status": "success"}, + } + mq.required_contexts_green = lambda statuses, contexts: (True, []) + mq.post_comment = fake_post_comment + + # Simulate merge failing with HTTP 405 (permission denied). + # The ApiError raised by api() is caught inside process_once(). + merge_error = mq.ApiError( + "POST /repos/x/y/pulls/42/merge -> HTTP 405: User not allowed to merge PR" + ) + with patch.object(mq, "merge_pull", side_effect=merge_error): + exit_code = mq.process_once(dry_run=False) + + assert exit_code == 2, f"Expected exit code 2, got {exit_code}" + assert captured_comment["pr_number"] == 42 + assert "MERGE FAILED" in captured_comment["body"] + assert "405" in captured_comment["body"] diff --git a/.gitea/scripts/tests/test_sop_checklist.py b/.gitea/scripts/tests/test_sop_checklist.py index 91c016a13..3eb71dbce 100644 --- a/.gitea/scripts/tests/test_sop_checklist.py +++ b/.gitea/scripts/tests/test_sop_checklist.py @@ -603,3 +603,51 @@ class TestComputeNaState(unittest.TestCase): self.assertEqual(na_directives[0][0], "sop-n/a") self.assertEqual(na_directives[0][1], "qa-review") self.assertIn("no surface", na_directives[0][2]) + + +class TestProbeNaGateFallback(unittest.TestCase): + """Regression test: probe() must handle gate names (qa-review, security-review) + from N/A gates without raising KeyError. + + mc#1389: compute_na_state calls probe(gate_name, [user]) where gate_name is + a gate name like 'qa-review' — NOT a checklist item slug. The probe must + resolve the gate's required_teams from na_gates, not raise KeyError from + items_by_slug lookup. + """ + + def test_probe_resolves_gate_name_from_na_gates(self): + cfg = sop.load_config(CONFIG_PATH) + items = cfg["items"] + items_by_slug = {it["slug"]: it for it in items} + na_gates = cfg.get("n/a_gates", {}) + + # Reconstruct the _required_teams_for helper from sop-checklist.py + def _required_teams_for(slug): + if slug in items_by_slug: + return items_by_slug[slug]["required_teams"] + if slug in na_gates: + return na_gates[slug].get("required_teams", []) + return None + + # Gate names should resolve from na_gates + self.assertEqual( + _required_teams_for("qa-review"), + ["qa", "security", "engineers"], + ) + self.assertEqual( + _required_teams_for("security-review"), + ["security", "managers", "ceo"], + ) + + # Checklist item slugs should still resolve from items_by_slug + self.assertEqual( + _required_teams_for("comprehensive-testing"), + ["qa", "engineers"], + ) + self.assertEqual( + _required_teams_for("root-cause"), + ["managers", "ceo"], + ) + + # Unknown slug should return None (not raise KeyError) + self.assertIsNone(_required_teams_for("nonexistent-slug"))