From 7db2933c0974484b60146fa44fff35bbdb38991c Mon Sep 17 00:00:00 2001 From: Melon Claw Date: Mon, 20 Apr 2026 19:23:05 -0700 Subject: [PATCH 1/5] chore: add code-reviewer-gate workflow + branch protection scripts Closes #6. Mirror of melon-monarch-cfo#100: apply PR-only enforcement and code-reviewer-gate to main. - `.github/workflows/code-reviewer-gate.yml`: triggers on PR events and `issue_comment`; posts a commit status via the GitHub Statuses API. - `scripts/check_reviewer_verdict.py`: Python helper for the workflow. - `scripts/branch_protection_config.json`: committed protection config (enforce_admins, required_linear_history, code-reviewer-gate check). - `scripts/apply_branch_protection.sh`: idempotent apply script. - `.github/pull_request_template.md`: sentinel format + step-by-step instructions in "Required reviews" section. - `AGENTS.md`: new "Merge policy" section. Ordering constraint: do not run apply_branch_protection.sh until cfo retroactive M8 PRs are merged. See cfo#100. Co-Authored-By: Claude Opus 4.7 --- .github/pull_request_template.md | 31 +++++++- .github/workflows/code-reviewer-gate.yml | 97 ++++++++++++++++++++++++ AGENTS.md | 37 +++++++++ scripts/apply_branch_protection.sh | 25 ++++++ scripts/branch_protection_config.json | 19 +++++ scripts/check_reviewer_verdict.py | 31 ++++++++ 6 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/code-reviewer-gate.yml create mode 100755 scripts/apply_branch_protection.sh create mode 100644 scripts/branch_protection_config.json create mode 100644 scripts/check_reviewer_verdict.py diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index bae4a7f..a7c1cf6 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -33,9 +33,34 @@ Drop boilerplate like "added tests" if it's the default.> ## Required reviews -- [ ] **code-reviewer agent review posted on this PR and blockers resolved** - (required — invoke the `code-reviewer` subagent, post its review - as a PR review, address findings or justify dismissal in a comment) +- [ ] **code-reviewer verdict comment posted for the current HEAD SHA** + (required — the `code-reviewer-gate` CI check blocks merge until a + matching comment is present; see instructions below) + +
+How to post the verdict (expand) + +1. Run the `code-reviewer` subagent on this PR (e.g. via Claude Code's + built-in `code-reviewer` agent type). +2. Address any blockers; justify dismissed nits in a reply. +3. Post a PR comment whose **first two lines are exactly**: + + ``` + [code-reviewer] verdict: APPROVED + reviewed-sha: + ``` + + Or, if blockers remain: + + ``` + [code-reviewer] verdict: CHANGES REQUESTED + reviewed-sha: + ``` + +4. The `code-reviewer-gate` CI job will re-evaluate within ~30 s. + A new push **resets the gate** — post a fresh verdict for the new SHA. + +
## Data safety diff --git a/.github/workflows/code-reviewer-gate.yml b/.github/workflows/code-reviewer-gate.yml new file mode 100644 index 0000000..4d6221d --- /dev/null +++ b/.github/workflows/code-reviewer-gate.yml @@ -0,0 +1,97 @@ +name: code-reviewer gate + +on: + pull_request: + types: [opened, synchronize, reopened] + issue_comment: + types: [created] + +# statuses: write — needed to POST a commit status to the PR's HEAD SHA. +# For issue_comment events the workflow runs on the default branch, not the +# PR head, so we cannot rely on the Actions check-run being associated with +# the right commit. We post the status explicitly via the Statuses API. +permissions: + contents: read + pull-requests: read + statuses: write + +jobs: + code-reviewer-gate: + name: code-reviewer-gate + runs-on: ubuntu-latest + # Skip regular issue comments (not on a PR). + if: > + github.event_name == 'pull_request' || + github.event.issue.pull_request != null + steps: + - uses: actions/checkout@v4 + + - name: Resolve PR number and HEAD SHA + id: pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + PR_NUMBER="${{ github.event.pull_request.number }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + else + PR_NUMBER="${{ github.event.issue.number }}" + HEAD_SHA=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.head.sha') + fi + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + + - name: Evaluate verdict + id: verdict + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + run: | + set -euo pipefail + COMMENTS_JSON=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" \ + --jq '[.[] | {body: .body}]') + export COMMENTS_JSON HEAD_SHA + RESULT=$(python3 scripts/check_reviewer_verdict.py) + echo "result=$RESULT" >> "$GITHUB_OUTPUT" + echo "Gate verdict: $RESULT (SHA: $HEAD_SHA)" + + - name: Post commit status + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + RESULT: ${{ steps.verdict.outputs.result }} + run: | + set -euo pipefail + case "$RESULT" in + APPROVED) + STATE="success" + DESC="code-reviewer approved this commit" + ;; + CHANGES_REQUESTED) + STATE="failure" + DESC="code-reviewer requested changes — address blockers before merge" + ;; + *) + STATE="failure" + DESC="No code-reviewer verdict for SHA ${HEAD_SHA:0:7} — post verdict comment first" + ;; + esac + gh api -X POST "repos/$REPO/statuses/$HEAD_SHA" \ + --field state="$STATE" \ + --field description="$DESC" \ + --field context="code-reviewer-gate" + echo "Posted status: $STATE" + + - name: Fail job if not approved + if: steps.verdict.outputs.result != 'APPROVED' + run: | + echo "::error::No approved verdict for SHA ${{ steps.pr.outputs.head_sha }}." + echo "Post a verdict comment:" + echo " [code-reviewer] verdict: APPROVED" + echo " reviewed-sha: ${{ steps.pr.outputs.head_sha }}" + exit 1 diff --git a/AGENTS.md b/AGENTS.md index 080e375..6b3a8c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,43 @@ library, not an app. 5. Small atomic commits > big mega-commits. 6. On merge: `--squash --delete-branch`. No merge commits. +## Merge policy + +`main` is a protected branch. **Direct pushes are rejected.** Every +change must land through a PR. No admin bypass; `enforce_admins: true`. + +### `code-reviewer-gate` required status check + +Every PR must pass the `code-reviewer-gate` CI check before it can +merge. The check evaluates PR comments for a sentinel verdict: + +``` +[code-reviewer] verdict: APPROVED +reviewed-sha: +``` + +**How to satisfy the gate:** + +1. Run the `code-reviewer` subagent against the PR. +2. Address blockers; justify dismissed nits. +3. Post the verdict comment with the exact format above. The SHA must + match the PR's current HEAD commit exactly. +4. The gate re-evaluates within ~30 s (triggered by the `issue_comment` + event). Wait for the green check before merging. + +**After a new push:** the gate reverts to failing until a fresh verdict +comment is posted for the new HEAD SHA. The old approval is intentionally +invalidated — don't recycle stale verdicts. + +**CHANGES REQUESTED:** keeps the gate red. Fix the blockers, push, then +post a new `APPROVED` verdict for the new SHA. + +### Applying / re-applying branch protection + +The protection config is committed at `scripts/branch_protection_config.json`. +Run `bash scripts/apply_branch_protection.sh` from the repo root (with `gh` +authenticated) to re-apply it idempotently. + ## Repository layout ``` diff --git a/scripts/apply_branch_protection.sh b/scripts/apply_branch_protection.sh new file mode 100755 index 0000000..d92ae14 --- /dev/null +++ b/scripts/apply_branch_protection.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Idempotent: reads scripts/branch_protection_config.json and applies it to main. +# Usage: REPO=melon-lab-com/melon-monarch-ingest bash scripts/apply_branch_protection.sh +# (defaults to the repo detected by gh in the current directory) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG="$SCRIPT_DIR/branch_protection_config.json" + +REPO="${REPO:-$(gh repo view --json nameWithOwner --jq '.nameWithOwner')}" + +echo "Applying branch protection to $REPO / main ..." +gh api -X PUT "repos/$REPO/branches/main/protection" --input "$CONFIG" +echo "Done. Current protection:" +gh api "repos/$REPO/branches/main/protection" \ + --jq '{ + enforce_admins: .enforce_admins.enabled, + required_prs: (.required_pull_request_reviews != null), + required_approvals: .required_pull_request_reviews.required_approving_review_count, + dismiss_stale: .required_pull_request_reviews.dismiss_stale_reviews, + required_linear: .required_linear_history.enabled, + allow_force_push: .allow_force_pushes.enabled, + allow_deletions: .allow_deletions.enabled, + required_checks: [.required_status_checks.contexts[]] + }' diff --git a/scripts/branch_protection_config.json b/scripts/branch_protection_config.json new file mode 100644 index 0000000..7698038 --- /dev/null +++ b/scripts/branch_protection_config.json @@ -0,0 +1,19 @@ +{ + "required_status_checks": { + "strict": true, + "contexts": [ + "lint + types + tests (py3.12)", + "pre-commit hooks", + "code-reviewer-gate" + ] + }, + "enforce_admins": true, + "required_pull_request_reviews": { + "required_approving_review_count": 0, + "dismiss_stale_reviews": true + }, + "restrictions": null, + "required_linear_history": true, + "allow_force_pushes": false, + "allow_deletions": false +} diff --git a/scripts/check_reviewer_verdict.py b/scripts/check_reviewer_verdict.py new file mode 100644 index 0000000..5c34eac --- /dev/null +++ b/scripts/check_reviewer_verdict.py @@ -0,0 +1,31 @@ +"""Read PR comments from $COMMENTS_JSON and match the sentinel for $HEAD_SHA. + +Exits 0 and prints APPROVED / CHANGES_REQUESTED / PENDING. +Called by .github/workflows/code-reviewer-gate.yml. +""" + +import json +import os +import sys + +head_sha = os.environ["HEAD_SHA"] +comments: list[dict[str, str]] = json.loads(os.environ["COMMENTS_JSON"]) + +APPROVED = "[code-reviewer] verdict: APPROVED" +REJECTED = "[code-reviewer] verdict: CHANGES REQUESTED" + +for comment in comments: + lines = comment["body"].strip().splitlines() + if not lines: + continue + first = lines[0].strip() + if first not in (APPROVED, REJECTED): + continue + for line in lines[1:]: + if line.strip().startswith("reviewed-sha:"): + sha = line.split(":", 1)[1].strip() + if sha == head_sha: + print("APPROVED" if first == APPROVED else "CHANGES_REQUESTED") + sys.exit(0) + +print("PENDING") From 20e4ed7d583043cd78cd6e7f1709fc53722b299f Mon Sep 17 00:00:00 2001 From: Melon Claw Date: Mon, 20 Apr 2026 19:29:00 -0700 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20code-reviewer-gate=20=E2=80=94=20add?= =?UTF-8?q?ress=20review=20blockers=20(v2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of melon-monarch-cfo fix commit (see cfo#101): - check_reviewer_verdict.py: fix NoneType crash on deleted comments, block self-approval (author != PR_AUTHOR), take last matching verdict instead of first. - workflow: pass PR_AUTHOR env var; --field per_page=100 for pagination; preserve author field in jq projection. - apply_branch_protection.sh: add config-file existence check. - PR template + AGENTS.md: top-level vs. review comment clarification; fix timing claim (~30 s → ~60 s). Co-Authored-By: Claude Opus 4.7 --- .github/pull_request_template.md | 4 +++- .github/workflows/code-reviewer-gate.yml | 8 +++++-- AGENTS.md | 2 +- scripts/apply_branch_protection.sh | 4 +++- scripts/check_reviewer_verdict.py | 28 ++++++++++++++++++------ 5 files changed, 34 insertions(+), 12 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a7c1cf6..ee81941 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -43,7 +43,9 @@ Drop boilerplate like "added tests" if it's the default.> 1. Run the `code-reviewer` subagent on this PR (e.g. via Claude Code's built-in `code-reviewer` agent type). 2. Address any blockers; justify dismissed nits in a reply. -3. Post a PR comment whose **first two lines are exactly**: +3. Post a **top-level PR comment** (use the comment box at the bottom of the + conversation, not an inline review comment) whose **first two lines are + exactly**: ``` [code-reviewer] verdict: APPROVED diff --git a/.github/workflows/code-reviewer-gate.yml b/.github/workflows/code-reviewer-gate.yml index 4d6221d..c810697 100644 --- a/.github/workflows/code-reviewer-gate.yml +++ b/.github/workflows/code-reviewer-gate.yml @@ -52,9 +52,13 @@ jobs: PR_NUMBER: ${{ steps.pr.outputs.pr_number }} run: | set -euo pipefail + # Preserve author to block self-approval. --field per_page=100 + # avoids pagination truncation at the default 30-comment page. COMMENTS_JSON=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" \ - --jq '[.[] | {body: .body}]') - export COMMENTS_JSON HEAD_SHA + --field per_page=100 \ + --jq '[.[] | {body: .body, author: .user.login}]') + PR_AUTHOR=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.user.login') + export COMMENTS_JSON HEAD_SHA PR_AUTHOR RESULT=$(python3 scripts/check_reviewer_verdict.py) echo "result=$RESULT" >> "$GITHUB_OUTPUT" echo "Gate verdict: $RESULT (SHA: $HEAD_SHA)" diff --git a/AGENTS.md b/AGENTS.md index 6b3a8c5..d7dae06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ reviewed-sha: 2. Address blockers; justify dismissed nits. 3. Post the verdict comment with the exact format above. The SHA must match the PR's current HEAD commit exactly. -4. The gate re-evaluates within ~30 s (triggered by the `issue_comment` +4. The gate re-evaluates within ~60 s (triggered by the `issue_comment` event). Wait for the green check before merging. **After a new push:** the gate reverts to failing until a fresh verdict diff --git a/scripts/apply_branch_protection.sh b/scripts/apply_branch_protection.sh index d92ae14..4bf4350 100755 --- a/scripts/apply_branch_protection.sh +++ b/scripts/apply_branch_protection.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Idempotent: reads scripts/branch_protection_config.json and applies it to main. -# Usage: REPO=melon-lab-com/melon-monarch-ingest bash scripts/apply_branch_protection.sh +# Usage: REPO=melon-lab-com/melon-monarch-cfo bash scripts/apply_branch_protection.sh # (defaults to the repo detected by gh in the current directory) set -euo pipefail @@ -9,6 +9,8 @@ CONFIG="$SCRIPT_DIR/branch_protection_config.json" REPO="${REPO:-$(gh repo view --json nameWithOwner --jq '.nameWithOwner')}" +[[ -f "$CONFIG" ]] || { echo "error: config not found at $CONFIG"; exit 1; } + echo "Applying branch protection to $REPO / main ..." gh api -X PUT "repos/$REPO/branches/main/protection" --input "$CONFIG" echo "Done. Current protection:" diff --git a/scripts/check_reviewer_verdict.py b/scripts/check_reviewer_verdict.py index 5c34eac..d0a76ce 100644 --- a/scripts/check_reviewer_verdict.py +++ b/scripts/check_reviewer_verdict.py @@ -1,21 +1,34 @@ """Read PR comments from $COMMENTS_JSON and match the sentinel for $HEAD_SHA. -Exits 0 and prints APPROVED / CHANGES_REQUESTED / PENDING. +Prints APPROVED / CHANGES_REQUESTED / PENDING and exits 0. Called by .github/workflows/code-reviewer-gate.yml. + +Rules: +- Only comments NOT authored by $PR_AUTHOR are considered (no self-approval). +- The last matching verdict for $HEAD_SHA wins (revoke with CHANGES REQUESTED). +- "body": null comments (deleted) are silently skipped. """ import json import os -import sys head_sha = os.environ["HEAD_SHA"] -comments: list[dict[str, str]] = json.loads(os.environ["COMMENTS_JSON"]) +pr_author = os.environ.get("PR_AUTHOR", "") +comments: list[dict[str, str | None]] = json.loads(os.environ["COMMENTS_JSON"]) APPROVED = "[code-reviewer] verdict: APPROVED" REJECTED = "[code-reviewer] verdict: CHANGES REQUESTED" +result: str | None = None + for comment in comments: - lines = comment["body"].strip().splitlines() + # Skip self-approvals and deleted bodies. + if comment.get("author") == pr_author: + continue + body = comment.get("body") + if not body: + continue + lines = body.strip().splitlines() if not lines: continue first = lines[0].strip() @@ -25,7 +38,8 @@ if line.strip().startswith("reviewed-sha:"): sha = line.split(":", 1)[1].strip() if sha == head_sha: - print("APPROVED" if first == APPROVED else "CHANGES_REQUESTED") - sys.exit(0) + # Keep iterating — last matching verdict wins. + result = "APPROVED" if first == APPROVED else "CHANGES_REQUESTED" + break -print("PENDING") +print(result if result is not None else "PENDING") From 9320c93e37af6b26d6389923f0e0b0be4178042d Mon Sep 17 00:00:00 2001 From: Melon Claw Date: Mon, 20 Apr 2026 19:30:37 -0700 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20revert=20author-check=20=E2=80=94=20?= =?UTF-8?q?solo-contributor=20gate=20must=20allow=20self-posted=20verdicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of cfo fix: code-reviewer agent posts verdicts as the repo owner; author-check would always filter them out. SHA-anchoring is the meaningful integrity mechanism. See cfo#101. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/code-reviewer-gate.yml | 9 ++++----- scripts/check_reviewer_verdict.py | 12 ++++++------ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/code-reviewer-gate.yml b/.github/workflows/code-reviewer-gate.yml index c810697..d0c992c 100644 --- a/.github/workflows/code-reviewer-gate.yml +++ b/.github/workflows/code-reviewer-gate.yml @@ -52,13 +52,12 @@ jobs: PR_NUMBER: ${{ steps.pr.outputs.pr_number }} run: | set -euo pipefail - # Preserve author to block self-approval. --field per_page=100 - # avoids pagination truncation at the default 30-comment page. + # --field per_page=100 avoids pagination truncation at the + # default 30-comment page size. COMMENTS_JSON=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" \ --field per_page=100 \ - --jq '[.[] | {body: .body, author: .user.login}]') - PR_AUTHOR=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.user.login') - export COMMENTS_JSON HEAD_SHA PR_AUTHOR + --jq '[.[] | {body: .body}]') + export COMMENTS_JSON HEAD_SHA RESULT=$(python3 scripts/check_reviewer_verdict.py) echo "result=$RESULT" >> "$GITHUB_OUTPUT" echo "Gate verdict: $RESULT (SHA: $HEAD_SHA)" diff --git a/scripts/check_reviewer_verdict.py b/scripts/check_reviewer_verdict.py index d0a76ce..4bbea4c 100644 --- a/scripts/check_reviewer_verdict.py +++ b/scripts/check_reviewer_verdict.py @@ -4,16 +4,19 @@ Called by .github/workflows/code-reviewer-gate.yml. Rules: -- Only comments NOT authored by $PR_AUTHOR are considered (no self-approval). -- The last matching verdict for $HEAD_SHA wins (revoke with CHANGES REQUESTED). - "body": null comments (deleted) are silently skipped. +- The last matching verdict for $HEAD_SHA wins (a CHANGES REQUESTED posted + after an APPROVED for the same SHA revokes the approval). +- No author check: this is a solo-contributor repo; the code-reviewer agent + runs as the repo owner and posts verdicts under their account. The SHA + anchor is the meaningful integrity mechanism. For multi-contributor repos + add: `if comment.get("author") == os.environ.get("PR_AUTHOR"): continue`. """ import json import os head_sha = os.environ["HEAD_SHA"] -pr_author = os.environ.get("PR_AUTHOR", "") comments: list[dict[str, str | None]] = json.loads(os.environ["COMMENTS_JSON"]) APPROVED = "[code-reviewer] verdict: APPROVED" @@ -22,9 +25,6 @@ result: str | None = None for comment in comments: - # Skip self-approvals and deleted bodies. - if comment.get("author") == pr_author: - continue body = comment.get("body") if not body: continue From 6b582ae57f2d7af92d3df303094be29f3c1b3afc Mon Sep 17 00:00:00 2001 From: Melon Claw Date: Mon, 20 Apr 2026 19:36:30 -0700 Subject: [PATCH 4/5] chore: sync to JavaScript workflow approach (cfo#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace bash+Python implementation with cleaner JavaScript workflow (same approach as companion cfo PR #103): - code-reviewer-gate.yml: use actions/github-script@v7; `pending` on push events; scan ALL comments on issue_comment, pick latest by updated_at; supports edited verdicts; uses github.paginate for reliable pagination. No Python script needed. - Remove scripts/check_reviewer_verdict.py (no longer needed). - scripts/branch_protection_config.json: add missing fields from cfo config (require_code_owner_reviews, lock_branch, allow_fork_syncing). - PR template + AGENTS.md: use simpler sentinel format (no SHA line needed — gate uses time-ordering instead). Sentinel: [code-reviewer] verdict: APPROVED [code-reviewer] verdict: REQUEST_CHANGES Co-Authored-By: Claude Opus 4.7 --- .github/pull_request_template.md | 39 ++---- .github/workflows/code-reviewer-gate.yml | 168 ++++++++++++----------- AGENTS.md | 24 ++-- scripts/branch_protection_config.json | 10 +- scripts/check_reviewer_verdict.py | 45 ------ 5 files changed, 120 insertions(+), 166 deletions(-) delete mode 100644 scripts/check_reviewer_verdict.py diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ee81941..7519842 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -33,36 +33,23 @@ Drop boilerplate like "added tests" if it's the default.> ## Required reviews -- [ ] **code-reviewer verdict comment posted for the current HEAD SHA** - (required — the `code-reviewer-gate` CI check blocks merge until a - matching comment is present; see instructions below) +- [ ] **code-reviewer agent run, verdict posted, blockers resolved.** + Invoke the `code-reviewer` subagent. When it returns, post a + **top-level PR comment** (not an inline review comment) with the + sentinel the `code-reviewer-gate` workflow scans for: -
-How to post the verdict (expand) + ``` + [code-reviewer] verdict: APPROVED + ``` -1. Run the `code-reviewer` subagent on this PR (e.g. via Claude Code's - built-in `code-reviewer` agent type). -2. Address any blockers; justify dismissed nits in a reply. -3. Post a **top-level PR comment** (use the comment box at the bottom of the - conversation, not an inline review comment) whose **first two lines are - exactly**: + Or, if blockers remain: - ``` - [code-reviewer] verdict: APPROVED - reviewed-sha: - ``` + ``` + [code-reviewer] verdict: REQUEST_CHANGES + ``` - Or, if blockers remain: - - ``` - [code-reviewer] verdict: CHANGES REQUESTED - reviewed-sha: - ``` - -4. The `code-reviewer-gate` CI job will re-evaluate within ~30 s. - A new push **resets the gate** — post a fresh verdict for the new SHA. - -
+ New commits reset the gate to `pending` — re-run and post a + fresh verdict. See AGENTS.md → **Merge policy** for details. ## Data safety diff --git a/.github/workflows/code-reviewer-gate.yml b/.github/workflows/code-reviewer-gate.yml index d0c992c..9a37cba 100644 --- a/.github/workflows/code-reviewer-gate.yml +++ b/.github/workflows/code-reviewer-gate.yml @@ -1,100 +1,102 @@ -name: code-reviewer gate +name: code-reviewer-gate + +# Enforces the "every PR needs a code-reviewer verdict" rule documented in +# AGENTS.md → Merge policy. Creates a commit status named `code-reviewer-gate` +# on the PR's head SHA: +# +# - on pull_request open / synchronize / reopened → state=pending +# (new commits invalidate prior verdicts; post a fresh verdict) +# - on issue_comment created / edited → scan ALL comments, use the +# LATEST one matching the sentinel, set state=success (APPROVED) or +# state=failure (REQUEST_CHANGES / CHANGES_REQUESTED) +# +# Sentinel format (post as a top-level PR comment, not an inline review): +# [code-reviewer] verdict: APPROVED +# To request changes: +# [code-reviewer] verdict: REQUEST_CHANGES on: pull_request: types: [opened, synchronize, reopened] issue_comment: - types: [created] + types: [created, edited] -# statuses: write — needed to POST a commit status to the PR's HEAD SHA. -# For issue_comment events the workflow runs on the default branch, not the -# PR head, so we cannot rely on the Actions check-run being associated with -# the right commit. We post the status explicitly via the Statuses API. permissions: contents: read - pull-requests: read statuses: write + pull-requests: read + issues: read jobs: - code-reviewer-gate: - name: code-reviewer-gate + gate: + # Run on every PR event; for issue_comment only when on a PR (not issue). + if: github.event_name == 'pull_request' || github.event.issue.pull_request != null runs-on: ubuntu-latest - # Skip regular issue comments (not on a PR). - if: > - github.event_name == 'pull_request' || - github.event.issue.pull_request != null steps: - - uses: actions/checkout@v4 + - uses: actions/github-script@v7 + with: + script: | + const CONTEXT = 'code-reviewer-gate'; + const SENTINEL = /\[code-reviewer\]\s+verdict:\s+(APPROVED|REQUEST_CHANGES|CHANGES_REQUESTED)/i; + + let pr; + if (context.eventName === 'pull_request') { + pr = context.payload.pull_request; + } else { + const { data } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.issue.number, + }); + pr = data; + } + + let state = 'pending'; + let description = 'Awaiting [code-reviewer] verdict'; + + if (context.eventName === 'issue_comment') { + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100, + }, + ); - - name: Resolve PR number and HEAD SHA - id: pr - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - PR_NUMBER="${{ github.event.pull_request.number }}" - HEAD_SHA="${{ github.event.pull_request.head.sha }}" - else - PR_NUMBER="${{ github.event.issue.number }}" - HEAD_SHA=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.head.sha') - fi - echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" - echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + // Pick the LATEST matching comment by updated_at so an edited + // verdict (e.g. APPROVED → REQUEST_CHANGES after a follow-up) + // always wins. + let latest = null; + for (const c of comments) { + const m = (c.body || '').match(SENTINEL); + if (!m) continue; + if (!latest || new Date(c.updated_at) >= new Date(latest.at)) { + latest = { verdict: m[1].toUpperCase(), at: c.updated_at }; + } + } - - name: Evaluate verdict - id: verdict - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - HEAD_SHA: ${{ steps.pr.outputs.head_sha }} - PR_NUMBER: ${{ steps.pr.outputs.pr_number }} - run: | - set -euo pipefail - # --field per_page=100 avoids pagination truncation at the - # default 30-comment page size. - COMMENTS_JSON=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" \ - --field per_page=100 \ - --jq '[.[] | {body: .body}]') - export COMMENTS_JSON HEAD_SHA - RESULT=$(python3 scripts/check_reviewer_verdict.py) - echo "result=$RESULT" >> "$GITHUB_OUTPUT" - echo "Gate verdict: $RESULT (SHA: $HEAD_SHA)" + if (latest) { + if (latest.verdict === 'APPROVED') { + state = 'success'; + description = 'code-reviewer APPROVED'; + } else { + state = 'failure'; + description = `code-reviewer ${latest.verdict}`; + } + } + } + // else: pull_request event → always reset to pending; new commits + // invalidate prior verdicts. - - name: Post commit status - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - HEAD_SHA: ${{ steps.pr.outputs.head_sha }} - RESULT: ${{ steps.verdict.outputs.result }} - run: | - set -euo pipefail - case "$RESULT" in - APPROVED) - STATE="success" - DESC="code-reviewer approved this commit" - ;; - CHANGES_REQUESTED) - STATE="failure" - DESC="code-reviewer requested changes — address blockers before merge" - ;; - *) - STATE="failure" - DESC="No code-reviewer verdict for SHA ${HEAD_SHA:0:7} — post verdict comment first" - ;; - esac - gh api -X POST "repos/$REPO/statuses/$HEAD_SHA" \ - --field state="$STATE" \ - --field description="$DESC" \ - --field context="code-reviewer-gate" - echo "Posted status: $STATE" + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: pr.head.sha, + state, + context: CONTEXT, + description, + }); - - name: Fail job if not approved - if: steps.verdict.outputs.result != 'APPROVED' - run: | - echo "::error::No approved verdict for SHA ${{ steps.pr.outputs.head_sha }}." - echo "Post a verdict comment:" - echo " [code-reviewer] verdict: APPROVED" - echo " reviewed-sha: ${{ steps.pr.outputs.head_sha }}" - exit 1 + core.info(`Set ${CONTEXT} = ${state} (${description}) on ${pr.head.sha}`); diff --git a/AGENTS.md b/AGENTS.md index d7dae06..f330a21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,28 +31,34 @@ change must land through a PR. No admin bypass; `enforce_admins: true`. ### `code-reviewer-gate` required status check Every PR must pass the `code-reviewer-gate` CI check before it can -merge. The check evaluates PR comments for a sentinel verdict: +merge. The check evaluates PR comments for a sentinel verdict. + +**Sentinel format** — post as a top-level PR comment (not an inline +review comment): ``` [code-reviewer] verdict: APPROVED -reviewed-sha: +``` + +Or to block merge: + +``` +[code-reviewer] verdict: REQUEST_CHANGES ``` **How to satisfy the gate:** 1. Run the `code-reviewer` subagent against the PR. 2. Address blockers; justify dismissed nits. -3. Post the verdict comment with the exact format above. The SHA must - match the PR's current HEAD commit exactly. +3. Post the verdict comment in the format above. 4. The gate re-evaluates within ~60 s (triggered by the `issue_comment` event). Wait for the green check before merging. -**After a new push:** the gate reverts to failing until a fresh verdict -comment is posted for the new HEAD SHA. The old approval is intentionally -invalidated — don't recycle stale verdicts. +**After a new push:** the gate resets to `pending`. Post a fresh verdict +for the updated code before merging. -**CHANGES REQUESTED:** keeps the gate red. Fix the blockers, push, then -post a new `APPROVED` verdict for the new SHA. +**REQUEST_CHANGES:** keeps the gate red. Fix the blockers, push, then +post a new `APPROVED` verdict. ### Applying / re-applying branch protection diff --git a/scripts/branch_protection_config.json b/scripts/branch_protection_config.json index 7698038..e54d67d 100644 --- a/scripts/branch_protection_config.json +++ b/scripts/branch_protection_config.json @@ -10,10 +10,14 @@ "enforce_admins": true, "required_pull_request_reviews": { "required_approving_review_count": 0, - "dismiss_stale_reviews": true + "dismiss_stale_reviews": true, + "require_code_owner_reviews": false }, - "restrictions": null, "required_linear_history": true, "allow_force_pushes": false, - "allow_deletions": false + "allow_deletions": false, + "required_conversation_resolution": false, + "lock_branch": false, + "allow_fork_syncing": false, + "restrictions": null } diff --git a/scripts/check_reviewer_verdict.py b/scripts/check_reviewer_verdict.py deleted file mode 100644 index 4bbea4c..0000000 --- a/scripts/check_reviewer_verdict.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Read PR comments from $COMMENTS_JSON and match the sentinel for $HEAD_SHA. - -Prints APPROVED / CHANGES_REQUESTED / PENDING and exits 0. -Called by .github/workflows/code-reviewer-gate.yml. - -Rules: -- "body": null comments (deleted) are silently skipped. -- The last matching verdict for $HEAD_SHA wins (a CHANGES REQUESTED posted - after an APPROVED for the same SHA revokes the approval). -- No author check: this is a solo-contributor repo; the code-reviewer agent - runs as the repo owner and posts verdicts under their account. The SHA - anchor is the meaningful integrity mechanism. For multi-contributor repos - add: `if comment.get("author") == os.environ.get("PR_AUTHOR"): continue`. -""" - -import json -import os - -head_sha = os.environ["HEAD_SHA"] -comments: list[dict[str, str | None]] = json.loads(os.environ["COMMENTS_JSON"]) - -APPROVED = "[code-reviewer] verdict: APPROVED" -REJECTED = "[code-reviewer] verdict: CHANGES REQUESTED" - -result: str | None = None - -for comment in comments: - body = comment.get("body") - if not body: - continue - lines = body.strip().splitlines() - if not lines: - continue - first = lines[0].strip() - if first not in (APPROVED, REJECTED): - continue - for line in lines[1:]: - if line.strip().startswith("reviewed-sha:"): - sha = line.split(":", 1)[1].strip() - if sha == head_sha: - # Keep iterating — last matching verdict wins. - result = "APPROVED" if first == APPROVED else "CHANGES_REQUESTED" - break - -print(result if result is not None else "PENDING") From ef56dcc023fcd57e937690eec347a6e8fcd934d8 Mon Sep 17 00:00:00 2001 From: Melon Claw Date: Mon, 20 Apr 2026 19:40:52 -0700 Subject: [PATCH 5/5] fix: scope ci concurrency per-job; use created_at for verdict ordering - ci.yml: scope concurrency group per-job (group: ci-{ref}-{job}) to prevent cross-job cancellation from leaving required checks in `cancelled` state, which deadlocks PRs under branch protection. - code-reviewer-gate.yml: use created_at (not updated_at) to determine the "latest" verdict so editing an old APPROVED comment can't trump a newer REQUEST_CHANGES verdict posted chronologically after it. - apply_branch_protection.sh: fix copy-paste repo name in usage comment. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 6 +++++- .github/workflows/code-reviewer-gate.yml | 10 +++++----- scripts/apply_branch_protection.sh | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b144801..e4af826 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,11 @@ on: pull_request: concurrency: - group: ci-${{ github.ref }} + # Scope per-job so a rapid push only cancels the same job on the old + # commit, not other jobs. Cross-job cancellation left a `cancelled` + # status on required checks, which branch protection treats as pending + # and deadlocks the PR until the next push. + group: ci-${{ github.ref }}-${{ github.job }} cancel-in-progress: true jobs: diff --git a/.github/workflows/code-reviewer-gate.yml b/.github/workflows/code-reviewer-gate.yml index 9a37cba..3d4eee3 100644 --- a/.github/workflows/code-reviewer-gate.yml +++ b/.github/workflows/code-reviewer-gate.yml @@ -65,15 +65,15 @@ jobs: }, ); - // Pick the LATEST matching comment by updated_at so an edited - // verdict (e.g. APPROVED → REQUEST_CHANGES after a follow-up) - // always wins. + // Pick the LATEST matching comment by created_at (not + // updated_at) so editing an old APPROVED comment can't + // trump a newer REQUEST_CHANGES verdict posted after it. let latest = null; for (const c of comments) { const m = (c.body || '').match(SENTINEL); if (!m) continue; - if (!latest || new Date(c.updated_at) >= new Date(latest.at)) { - latest = { verdict: m[1].toUpperCase(), at: c.updated_at }; + if (!latest || new Date(c.created_at) > new Date(latest.at)) { + latest = { verdict: m[1].toUpperCase(), at: c.created_at }; } } diff --git a/scripts/apply_branch_protection.sh b/scripts/apply_branch_protection.sh index 4bf4350..4353480 100755 --- a/scripts/apply_branch_protection.sh +++ b/scripts/apply_branch_protection.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Idempotent: reads scripts/branch_protection_config.json and applies it to main. -# Usage: REPO=melon-lab-com/melon-monarch-cfo bash scripts/apply_branch_protection.sh +# Usage: REPO=melon-lab-com/melon-monarch-ingest bash scripts/apply_branch_protection.sh # (defaults to the repo detected by gh in the current directory) set -euo pipefail