diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index bae4a7f..7519842 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -33,9 +33,23 @@ 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 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: + + ``` + [code-reviewer] verdict: APPROVED + ``` + + Or, if blockers remain: + + ``` + [code-reviewer] verdict: REQUEST_CHANGES + ``` + + 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/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 new file mode 100644 index 0000000..3d4eee3 --- /dev/null +++ b/.github/workflows/code-reviewer-gate.yml @@ -0,0 +1,102 @@ +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, edited] + +permissions: + contents: read + statuses: write + pull-requests: read + issues: read + +jobs: + 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 + steps: + - 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, + }, + ); + + // 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.created_at) > new Date(latest.at)) { + latest = { verdict: m[1].toUpperCase(), at: c.created_at }; + } + } + + 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. + + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: pr.head.sha, + state, + context: CONTEXT, + description, + }); + + core.info(`Set ${CONTEXT} = ${state} (${description}) on ${pr.head.sha}`); diff --git a/AGENTS.md b/AGENTS.md index 080e375..f330a21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,49 @@ 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. + +**Sentinel format** — post as a top-level PR comment (not an inline +review comment): + +``` +[code-reviewer] verdict: APPROVED +``` + +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 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 resets to `pending`. Post a fresh verdict +for the updated code before merging. + +**REQUEST_CHANGES:** keeps the gate red. Fix the blockers, push, then +post a new `APPROVED` verdict. + +### 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..4353480 --- /dev/null +++ b/scripts/apply_branch_protection.sh @@ -0,0 +1,27 @@ +#!/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')}" + +[[ -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:" +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..e54d67d --- /dev/null +++ b/scripts/branch_protection_config.json @@ -0,0 +1,23 @@ +{ + "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, + "require_code_owner_reviews": false + }, + "required_linear_history": true, + "allow_force_pushes": false, + "allow_deletions": false, + "required_conversation_resolution": false, + "lock_branch": false, + "allow_fork_syncing": false, + "restrictions": null +}