From 0bc41713d4f06031066cda9b23b3df48fef2116f Mon Sep 17 00:00:00 2001 From: Molecule AI Infra-Runtime-BE Date: Sun, 17 May 2026 22:58:43 +0000 Subject: [PATCH 1/2] fix(ci): add secrets:read to qa-review and security-review workflows The SOP_TIER_CHECK_TOKEN team-membership probe (GET /api/v1/teams/{id}/members/{u}) requires the workflow token to carry secrets:read scope. Without it the API returns 403 and the approval gate reports failure even when a valid team APPROVE exists. Adds secrets: read to both qa-review.yml and security-review.yml permissions blocks, consistent with sop-checklist/sop-tier-check fix in PR #1414. Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/qa-review.yml | 1 + .gitea/workflows/security-review.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.gitea/workflows/qa-review.yml b/.gitea/workflows/qa-review.yml index 13f610dc4..cc947cf99 100644 --- a/.gitea/workflows/qa-review.yml +++ b/.gitea/workflows/qa-review.yml @@ -89,6 +89,7 @@ on: permissions: contents: read pull-requests: read + secrets: read # required for SOP_TIER_CHECK_TOKEN team-membership probe jobs: # bp-exempt: PR review bot signal; required merge state is enforced by CI / all-required. diff --git a/.gitea/workflows/security-review.yml b/.gitea/workflows/security-review.yml index b882a7427..308bb9a5c 100644 --- a/.gitea/workflows/security-review.yml +++ b/.gitea/workflows/security-review.yml @@ -16,6 +16,7 @@ on: permissions: contents: read pull-requests: read + secrets: read # required for SOP_TIER_CHECK_TOKEN team-membership probe jobs: # bp-exempt: PR security review bot signal; required merge state is enforced by CI / all-required. -- 2.52.0 From 05bd6b3098d9429d278e2b9902a0be293dae52dc Mon Sep 17 00:00:00 2001 From: Molecule AI Infra-Runtime-BE Date: Mon, 18 May 2026 04:35:47 +0000 Subject: [PATCH 2/2] fix(queue): auto-hold PRs when required contexts not green When the merge queue encounters a PR whose required status checks are not green, it now applies the merge-queue-hold label and posts a comment explaining the blocker. Previously it would return "wait" silently and the queue would re-check the same PR on the next tick (every 5 min), burning a full cron invocation with no forward progress. Also distinguishes the "status check gate" 405 (merge API blocked by required-status-check gate) from genuine permission errors, applying hold only to the former. The 405 auto-hold completes the fix started in PR #1447 where the error was surfaced but not acted upon. Fixes: internal#287 (queue cycling on qa/sec-failing PRs) Co-Authored-By: Claude Opus 4.7 --- .gitea/scripts/gitea-merge-queue.py | 87 ++++++++++++++++--- .../scripts/tests/test_gitea_merge_queue.py | 79 +++++++++++++++++ 2 files changed, 152 insertions(+), 14 deletions(-) diff --git a/.gitea/scripts/gitea-merge-queue.py b/.gitea/scripts/gitea-merge-queue.py index 964d8aa26..4d9707325 100644 --- a/.gitea/scripts/gitea-merge-queue.py +++ b/.gitea/scripts/gitea-merge-queue.py @@ -348,6 +348,30 @@ def post_comment(pr_number: int, body: str, *, dry_run: bool) -> None: api("POST", f"/repos/{OWNER}/{NAME}/issues/{pr_number}/comments", body={"body": body}) +def add_hold_label(pr_number: int, dry_run: bool) -> bool: + """Apply the merge-queue-hold label to a PR. Returns True if the label + was added (or was already present).""" + if not HOLD_LABEL: + return False + print(f"::notice::adding `{HOLD_LABEL}` to PR #{pr_number}") + if dry_run: + return True + try: + api( + "POST", + f"/repos/{OWNER}/{NAME}/issues/{pr_number}/labels", + body={"labels": [HOLD_LABEL]}, + ) + return True + except ApiError as exc: + # 404 = PR already closed/deleted; 422 = label already present (Gitea + # returns 422 for duplicate label assignment — not a real error). + if "404" in str(exc) or "422" in str(exc): + return True + sys.stderr.write(f"::warning::could not add hold label to PR #{pr_number}: {exc}\n") + return False + + def update_pull(pr_number: int, *, dry_run: bool) -> None: print(f"::notice::updating PR #{pr_number} with base branch via style={UPDATE_STYLE}") if dry_run: @@ -444,6 +468,24 @@ def process_once(*, dry_run: bool = False) -> int: dry_run=dry_run, ) return 0 + if decision.action == "wait": + # Required contexts are not green. Auto-hold so the queue stops cycling + # on this PR and processes the next. Holds are removed manually once the + # blocker (e.g. qa/sec gate, missing SOP_TIER_CHECK_TOKEN) is resolved. + # Distinguish "not all required status checks successful" 405 (merge + # attempted → add hold + comment) from permanent permission errors. + add_hold_label(pr_number, dry_run=dry_run) + post_comment( + pr_number, + ( + f"merge-queue: auto-held — required contexts not green: " + f"{decision.reason}. " + "Remove the `merge-queue-hold` label and re-label `merge-queue` " + "to restart queue processing once the blocker is resolved." + ), + dry_run=dry_run, + ) + return 0 if decision.ready: latest_main_sha = get_branch_head(WATCH_BRANCH) if latest_main_sha != main_sha: @@ -455,21 +497,38 @@ def process_once(*, dry_run: bool = False) -> int: try: merge_pull(pr_number, dry_run=dry_run) except MergePermissionError as exc: - # Permanent merge failure (HTTP 403/404/405). Post a comment so - # maintainers know why, then return 0 so this tick is done. - # The PR stays in the queue; future ticks can retry after the - # permission issue is resolved. + # Permanent merge failure (HTTP 403/404/405). Distinguish the + # Gitea-internal "status check gate" 405 (merge attempted, gate + # blocked) from a genuine permission error. + msg_lower = str(exc).lower() + is_status_check_failure = "not all required status checks successful" in msg_lower sys.stderr.write(f"::error::merge permission error for PR #{pr_number}: {exc}\n") - post_comment( - pr_number, - ( - "merge-queue: merge failed with HTTP 405 'User not allowed to merge PR'. " - "No available token has Can-merge permission on this repo. " - "Fix: grant Can-merge to a token, or add a maintain/admin collaborator. " - "Skipping to next queued PR on next tick." - ), - dry_run=dry_run, - ) + if is_status_check_failure: + # Merge API returned 405 because a required status check (e.g. + # qa-review, security-review) was still failing at merge time. + # Auto-hold so the queue stops cycling and processes the next PR. + add_hold_label(pr_number, dry_run=dry_run) + post_comment( + pr_number, + ( + "merge-queue: merge attempt blocked by Gitea's required-status-check " + "gate (HTTP 405 'not all required status checks successful'). " + "Auto-held — remove `merge-queue-hold` and re-label `merge-queue` " + "once the blocking checks pass." + ), + dry_run=dry_run, + ) + else: + post_comment( + pr_number, + ( + "merge-queue: merge failed with HTTP 405 'User not allowed to merge PR'. " + "No available token has Can-merge permission on this repo. " + "Fix: grant Can-merge to a token, or add a maintain/admin collaborator. " + "Skipping to next queued PR on next tick." + ), + dry_run=dry_run, + ) return 0 return 0 return 0 diff --git a/.gitea/scripts/tests/test_gitea_merge_queue.py b/.gitea/scripts/tests/test_gitea_merge_queue.py index d4ef81271..75a321b3f 100644 --- a/.gitea/scripts/tests/test_gitea_merge_queue.py +++ b/.gitea/scripts/tests/test_gitea_merge_queue.py @@ -128,3 +128,82 @@ def test_MergePermissionError_message_preserved(): exc = mq.MergePermissionError("POST /merge -> HTTP 405: User not allowed") assert "405" in str(exc) assert "User not allowed" in str(exc) + + +def test_merge_decision_waits_when_required_contexts_not_green(): + """When a required context (e.g. CI / all-required) is not success, the + decision is 'wait' — the queue can then auto-hold on this.""" + required = [ + "CI / all-required (pull_request)", + "sop-checklist / all-items-acked (pull_request)", + ] + decision = mq.evaluate_merge_readiness( + main_status={ + "state": "success", + "statuses": [{"context": "CI / all-required (push)", "status": "success"}], + }, + pr_status={ + "state": "failure", + "statuses": [ + {"context": "CI / all-required (pull_request)", "status": "failure"}, + {"context": "sop-checklist / all-items-acked (pull_request)", "status": "success"}, + ], + }, + required_contexts=required, + pr_has_current_base=True, + pr_labels=None, + ) + assert decision.ready is False + assert decision.action == "wait" + assert "CI / all-required" in decision.reason + + +def test_tier_low_sop_checklist_pending_is_accepted(): + """tier:low PRs get soft-fail on sop-checklist: pending is OK.""" + required = ["sop-checklist / all-items-acked (pull_request)"] + statuses = { + "sop-checklist / all-items-acked (pull_request)": { + "status": "pending", + } + } + ok, missing = mq.required_contexts_green( + statuses, required, pr_labels={"tier:low"} + ) + assert ok is True + assert missing == [] + + +def test_tier_low_sop_checklist_failure_is_not_accepted(): + """tier:low soft-fail only covers pending, not actual failure.""" + required = ["sop-checklist / all-items-acked (pull_request)"] + statuses = { + "sop-checklist / all-items-acked (pull_request)": { + "status": "failure", + } + } + ok, missing = mq.required_contexts_green( + statuses, required, pr_labels={"tier:low"} + ) + assert ok is False + + +def test_is_tier_low_pending_ok_true(): + statuses = { + "sop-checklist / all-items-acked (pull_request)": {"status": "pending"} + } + assert mq._is_tier_low_pending_ok( + statuses, + "sop-checklist / all-items-acked (pull_request)", + {"tier:low"}, + ) is True + + +def test_is_tier_low_pending_ok_not_tier_low(): + statuses = { + "sop-checklist / all-items-acked (pull_request)": {"status": "pending"} + } + assert mq._is_tier_low_pending_ok( + statuses, + "sop-checklist / all-items-acked (pull_request)", + set(), + ) is False -- 2.52.0