Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
102 changes: 102 additions & 0 deletions .github/workflows/code-reviewer-gate.yml
Original file line number Diff line number Diff line change
@@ -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}`);
43 changes: 43 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
27 changes: 27 additions & 0 deletions scripts/apply_branch_protection.sh
Original file line number Diff line number Diff line change
@@ -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[]]
}'
23 changes: 23 additions & 0 deletions scripts/branch_protection_config.json
Original file line number Diff line number Diff line change
@@ -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
}
Loading