diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index e8f445748..5995ec817 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -2,10 +2,6 @@ name: Application CI on: pull_request: - branches: - - develop - - master - - "release/**" push: branches: - develop diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml index c5c613c08..0e250389c 100644 --- a/.github/workflows/bandit.yml +++ b/.github/workflows/bandit.yml @@ -4,7 +4,6 @@ on: push: branches: [ develop, master ] pull_request: - branches: [ develop, master ] workflow_dispatch: permissions: diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index c303d1e61..21e607f7b 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -2,10 +2,6 @@ name: Dependency Review on: pull_request: - branches: - - develop - - master - - "release/**" workflow_dispatch: permissions: diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index fc7058413..dd1015812 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -5,10 +5,6 @@ on: tags: - "v*" pull_request: - branches: - - develop - - master - - "release/**" permissions: contents: read diff --git a/backend/tests/test_release_governance.py b/backend/tests/test_release_governance.py index a23c70746..35ae2c496 100644 --- a/backend/tests/test_release_governance.py +++ b/backend/tests/test_release_governance.py @@ -651,7 +651,6 @@ def test_app_ci_runs_backend_and_frontend_checks_without_duplicate_release_pushe workflow = read_repo_text(".github/workflows/app-ci.yml") assert "pull_request:" in workflow - assert "release/**" in workflow assert "python -m pytest" in workflow assert "PYTHONWARNINGS: error" in workflow assert 'DISABLE_BACKGROUND_WORKERS: "1"' in workflow @@ -669,6 +668,10 @@ def test_app_ci_runs_backend_and_frontend_checks_without_duplicate_release_pushe assert "master" in push_block assert "release/**" not in push_block + pull_request_block = workflow.split("pull_request:", 1)[1].split("push:", 1)[0] + assert "branches:" not in pull_request_block + assert "branches-ignore:" not in pull_request_block + def test_docker_publish_validates_pr_images_and_publishes_semver_images_only_on_tags() -> ( None @@ -710,7 +713,8 @@ def test_docker_publish_validates_pr_images_and_publishes_semver_images_only_on_ ] assert "tags:" in push_block assert "branches:" not in push_block - assert "develop" in pull_request_block + assert "branches:" not in pull_request_block + assert "branches-ignore:" not in pull_request_block assert "ai_email_client-backend" in workflow assert "ai_email_client-frontend" in workflow assert workflow.count("image: naruon") == 2 diff --git a/backend/tests/test_stacked_pr_workflow_contract.py b/backend/tests/test_stacked_pr_workflow_contract.py new file mode 100644 index 000000000..0929d4aca --- /dev/null +++ b/backend/tests/test_stacked_pr_workflow_contract.py @@ -0,0 +1,29 @@ +"""Regression coverage for governed checks on stacked pull requests.""" + +from pathlib import Path +import re + + +REPO_ROOT = Path(__file__).resolve().parents[2] +GOVERNED_PULL_REQUEST_WORKFLOWS = ( + "app-ci.yml", + "bandit.yml", + "dependency-review.yml", + "docker-publish.yml", +) + + +def test_governed_pull_request_workflows_accept_stacked_base_branches() -> None: + """Required repository checks must run for every PR base, including stacks.""" + for name in GOVERNED_PULL_REQUEST_WORKFLOWS: + workflow = (REPO_ROOT / ".github" / "workflows" / name).read_text() + pull_request_trigger = re.search( + r"(?ms)^ pull_request:\s*$\n(?P(?:^ .*$\n)*)", + workflow, + ) + assert pull_request_trigger is not None, f"{name} must run on pull_request" + body = pull_request_trigger.group("body") + assert "branches:" not in body, f"{name} must not exclude stacked PR base branches" + assert "branches-ignore:" not in body, ( + f"{name} must not exclude stacked PR base branches via branches-ignore" + ) diff --git a/docs/development/merge-gate-policy.md b/docs/development/merge-gate-policy.md index 5875d21a6..e691e22fa 100644 --- a/docs/development/merge-gate-policy.md +++ b/docs/development/merge-gate-policy.md @@ -9,8 +9,9 @@ awaited by default. - Required status checks must pass on the current head SHA. - Application CI must run backend pytest and frontend test/lint/build checks on - pull requests to `master` and `release/**`, while release-branch pushes must - not create duplicate check noise; push checks are scoped to `master`. + every pull request, including one stacked on another open PR's branch, not + just `develop`/`master`/`release/**` — required checks must not exclude a PR + base; push checks are scoped to `develop` and `master`. - The robot-review gate prefers CodeRabbit evidence. When the current head has CodeRabbit check-run evidence, it satisfies the gate only when current-head blocking findings, warnings, and failures are fixed, rebutted with evidence, @@ -46,6 +47,14 @@ awaited by default. resolution via GITHUB_PATH. - Authoritative `Review skipped` evidence counts only when the same check output carries no blocking warning/failure language alongside it. +- A review-like check name or status context is not publisher authentication. + Check evidence requires the `coderabbitai` or `github-code-quality` App slug; + status evidence requires the corresponding exact `[bot]` creator and Bot type. + Missing or unrelated publishers cannot replace the OpenCode fallback. + Successful and skipped check conclusions still block when their own output + contains the blocking warning/failure evidence described above. Exclude only + complete known clean lines (`No warnings found` and + `No actionable comments were generated`), never the whole containing output. - `reviewDecision=CHANGES_REQUESTED` is a blocker until requested changes are addressed or superseded on the current head. - Blocker comments use the idempotent @@ -65,6 +74,65 @@ awaited by default. ## Evidence commands +### Multiline robot notices and collected regressions (2026-09-06) + +At predecessor `550798ccafebea4b1a9a65018e63b9661ff25a53`, a multiline +CodeRabbit approval notice could be mistaken for a substantive blocker even +after an exact-head OpenCode fallback was accepted. The existing fake-GitHub +shell harness reproduced this after adding realistic line breaks: it published +a failure instead of the expected ready state. The common notice-removal +expression now uses jq's `m` flag so dot matches newlines; `s` only changes +anchor semantics. Only the non-greedy marker-delimited notice is removed; +separate blocking warnings must remain blockers. No approval or review rule +is weakened and no external GitHub mutation occurs in this harness. + +Run `bash scripts/ci/test_pr_governance_gate.sh` for the full metadata scenarios. +The four-workflow stacked-base regression lives in +`backend/tests/test_stacked_pr_workflow_contract.py`, where Application CI +collects it; the root-level copy was moved, not discarded or duplicated. +Local harness evidence is not a hosted approval or protected merge. + +Review finding #3939597997 exposed a second stale-notice path at +`e058f3ead35f9a19d3c3b20c6ab5fc04d2e2cbb2`: a successful current-head +CodeRabbit check or status was overwritten by an `in_progress` governance +result solely because its issue comment still carried the pending notice. +The new success-check fixture failed the expected ready assertion; the fake +publisher explicitly emitted `in_progress`. Pending notices now add a wait +only when CodeRabbit check/status evidence is absent and no qualifying +OpenCode approval exists. Existing pending/failed check handling and separate +substantive-comment blockers remain authoritative. The full shell harness +covers success checks and success statuses with stale notices separately; +the notice-only waiting fixture now correctly contains no check evidence. + +Independent readiness review at `fac3437c03d928e45632763530a4f130dfe505fd` +identified two pre-existing metadata risks, not regressions introduced by the +stale-notice repair: name-only publisher matching and success/skipped results +bypassing output inspection. Six isolated fake-GitHub scenarios each emitted +an incorrect success before the fix: an unrelated App check or status creator, +each with and without the pending notice, and success/skipped checks carrying +a pre-merge blocking warning. Publisher authentication now precedes evidence +selection, and output inspection precedes conclusion acceptance. The real +current-head CodeRabbit status creator was checked as `coderabbitai[bot]`, Bot. +These are local metadata-gate findings; no protected-branch bypass or malicious +publication in production was established. Keep the full harness, authoritative +positive cases, fallback and separate-comment blockers intact when repairing +this boundary; do not grant another publisher access to silence a wait state. + +Review #3944130242 exposed a regression at `b6d6c286`: output-first matching +also rejected those clean summaries. The new focused harness assertion failed +before repair. Output is now split into lines before matching whole known +clean statements; case, surrounding whitespace, CRLF and terminal punctuation +are covered. An initial whole-string regex attempt still failed and was not +accepted. Eleven scenarios cover clean success/skipped/neutral, an explicit +failure despite clean text, a separate real warning for each passing conclusion, +same-line `except`/`but` qualifications, neutral without skip evidence and an +untrusted publisher with clean text. Unknown wording remains subject to +the existing blocker policy; this is not a general natural-language negation +parser. Publisher authentication and separate-comment evidence are unchanged. + +Reference: jqlang. (n.d.). *Regular expressions*. In *jq 1.7 manual*. +https://jqlang.org/manual/v1.7/#regular-expressions + Use the same head SHA across all checks: ```bash diff --git a/scripts/ci/pr_governance_gate.sh b/scripts/ci/pr_governance_gate.sh index a66142ca8..8d877de16 100644 --- a/scripts/ci/pr_governance_gate.sh +++ b/scripts/ci/pr_governance_gate.sh @@ -258,7 +258,7 @@ IS_DRAFT="$(printf '%s' "$PR_JSON" | jq -r '.isDraft')" REVIEW_DECISION="$(printf '%s' "$PR_JSON" | jq -r '.reviewDecision // ""')" if [ "$IS_DRAFT" = "true" ]; then - add_blocker 'Draft PR: merge automation is paused.' + add_waiting 'Draft PR: merge automation is paused.' fi if [ "$MERGE_STATE" = "BEHIND" ]; then @@ -341,6 +341,31 @@ CODERABBIT_BLOCKING_PATTERN='pre[- ]merge|blocking|failure|failed|warning|potent CODERABBIT_ISSUE_BLOCKING_PATTERN='pre[- ]merge[^\n]*(blocking|failure|failed|warning|potential issue)|blocking (issue|finding)|potential issue|actionable comments?|changes requested|request changes' CODERABBIT_ISSUE_SUBSTANTIVE_BLOCKING_PATTERN='pre[- ]merge[^\n]*(blocking|failure|failed|warning|potential issue)|blocking (issue|finding)|potential issue|changes requested|request changes' CODERABBIT_NO_ACTIONABLE_PATTERN='no actionable comments? (were )?generated' +CODERABBIT_CLEAN_OUTPUT_LINE_PATTERN='^[ \t]*(No actionable comments? (were )?generated|No warnings found)[.!]?[ \t\r]*$' +CODERABBIT_APPROVAL_PENDING_PATTERN='CodeRabbit has no unresolved comments, but it has not reviewed the latest commit' +CODERABBIT_APPROVAL_NOTICE_SPAN_PATTERN='.*?' + +# Fetch comments once for both pending notices and substantive blockers. +# Without CodeRabbit check/status evidence, an exact-head structured OpenCode +# approval may satisfy the fallback even when a pending notice exists. +# Separate findings still block; a notice without either evidence source waits. +if ! ISSUE_COMMENTS_JSON="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" 2>"$ISSUE_COMMENTS_ERROR_FILE")"; then + printf 'issue comment lookup failed:\n' + printf '%s\n' "$(<"$ISSUE_COMMENTS_ERROR_FILE")" | sed 's/^/ /' + add_blocker 'PR issue comments could not be read; see the workflow run log.' + ISSUE_COMMENTS_JSON='[]' +fi +CODERABBIT_APPROVAL_PENDING_COUNT="$(printf '%s' "$ISSUE_COMMENTS_JSON" | jq -s \ + --arg head_sha "$HEAD_SHA" \ + --arg approval_pending_pattern "$CODERABBIT_APPROVAL_PENDING_PATTERN" ' + [.[][] + | select((.user.login // "") | test("'"$REVIEW_BOT_LOGIN_PATTERN"'"; "i")) + | select((.body // "") | contains("")) + | select((.body // "") | test($approval_pending_pattern; "i")) + | select((.body // "") | contains($head_sha))] + | length' +)" + CHECK_RUNS="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/check-runs?per_page=100")" COMMIT_STATUS_JSON='{"statuses":[]}' if ! COMMIT_STATUS_JSON="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/status" 2>"$COMMIT_STATUS_ERROR_FILE")"; then @@ -353,11 +378,13 @@ CODERABBIT_MATCHES="$(printf '%s' "$CHECK_RUNS" | jq ' | select( .app.slug == "coderabbitai" or .app.slug == "github-code-quality" - or (.name | test("CodeRabbit|coderabbit|GitHub Code Quality|github-code-quality"; "i")) )]' )" CODERABBIT_STATUS_MATCHES="$(printf '%s' "$COMMIT_STATUS_JSON" | jq ' [.statuses[] + | select(.creator.type == "Bot") + | select((.creator.login // "" | ascii_downcase) as $login + | $login == "coderabbitai[bot]" or $login == "github-code-quality[bot]") | select((.context // "") | test("CodeRabbit|coderabbit|GitHub Code Quality|github-code-quality"; "i"))] | group_by((.context // "") | ascii_downcase) | map(sort_by(.updated_at // .created_at // "") | last) @@ -365,6 +392,7 @@ CODERABBIT_STATUS_MATCHES="$(printf '%s' "$COMMIT_STATUS_JSON" | jq ' CODERABBIT_CHECK_COUNT="$(printf '%s' "$CODERABBIT_MATCHES" | jq 'length')" CODERABBIT_STATUS_COUNT="$(printf '%s' "$CODERABBIT_STATUS_MATCHES" | jq 'length')" CODERABBIT_COUNT=$((CODERABBIT_CHECK_COUNT + CODERABBIT_STATUS_COUNT)) +OPENCODE_ADVERSARIAL_APPROVAL_COUNT=0 if [ "$CODERABBIT_COUNT" = "0" ]; then if ! OPENCODE_REVIEWS_JSON="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" 2>"$OPENCODE_REVIEWS_ERROR_FILE")"; then printf 'OpenCode review lookup failed:\n' @@ -386,24 +414,24 @@ if [ "$CODERABBIT_COUNT" = "0" ]; then if [ "$OPENCODE_ADVERSARIAL_APPROVAL_COUNT" = "0" ]; then add_waiting "Waiting for current-head CodeRabbit evidence or a structured OpenCode App adversarial approval on ${HEAD_REF_OID}." else - printf 'CodeRabbit check is absent; accepted current-head OpenCode App adversarial approval on %s.\n' "$HEAD_REF_OID" + printf 'CodeRabbit check evidence is absent; accepted current-head OpenCode App adversarial approval on %s.\n' "$HEAD_REF_OID" fi fi else CODERABBIT_PENDING="$(printf '%s' "$CODERABBIT_MATCHES" | jq '[.[] | select(.status != "completed")] | length')" CODERABBIT_STATUS_PENDING="$(printf '%s' "$CODERABBIT_STATUS_MATCHES" | jq '[.[] | select((.state // "" | ascii_downcase) == "pending")] | length')" - CODERABBIT_FAILED="$(printf '%s' "$CODERABBIT_MATCHES" | jq --arg pattern "$CODERABBIT_BLOCKING_PATTERN" ' + CODERABBIT_FAILED="$(printf '%s' "$CODERABBIT_MATCHES" | jq --arg pattern "$CODERABBIT_BLOCKING_PATTERN" \ + --arg clean_line "$CODERABBIT_CLEAN_OUTPUT_LINE_PATTERN" ' [.[] | select(.status == "completed") + # Remove only whole known clean lines, never an output containing one. + | ([.output.title, .output.summary, .output.text] | map(. // "") | join("\n") + | split("\n") | map(select(test($clean_line; "i") | not)) | join("\n")) as $check_output | select((.conclusion // "") as $conclusion - | if $conclusion == "success" or $conclusion == "skipped" then false + | if $check_output | test($pattern; "i") then true + elif $conclusion == "success" or $conclusion == "skipped" then false elif $conclusion == "neutral" then - # Skip evidence only counts when the output carries no blocking - # language alongside it. - (([.output.title, .output.summary, .output.text] | map(. // "") | join("\n")) as $neutral_output - | (($neutral_output | test("Review skipped"; "i")) - and (($neutral_output | test($pattern; "i")) | not)) - | not) + ($check_output | test("Review skipped"; "i") | not) else true end)] | length' @@ -426,33 +454,38 @@ else fi fi -if ! ISSUE_COMMENTS_JSON="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" 2>"$ISSUE_COMMENTS_ERROR_FILE")"; then - printf 'issue comment lookup failed:\n' - printf '%s\n' "$(<"$ISSUE_COMMENTS_ERROR_FILE")" | sed 's/^/ /' - add_blocker 'PR issue comments could not be read; see the workflow run log.' -else - CODERABBIT_ISSUE_BLOCKERS="$(printf '%s' "$ISSUE_COMMENTS_JSON" | jq -s \ - --arg head_sha "$HEAD_SHA" \ - --arg pattern "$CODERABBIT_ISSUE_BLOCKING_PATTERN" \ - --arg substantive_pattern "$CODERABBIT_ISSUE_SUBSTANTIVE_BLOCKING_PATTERN" \ - --arg no_actionable_pattern "$CODERABBIT_NO_ACTIONABLE_PATTERN" ' - [.[][] - | select((.user.login // "") | test("'"$REVIEW_BOT_LOGIN_PATTERN"'"; "i")) - | select( - (.body // "") as $body - | ($body | split("
")[0]) as $summary - | ($body | test($pattern; "i")) - and ( - (($body | test($no_actionable_pattern; "i")) | not) - or ($summary | test($substantive_pattern; "i")) - ) - ) - | select((.body // "") | contains($head_sha))] - | length' - )" - if [ "$CODERABBIT_ISSUE_BLOCKERS" != "0" ]; then - add_blocker "Current-head CodeRabbit issue comment has blocking warning/failure evidence on ${HEAD_REF_OID}." - fi +# CODERABBIT_APPROVAL_PENDING_COUNT was already computed above (before the +# check-run/status lookup), from the same ISSUE_COMMENTS_JSON fetched there. +# Only the blocking-evidence scan runs here: it strips just the marker- +# delimited approval-pending span from each comment body before testing for +# blocking language, rather than excluding the whole comment whenever that +# marker is present anywhere in it -- a comment can legitimately carry both +# the boilerplate pending notice and a separate, real blocking finding, and +# the latter must still be caught. +CODERABBIT_ISSUE_BLOCKERS="$(printf '%s' "$ISSUE_COMMENTS_JSON" | jq -s \ + --arg head_sha "$HEAD_SHA" \ + --arg pattern "$CODERABBIT_ISSUE_BLOCKING_PATTERN" \ + --arg substantive_pattern "$CODERABBIT_ISSUE_SUBSTANTIVE_BLOCKING_PATTERN" \ + --arg no_actionable_pattern "$CODERABBIT_NO_ACTIONABLE_PATTERN" \ + --arg notice_span_pattern "$CODERABBIT_APPROVAL_NOTICE_SPAN_PATTERN" ' + [.[][] + | select((.user.login // "") | test("'"$REVIEW_BOT_LOGIN_PATTERN"'"; "i")) + | select( + ((.body // "") | gsub($notice_span_pattern; ""; "m")) as $body + | ($body | split("
")[0]) as $summary + | ($body | test($pattern; "i")) + and ( + (($body | test($no_actionable_pattern; "i")) | not) + or ($summary | test($substantive_pattern; "i")) + ) + ) + | select((.body // "") | contains($head_sha))] + | length' +)" +if [ "$CODERABBIT_ISSUE_BLOCKERS" != "0" ]; then + add_blocker "Current-head CodeRabbit issue comment has blocking warning/failure evidence on ${HEAD_REF_OID}." +elif [ "$CODERABBIT_COUNT" = "0" ] && [ "$CODERABBIT_APPROVAL_PENDING_COUNT" != "0" ] && [ "$OPENCODE_ADVERSARIAL_APPROVAL_COUNT" = "0" ]; then + add_waiting "Waiting for CodeRabbit to review the latest commit on ${HEAD_REF_OID}." fi if ! REVIEW_COMMENTS_JSON="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/comments" 2>"$REVIEW_COMMENTS_ERROR_FILE")"; then diff --git a/scripts/ci/test_pr_governance_gate.sh b/scripts/ci/test_pr_governance_gate.sh index 8fb42aa9b..a61786873 100644 --- a/scripts/ci/test_pr_governance_gate.sh +++ b/scripts/ci/test_pr_governance_gate.sh @@ -17,6 +17,9 @@ args="$*" if [ "$1" = "pr" ] && [ "$2" = "view" ]; then case "${GH_SCENARIO:-pass}" in + draft) + printf '{"number":42,"state":"OPEN","headRefOid":"%s","isDraft":true,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"","statusCheckRollup":[]}' "$head_sha" + ;; changes_requested) printf '{"number":42,"state":"OPEN","headRefOid":"%s","isDraft":false,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"CHANGES_REQUESTED","statusCheckRollup":[]}' "$head_sha" ;; @@ -137,10 +140,40 @@ fi if [ "$1" = "api" ] && [[ "$2" == repos/*/commits/*/check-runs* ]]; then case "${GH_SCENARIO:-pass}" in + clean_summary_success|clean_summary_skipped|clean_summary_neutral|clean_summary_failure|clean_without_skip_neutral|forged_clean_success|mixed_summary_success|mixed_summary_skipped|mixed_summary_neutral|qualified_summary_success|qualified_but_success) + conclusion="${GH_SCENARIO##*_}" + app_slug='coderabbitai' + summary='No warnings found.' + output_text='No actionable comments were generated' + case "$GH_SCENARIO" in + forged_clean_success) app_slug='github-actions' ;; + clean_summary_skipped) summary=$' \tno WARNINGS found! \t\r' ;; + clean_summary_neutral) output_text=$'Review skipped\nNo actionable comments were generated' ;; + mixed_summary_*) output_text=$'Review skipped\nNo actionable comments were generated\nPre-merge blocking warning' ;; + qualified_summary_success) summary='No warnings found except blocking issues' ;; + qualified_but_success) summary='No warnings found, but a blocking issue remains' ;; + esac + jq -cn --arg app_slug "$app_slug" --arg conclusion "$conclusion" --arg summary "$summary" --arg text "$output_text" \ + '{check_runs:[{name:"CodeRabbit",app:{slug:$app_slug},status:"completed",conclusion:$conclusion,output:{summary:$summary,text:$text}}]}' + ;; + forged_check|forged_check_pending_notice) + printf '{"check_runs":[{"name":"CodeRabbit","app":{"slug":"github-actions"},"status":"completed","conclusion":"success"}]}' + ;; + forged_status|forged_status_pending_notice|missing_status_creator|wrong_status_creator_type|github_code_quality_status) + printf '{"check_runs":[]}' + ;; + github_code_quality_check) + printf '{"check_runs":[{"name":"GitHub Code Quality","app":{"slug":"github-code-quality"},"status":"completed","conclusion":"success"}]}' + ;; + coderabbit_success_with_warning|coderabbit_skipped_with_warning) + conclusion="${GH_SCENARIO#coderabbit_}" + conclusion="${conclusion%_with_warning}" + printf '{"check_runs":[{"name":"CodeRabbit","app":{"slug":"coderabbitai"},"status":"completed","conclusion":"%s","output":{"text":"Pre-merge blocking warning"}}]}' "$conclusion" + ;; coderabbit_pending) printf '{"check_runs":[{"name":"CodeRabbit","app":{"slug":"coderabbitai"},"status":"in_progress","conclusion":null,"html_url":"https://checks/coderabbit"}]}' ;; - missing_coderabbit|missing_coderabbit_with_adversarial_approval|missing_coderabbit_stale_approval|missing_coderabbit_actions_approval|missing_coderabbit_one_probe|opencode_reviews_error|coderabbit_status_success|coderabbit_status_pending|coderabbit_status_failed|coderabbit_status_unknown) + missing_coderabbit|missing_coderabbit_with_adversarial_approval|missing_coderabbit_stale_approval|missing_coderabbit_actions_approval|missing_coderabbit_one_probe|missing_coderabbit_adversarial_approval_with_pending_notice|coderabbit_approval_pending|coderabbit_status_success_with_pending_notice|opencode_reviews_error|coderabbit_status_success|coderabbit_status_pending|coderabbit_status_failed|coderabbit_status_unknown) printf '{"check_runs":[]}' ;; coderabbit_failed) @@ -167,17 +200,29 @@ fi if [ "$1" = "api" ] && [[ "$2" == repos/*/commits/*/status ]]; then case "${GH_SCENARIO:-pass}" in - coderabbit_status_success) - printf '{"statuses":[{"context":"CodeRabbit","state":"success","description":"Review approved","created_at":"2026-07-29T01:54:41Z","updated_at":"2026-07-29T01:54:41Z"}]}' + missing_status_creator) + printf '{"statuses":[{"context":"CodeRabbit","state":"success"}]}' + ;; + wrong_status_creator_type) + printf '{"statuses":[{"context":"CodeRabbit","state":"success","creator":{"login":"coderabbitai[bot]","type":"User"}}]}' + ;; + github_code_quality_status) + printf '{"statuses":[{"context":"GitHub Code Quality","state":"success","creator":{"login":"github-code-quality[bot]","type":"Bot"}}]}' + ;; + forged_status|forged_status_pending_notice) + printf '{"statuses":[{"context":"CodeRabbit","state":"success","creator":{"login":"unrelated-app[bot]","type":"Bot"}}]}' + ;; + coderabbit_status_success|coderabbit_status_success_with_pending_notice) + printf '{"statuses":[{"context":"CodeRabbit","creator":{"login":"coderabbitai[bot]","type":"Bot"},"state":"success","description":"Review approved","created_at":"2026-07-29T01:54:41Z","updated_at":"2026-07-29T01:54:41Z"}]}' ;; coderabbit_status_pending) - printf '{"statuses":[{"context":"CodeRabbit","state":"pending","description":"Review in progress","created_at":"2026-07-29T01:54:41Z","updated_at":"2026-07-29T01:54:41Z"}]}' + printf '{"statuses":[{"context":"CodeRabbit","creator":{"login":"coderabbitai[bot]","type":"Bot"},"state":"pending","description":"Review in progress","created_at":"2026-07-29T01:54:41Z","updated_at":"2026-07-29T01:54:41Z"}]}' ;; coderabbit_status_failed) - printf '{"statuses":[{"context":"CodeRabbit","state":"failure","description":"Review failed","created_at":"2026-07-29T01:54:41Z","updated_at":"2026-07-29T01:54:41Z"}]}' + printf '{"statuses":[{"context":"CodeRabbit","creator":{"login":"coderabbitai[bot]","type":"Bot"},"state":"failure","description":"Review failed","created_at":"2026-07-29T01:54:41Z","updated_at":"2026-07-29T01:54:41Z"}]}' ;; coderabbit_status_unknown) - printf '{"statuses":[{"context":"CodeRabbit","state":"stale","description":"Unrecognized state","created_at":"2026-07-29T01:54:41Z","updated_at":"2026-07-29T01:54:41Z"}]}' + printf '{"statuses":[{"context":"CodeRabbit","creator":{"login":"coderabbitai[bot]","type":"Bot"},"state":"stale","description":"Unrecognized state","created_at":"2026-07-29T01:54:41Z","updated_at":"2026-07-29T01:54:41Z"}]}' ;; *) printf '{"statuses":[]}' @@ -192,7 +237,7 @@ if [ "$1" = "api" ] && [[ "$args" == *repos/*/pulls/42/reviews* ]]; then exit 1 fi case "${GH_SCENARIO:-pass}" in - missing_coderabbit_with_adversarial_approval) + missing_coderabbit_with_adversarial_approval|missing_coderabbit_adversarial_approval_with_pending_notice) printf '[[{"user":{"login":"opencode-agent[bot]"},"state":"APPROVED","commit_id":"%s","body":"## Adversarial validation\\n\\n```json\\n{\\\"status\\\":\\\"passed\\\",\\\"probes\\\":[{\\\"outcome\\\":\\\"falsified\\\"},{\\\"outcome\\\":\\\"falsified\\\"}]}\\n```\\n\\nHead SHA: `%s`"}]]' "$head_sha" "$head_sha" ;; missing_coderabbit_stale_approval) @@ -249,6 +294,12 @@ if [ "$1" = "api" ] && [[ "$args" == *repos/*/issues/42/comments* ]]; then coderabbit_no_actionable_with_blocker) printf '[{"id":777,"user":{"login":"coderabbitai[bot]"},"created_at":"2026-05-19T00:01:00Z","body":"No actionable comments were generated in the recent review. Blocking issue remains on 0123456789abcdef0123456789abcdef01234567."}]' ;; + coderabbit_approval_pending|missing_coderabbit_adversarial_approval_with_pending_notice|coderabbit_check_success_with_pending_notice|coderabbit_status_success_with_pending_notice|forged_check_pending_notice|forged_status_pending_notice) + printf '[{"id":777,"user":{"login":"coderabbitai[bot]"},"created_at":"2026-05-19T00:01:00Z","body":"\\nCodeRabbit has no unresolved comments, but it has not reviewed the latest commit. \\nCodeRabbit will approve the changes if it finds no blocking issues. \\n"}]' + ;; + coderabbit_approval_pending_with_separate_blocking_warning) + printf '[{"id":777,"user":{"login":"coderabbitai[bot]"},"created_at":"2026-05-19T00:01:00Z","body":"\\nCodeRabbit has no unresolved comments, but it has not reviewed the latest commit. \\nCodeRabbit will approve the changes if it finds no blocking issues. \\n\\n\\nSeparately: Pre-merge blocking warning for 0123456789abcdef0123456789abcdef01234567."}]' + ;; github_code_quality_blocking_comment) printf '[{"id":777,"user":{"login":"github-code-quality[bot]"},"created_at":"2026-05-19T00:01:00Z","body":"Potential issue for 0123456789abcdef0123456789abcdef01234567"}]' ;; @@ -453,6 +504,19 @@ assert_failed_checks_create_marker_comment() { assert_not_in_file '^pr merge' "$temp_dir/gh.log" } +assert_draft_pr_waits_without_false_failure() { + local temp_dir + temp_dir="$(mktemp -d)" + run_gate draft "$temp_dir" + + assert_exit_code 0 "$temp_dir" + assert_in_file 'Draft PR: merge automation is paused.' "$temp_dir/gh.log" + assert_in_file 'status=in_progress' "$temp_dir/gh.log" + assert_not_in_file 'conclusion=failure' "$temp_dir/gh.log" + assert_not_in_file 'PR governance metadata gate is not ready' "$temp_dir/gh.log" + assert_not_in_file '^pr merge' "$temp_dir/gh.log" +} + assert_existing_marker_comment_is_patched() { local temp_dir temp_dir="$(mktemp -d)" @@ -486,6 +550,50 @@ assert_coderabbit_pending_waits_without_hard_comment() { assert_not_in_file '^pr merge' "$temp_dir/gh.log" } +assert_robot_evidence_requires_publisher_and_clean_output() { + local scenario temp_dir + for scenario in forged_check forged_check_pending_notice forged_status forged_status_pending_notice missing_status_creator wrong_status_creator_type forged_clean_success; do + temp_dir="$(mktemp -d)" + run_gate "$scenario" "$temp_dir" + assert_exit_code 0 "$temp_dir" + assert_in_file 'status=in_progress' "$temp_dir/gh.log" + assert_not_in_file 'conclusion=success' "$temp_dir/gh.log" + assert_not_in_file '^pr merge' "$temp_dir/gh.log" + done + for scenario in coderabbit_success_with_warning coderabbit_skipped_with_warning; do + temp_dir="$(mktemp -d)" + run_gate "$scenario" "$temp_dir" + assert_exit_code 0 "$temp_dir" + assert_in_file 'conclusion=failure' "$temp_dir/gh.log" + assert_not_in_file '^pr merge' "$temp_dir/gh.log" + done + for scenario in github_code_quality_check github_code_quality_status; do + temp_dir="$(mktemp -d)" + run_gate "$scenario" "$temp_dir" + assert_exit_code 0 "$temp_dir" + assert_in_file 'conclusion=success' "$temp_dir/gh.log" + assert_not_in_file '^pr merge' "$temp_dir/gh.log" + done +} + +assert_clean_summaries_preserve_real_blockers() { + local scenario temp_dir + for scenario in clean_summary_success clean_summary_skipped clean_summary_neutral; do + temp_dir="$(mktemp -d)" + run_gate "$scenario" "$temp_dir" + assert_exit_code 0 "$temp_dir" + assert_in_file 'conclusion=success' "$temp_dir/gh.log" + assert_not_in_file '^pr merge' "$temp_dir/gh.log" + done + for scenario in clean_summary_failure clean_without_skip_neutral mixed_summary_success mixed_summary_skipped mixed_summary_neutral qualified_summary_success qualified_but_success; do + temp_dir="$(mktemp -d)" + run_gate "$scenario" "$temp_dir" + assert_exit_code 0 "$temp_dir" + assert_in_file 'conclusion=failure' "$temp_dir/gh.log" + assert_not_in_file '^pr merge' "$temp_dir/gh.log" + done +} + assert_coderabbit_success_commit_status_completes_gate() { local temp_dir temp_dir="$(mktemp -d)" @@ -549,6 +657,37 @@ assert_missing_coderabbit_accepts_exact_head_adversarial_opencode_approval() { assert_in_file 'conclusion=success' "$temp_dir/gh.log" } +assert_missing_coderabbit_adversarial_approval_overrides_pending_notice() { + # With no CodeRabbit check-run, its issue-comment notice is not gate + # evidence. The exact-head structured OpenCode approval satisfies the + # documented fallback even while that notice remains present. + local temp_dir + temp_dir="$(mktemp -d)" + run_gate missing_coderabbit_adversarial_approval_with_pending_notice "$temp_dir" + + assert_exit_code 0 "$temp_dir" + assert_in_file 'accepted current-head OpenCode App adversarial approval' "$temp_dir/output.txt" + assert_in_file 'PR governance metadata gate is ready' "$temp_dir/output.txt" + assert_in_file 'conclusion=success' "$temp_dir/gh.log" +} + +assert_coderabbit_approval_pending_notice_does_not_hide_separate_blocking_warning() { + # A CodeRabbit issue comment can legitimately carry both the boilerplate + # approval-pending notice and a separate, genuine pre-merge blocking + # warning in the same body. Excluding the whole comment from the + # blocking-evidence scan whenever the pending-notice marker appears + # anywhere in it would hide that second, real finding -- only the + # marker-delimited span itself should be exempted. + local temp_dir + temp_dir="$(mktemp -d)" + run_gate coderabbit_approval_pending_with_separate_blocking_warning "$temp_dir" + + assert_exit_code 0 "$temp_dir" + assert_in_file 'Current-head CodeRabbit issue comment has blocking warning/failure evidence' "$temp_dir/gh.log" + assert_in_file 'conclusion=failure' "$temp_dir/gh.log" + assert_not_in_file '^pr merge' "$temp_dir/gh.log" +} + assert_missing_coderabbit_rejects_non_authoritative_opencode_evidence() { local scenario temp_dir for scenario in \ @@ -763,6 +902,31 @@ assert_coderabbit_no_actionable_summary_with_blocker_still_blocks() { assert_not_in_file '^pr merge' "$temp_dir/gh.log" } +assert_coderabbit_success_overrides_stale_pending_notice() { + local scenario temp_dir + for scenario in coderabbit_check_success_with_pending_notice coderabbit_status_success_with_pending_notice; do + temp_dir="$(mktemp -d)" + run_gate "$scenario" "$temp_dir" + assert_exit_code 0 "$temp_dir" + assert_in_file 'PR governance metadata gate is ready' "$temp_dir/output.txt" + assert_in_file 'conclusion=success' "$temp_dir/gh.log" + assert_not_in_file 'Waiting for CodeRabbit to review the latest commit' "$temp_dir/output.txt" + assert_not_in_file '^pr merge' "$temp_dir/gh.log" + done +} + +assert_coderabbit_approval_pending_waits_without_blocking() { + local temp_dir + temp_dir="$(mktemp -d)" + run_gate coderabbit_approval_pending "$temp_dir" + + assert_exit_code 0 "$temp_dir" + assert_in_file 'Waiting for CodeRabbit to review the latest commit' "$temp_dir/output.txt" + assert_in_file 'status=in_progress' "$temp_dir/gh.log" + assert_not_in_file 'Current-head CodeRabbit issue comment has blocking warning/failure evidence' "$temp_dir/gh.log" + assert_not_in_file '^pr merge' "$temp_dir/gh.log" +} + assert_coderabbit_current_review_comment_blocks() { local temp_dir temp_dir="$(mktemp -d)" @@ -923,15 +1087,19 @@ assert_head_change_during_evaluation_skips_stale_publication assert_closed_during_evaluation_skips_stale_publication assert_startup_failure_creates_marker_comment assert_failed_checks_create_marker_comment +assert_draft_pr_waits_without_false_failure assert_existing_marker_comment_is_patched assert_resolved_marker_comment_is_updated_on_ready_gate assert_coderabbit_pending_waits_without_hard_comment +assert_robot_evidence_requires_publisher_and_clean_output +assert_clean_summaries_preserve_real_blockers assert_coderabbit_success_commit_status_completes_gate assert_coderabbit_pending_commit_status_waits assert_coderabbit_failed_commit_status_blocks assert_coderabbit_unknown_commit_status_fails_closed assert_missing_coderabbit_waits_for_adversarial_opencode_approval assert_missing_coderabbit_accepts_exact_head_adversarial_opencode_approval +assert_missing_coderabbit_adversarial_approval_overrides_pending_notice assert_missing_coderabbit_rejects_non_authoritative_opencode_evidence assert_opencode_review_lookup_error_is_logged_but_not_published_verbatim assert_completed_gate_check_is_republished_as_new_run @@ -949,6 +1117,9 @@ assert_coderabbit_stale_issue_comment_does_not_block assert_coderabbit_review_limit_issue_comment_does_not_block assert_coderabbit_no_actionable_summary_does_not_block assert_coderabbit_no_actionable_summary_with_blocker_still_blocks +assert_coderabbit_success_overrides_stale_pending_notice +assert_coderabbit_approval_pending_waits_without_blocking +assert_coderabbit_approval_pending_notice_does_not_hide_separate_blocking_warning assert_coderabbit_current_review_comment_blocks assert_coderabbit_resolved_current_review_comment_does_not_block assert_truncated_review_thread_metadata_blocks