From c57e78862ef8fb71379b3dafb712c30ca18ca26b Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 10 Apr 2026 22:00:47 -0500 Subject: [PATCH] =?UTF-8?q?docs:=20sync=20KB=20=E2=80=94=202026-04-10-pr-r?= =?UTF-8?q?eview-process.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-04-10-pr-review-process.md | 2460 +++++++++++++++++ 1 file changed, 2460 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-10-pr-review-process.md diff --git a/docs/superpowers/plans/2026-04-10-pr-review-process.md b/docs/superpowers/plans/2026-04-10-pr-review-process.md new file mode 100644 index 0000000..46a9c1b --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-pr-review-process.md @@ -0,0 +1,2460 @@ +# PR Review Process Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace COMMENT-only PR reviews (caused by Gitea self-approval block) with a real formal-review gate using a dedicated `claude-reviewer` Gitea bot, tightened branch protection, and a pre-tag deploy audit hook. + +**Architecture:** Two-identity Gitea split (existing `Claude` writes code, new `claude-reviewer` reviews). Branch protection requires 1 approval from `claude-reviewer` with admin override retained. `dismiss_stale_approvals: true` plus a dispatcher SHA check close the fix-loop on new commits. A local `pre-push` hook runs `deploy-audit` before CalVer tags reach the remote, blocking unless every PR in the range has a clean APPROVED review from `claude-reviewer`. + +**Tech Stack:** Bash, Python 3, Gitea REST API (curl + jq + `tea` CLI), systemd timers, git hooks, pytest + +**Spec:** `docs/superpowers/specs/2026-04-10-pr-review-process-design.md` + +--- + +## Environment Notes + +**Not running in a git worktree.** This plan executes across multiple locations on the workstation: + +- `~/.config/claude-scheduled/` — dispatcher code, secrets, git hooks (NOT a git repo — direct edits, backed up ad-hoc) +- `/mnt/NV2/Development/claude-home/` — the claude-home repo (this plan file lives here) +- Remote Gitea instance at `https://git.manticorum.com` — API calls + +No single worktree covers all of these. Each phase runs in its natural location. + +**Spec deviation:** Spec says store the new token at `~/.config/claude-scheduled/secrets/claude-reviewer-token`. The existing `secrets` is a **file**, not a directory, so that path would require reorganizing the existing layout (which would touch `issue-dispatcher.sh` too). Instead: create a sibling file at `~/.config/claude-scheduled/secrets-claude-reviewer` that sets `GITEA_ACCESS_TOKEN` the same way the existing `secrets` file does. Cleaner, zero blast radius on the issue-worker path. + +--- + +## File Structure + +**Files created:** +- `~/.config/claude-scheduled/secrets-claude-reviewer` — shell file sourcing `GITEA_ACCESS_TOKEN=`. NOT checked in. +- `~/.config/claude-scheduled/deploy-audit` — main audit script (bash wrapper, calls python core) +- `~/.config/claude-scheduled/deploy_audit_core.py` — pure-python audit logic (unit-testable) +- `~/.config/claude-scheduled/tests/test_deploy_audit_core.py` — pytest unit tests for the python core +- `~/.config/claude-scheduled/git-hooks/pre-push` — pre-push git hook, installed via `core.hooksPath` +- `~/.config/claude-scheduled/scripts/dump-branch-protection.sh` — back up existing protection JSONs before change +- `~/.config/claude-scheduled/scripts/apply-branch-protection.sh` — idempotent protection applier +- `~/.config/claude-scheduled/scripts/install-pre-push-hook.sh` — one-shot `core.hooksPath` installer +- `.claude/tmp/branch-protection-backup/.json` — per-repo backup JSONs (workstation-local, not git) + +**Files modified:** +- `~/.config/claude-scheduled/gitea-lib.sh` — add `SECRETS_FILE` override at line 10, add `get_last_review_sha`, `reviewing_label_age` helpers, verify helpers against live Gitea +- `~/.config/claude-scheduled/pr-reviewer-dispatcher.sh` — export `SECRETS_FILE` before sourcing `gitea-lib.sh`, replace skip-label filter with SHA-mismatch check, add staleness timeout for `ai-reviewing` + +**Files untouched (explicit non-goals):** +- `~/.claude/agents/pr-reviewer.md` — reviewer agent prompt/logic unchanged +- `~/.config/claude-scheduled/issue-dispatcher.sh`, `issue-poller.sh`, `runner.sh` — issue-worker path not affected +- `~/.config/claude-scheduled/secrets` — existing `Claude` token file untouched +- CalVer tag CI workflow and Docker build pipeline + +--- + +## Phase 1: Gitea Account Setup + +**Goal:** Establish the `claude-reviewer` identity and verify API access. No code changes, no behavior changes to existing dispatchers. + +### Task 1: Create the `claude-reviewer` Gitea user + +**Files:** None (manual Gitea admin action) + +- [ ] **Step 1: Open Gitea site admin → Users → Create Account** + +Navigate to `https://git.manticorum.com/-/admin/users/new`. Requires admin privileges. + +- [ ] **Step 2: Fill in account details** + +| Field | Value | +|---|---| +| Username | `claude-reviewer` | +| Email | `cal+claude-reviewer@` — a deliverable address you control, for password reset | +| Full Name | `Claude Reviewer` | +| Password | Generate a strong random password (e.g. `openssl rand -base64 32`) — won't be used for API access, store in password manager | +| Must Change Password | **Unchecked** | +| Send Notify | **Unchecked** | + +Click **Create Account**. + +- [ ] **Step 3: Set a distinct avatar** + +Log out, log in as `claude-reviewer` with the generated password, go to **Settings → Profile**. Upload an avatar that is visually distinct from the existing `Claude` user (different color/icon) so timeline entries are readable at a glance. A solid color blob works — the goal is visual separation, not aesthetics. + +- [ ] **Step 4: Verify the account exists via API** + +Run (as `cal`, using your own admin token): + +```bash +tea api /users/claude-reviewer | jq '{login, full_name, id, is_admin}' +``` + +Expected output: +```json +{ + "login": "claude-reviewer", + "full_name": "Claude Reviewer", + "id": , + "is_admin": false +} +``` + +If the user doesn't appear, the create step failed — retry Step 2. + +- [ ] **Step 5: Log out of `claude-reviewer`, log back in as `cal`** + +You won't log in as `claude-reviewer` again. All future interactions are via the PAT. + +### Task 2: Generate and store the `claude-reviewer` PAT + +**Files:** +- Create: `~/.config/claude-scheduled/secrets-claude-reviewer` + +- [ ] **Step 1: Generate the PAT via Gitea admin API** + +Gitea admin endpoint can create tokens for other users without logging in as them. Run: + +```bash +tea api -X POST /admin/users/claude-reviewer/tokens -f name=pr-reviewer-dispatcher \ + -f scopes=read:repository,write:issue,write:repository \ + | jq -r '.sha1' +``` + +This returns the raw PAT string (exactly once — it cannot be retrieved later). Capture it immediately. + +Expected: a 40-character hex token. If you see an error about scopes, your Gitea version may use different scope names — fall back to the UI: + +**Fallback:** log in as `claude-reviewer` in a private browser window → Settings → Applications → Generate New Token. Name: `pr-reviewer-dispatcher`. Scopes: `read:repository`, `write:issue`, `write:repository`. Copy the shown token. + +- [ ] **Step 2: Store the token in a sibling secrets file** + +Write the shell file that the dispatcher will source. Use `install` with mode `600` to avoid a permission race: + +```bash +install -m 600 /dev/null ~/.config/claude-scheduled/secrets-claude-reviewer +cat > ~/.config/claude-scheduled/secrets-claude-reviewer <<'EOF' +# claude-reviewer Gitea token — used ONLY by pr-reviewer-dispatcher.sh +# Do not share between dispatchers. +GITEA_ACCESS_TOKEN="" +EOF +chmod 600 ~/.config/claude-scheduled/secrets-claude-reviewer +``` + +Replace `` with the real token. + +- [ ] **Step 3: Verify the file** + +```bash +ls -la ~/.config/claude-scheduled/secrets-claude-reviewer +``` + +Expected: `-rw-------` (600 perms), owner `cal`. + +```bash +bash -c 'source ~/.config/claude-scheduled/secrets-claude-reviewer && echo "token length: ${#GITEA_ACCESS_TOKEN}"' +``` + +Expected: `token length: 40` (or whatever Gitea's current token length is — the point is that the file parses as a shell script and the var is set). + +- [ ] **Step 4: Smoke-test the token directly** + +```bash +bash -c 'source ~/.config/claude-scheduled/secrets-claude-reviewer && \ + curl -sf -H "Authorization: token $GITEA_ACCESS_TOKEN" \ + https://git.manticorum.com/api/v1/user | jq .login' +``` + +Expected: `"claude-reviewer"` + +If you see any other username, you pasted the wrong token — regenerate and retry. + +### Task 3: Add `claude-reviewer` as Write collaborator on tracked repos + +**Files:** None (API calls to Gitea) + +- [ ] **Step 1: List the tracked repos** + +```bash +cat ~/.config/claude-scheduled/tasks/pr-reviewer/repos.json | jq 'keys' +``` + +Expected: a JSON array of repo names, e.g. `["paper-dynasty-database", "major-domo-database", ...]`. Save this output mentally — you'll iterate over it. + +- [ ] **Step 2: Add claude-reviewer as collaborator to each repo** + +For each repo in the list, run (replacing `` and ``): + +```bash +tea api -X PUT "/repos///collaborators/claude-reviewer" \ + -f permission=write +``` + +A scripted loop: + +```bash +REPOS_FILE=~/.config/claude-scheduled/tasks/pr-reviewer/repos.json +python3 -c " +import json +with open('$REPOS_FILE') as f: + repos = json.load(f) +for name, cfg in repos.items(): + owner = cfg.get('owner', 'cal') + print(f'{owner}/{name}') +" | while read slug; do + echo "→ adding claude-reviewer to $slug" + tea api -X PUT "/repos/$slug/collaborators/claude-reviewer" -f permission=write \ + || echo " ERROR: failed for $slug" +done +``` + +Expected: no output from `tea api` per repo on success (204 No Content). An "ERROR" line indicates that repo needs manual attention. + +- [ ] **Step 3: Verify collaborator status for each repo** + +```bash +REPOS_FILE=~/.config/claude-scheduled/tasks/pr-reviewer/repos.json +python3 -c " +import json +with open('$REPOS_FILE') as f: + repos = json.load(f) +for name, cfg in repos.items(): + owner = cfg.get('owner', 'cal') + print(f'{owner}/{name}') +" | while read slug; do + perm=$(tea api "/repos/$slug/collaborators/claude-reviewer/permission" | jq -r '.permission') + echo "$slug → $perm" +done +``` + +Expected: every line ends with `write`. Any line ending with `none`, `read`, or an error message means collaborator wasn't added — retry Step 2 for that repo. + +### Task 4: End-to-end smoke test as `claude-reviewer` + +**Files:** None (API calls) + +- [ ] **Step 1: List repos using the claude-reviewer token** + +```bash +bash -c 'source ~/.config/claude-scheduled/secrets-claude-reviewer && \ + curl -sf -H "Authorization: token $GITEA_ACCESS_TOKEN" \ + "https://git.manticorum.com/api/v1/repos/search?limit=50" | jq ".data | length"' +``` + +Expected: an integer (number of repos visible to claude-reviewer). Should be at least the number of tracked repos. + +- [ ] **Step 2: Fetch a specific tracked repo's open PRs** + +Pick any tracked repo. Example with `cal/paper-dynasty-database`: + +```bash +bash -c 'source ~/.config/claude-scheduled/secrets-claude-reviewer && \ + curl -sf -H "Authorization: token $GITEA_ACCESS_TOKEN" \ + "https://git.manticorum.com/api/v1/repos/cal/paper-dynasty-database/pulls?state=open&limit=5" | jq ".[].number"' +``` + +Expected: integers (PR numbers) or empty output (no open PRs). Should NOT error. + +- [ ] **Step 3: Post a throwaway COMMENT review on a safe PR (optional but recommended)** + +Pick an open PR that's safe to comment on (a docs PR, or one you own). Post a COMMENT-only review as `claude-reviewer` (does not approve anything, does not request changes): + +```bash +PR_NUMBER= +REPO=cal/paper-dynasty-database # or whichever + +bash -c "source ~/.config/claude-scheduled/secrets-claude-reviewer && \ + curl -sf -X POST -H \"Authorization: token \$GITEA_ACCESS_TOKEN\" \ + -H 'Content-Type: application/json' \ + -d '{\"event\":\"COMMENT\",\"body\":\"smoke test from claude-reviewer PAT — ignore\"}' \ + https://git.manticorum.com/api/v1/repos/$REPO/pulls/$PR_NUMBER/reviews" | jq '{id, state, user: .user.login}' +``` + +Expected: +```json +{ + "id": , + "state": "COMMENT", + "user": "claude-reviewer" +} +``` + +If you see `"user": "cal"` — you sourced the wrong secrets file. If you see a 403 — the collaborator permission didn't stick for that repo, revisit Task 3 for that repo. If it succeeds, the account can review. You can dismiss the comment from the PR's review UI afterward. + +- [ ] **Step 4: Checkpoint — Phase 1 complete** + +The account exists, the token works, the account is a collaborator on every tracked repo, and it can post formal reviews. No code has changed; existing dispatchers are unaffected. Safe to stop here indefinitely and resume later. + +--- + +## Phase 2: Dispatcher Code Changes + +**Goal:** Swap the pr-reviewer-dispatcher to use the new token, add re-review-on-push and staleness-timeout logic. Issue-dispatcher path untouched. + +### Task 5: Add per-invocation secrets override to `gitea-lib.sh` + +**Files:** +- Modify: `~/.config/claude-scheduled/gitea-lib.sh:10` + +Current code (line 10): +```bash +source "$BASE_DIR/secrets" +``` + +This is a hard-coded path. To let individual dispatchers use different tokens without touching each other, add a `SECRETS_FILE` env var override. + +- [ ] **Step 1: Read the current line to confirm** + +```bash +sed -n '8,12p' ~/.config/claude-scheduled/gitea-lib.sh +``` + +Expected output includes the line `source "$BASE_DIR/secrets"`. + +- [ ] **Step 2: Replace with override-capable form** + +Replace line 10 with: + +```bash +source "${SECRETS_FILE:-$BASE_DIR/secrets}" +``` + +Use `sed` to do it in-place (with a timestamped backup): + +```bash +cp ~/.config/claude-scheduled/gitea-lib.sh ~/.config/claude-scheduled/gitea-lib.sh.bak.$(date +%Y%m%d-%H%M%S) +sed -i 's|^source "\$BASE_DIR/secrets"$|source "${SECRETS_FILE:-$BASE_DIR/secrets}"|' \ + ~/.config/claude-scheduled/gitea-lib.sh +``` + +- [ ] **Step 3: Verify the edit** + +```bash +grep -n 'SECRETS_FILE' ~/.config/claude-scheduled/gitea-lib.sh +``` + +Expected: `10:source "${SECRETS_FILE:-$BASE_DIR/secrets}"`. + +- [ ] **Step 4: Syntax check** + +```bash +bash -n ~/.config/claude-scheduled/gitea-lib.sh +``` + +Expected: no output (clean parse). + +- [ ] **Step 5: Smoke test — existing callers still work (default path)** + +Source it with no override and confirm the existing token loads: + +```bash +(export BASE_DIR=~/.config/claude-scheduled LOG_FILE=/dev/null && \ + source ~/.config/claude-scheduled/gitea-lib.sh && \ + echo "token prefix: ${GITEA_ACCESS_TOKEN:0:8}") +``` + +Expected: an 8-char token prefix. This should match the `Claude` user's current token prefix (the existing behavior). + +- [ ] **Step 6: Smoke test — override works** + +```bash +(export BASE_DIR=~/.config/claude-scheduled LOG_FILE=/dev/null \ + SECRETS_FILE=~/.config/claude-scheduled/secrets-claude-reviewer && \ + source ~/.config/claude-scheduled/gitea-lib.sh && \ + curl -sf -H "Authorization: token $GITEA_ACCESS_TOKEN" \ + https://git.manticorum.com/api/v1/user | jq -r '.login') +``` + +Expected: `claude-reviewer`. + +If this prints `cal` or any other user, the override isn't taking effect — check the sed result from Step 3. + +### Task 6: Add `get_last_review_sha` helper to `gitea-lib.sh` + +**Files:** +- Modify: `~/.config/claude-scheduled/gitea-lib.sh` (append new helper function) + +Purpose: given an owner/repo/PR number and a reviewer username, return the `commit_id` of the most recent non-dismissed review by that user. Empty string if no such review. + +- [ ] **Step 1: Draft the helper** + +Append to `gitea-lib.sh` before the `# ── Auth check ──` section (roughly after the label helpers, around line 139): + +```bash +# Get the commit SHA of the most recent non-dismissed review by a specific user. +# Returns empty string if no such review exists. +# Usage: sha=$(get_last_review_sha "cal" "paper-dynasty-database" 42 "claude-reviewer") +get_last_review_sha() { + local owner="$1" repo="$2" number="$3" reviewer="$4" + local output + output=$(gitea_get "repos/$owner/$repo/pulls/$number/reviews" 2>/dev/null) || return 0 + printf '%s' "$output" | + jq -r --arg user "$reviewer" ' + [.[] | select(.user.login == $user and .state != "DISMISSED" and .state != "PENDING")] + | sort_by(.submitted_at) + | last + | .commit_id // empty + ' 2>/dev/null +} +``` + +- [ ] **Step 2: Apply the edit** + +Use a heredoc + `sed` to insert above the auth-check section: + +```bash +# Locate the line number of the auth-check section marker +AUTH_LINE=$(grep -n '^# ── Auth check' ~/.config/claude-scheduled/gitea-lib.sh | head -1 | cut -d: -f1) +echo "Auth check starts at line $AUTH_LINE" + +# Write the new helper to a temp file +cat > /tmp/get_last_review_sha.snippet <<'SNIP' + +# Get the commit SHA of the most recent non-dismissed review by a specific user. +# Returns empty string if no such review exists. +# Usage: sha=$(get_last_review_sha "cal" "paper-dynasty-database" 42 "claude-reviewer") +get_last_review_sha() { + local owner="$1" repo="$2" number="$3" reviewer="$4" + local output + output=$(gitea_get "repos/$owner/$repo/pulls/$number/reviews" 2>/dev/null) || return 0 + printf '%s' "$output" | + jq -r --arg user "$reviewer" ' + [.[] | select(.user.login == $user and .state != "DISMISSED" and .state != "PENDING")] + | sort_by(.submitted_at) + | last + | .commit_id // empty + ' 2>/dev/null +} +SNIP + +# Insert the snippet before the auth check section +sed -i "${AUTH_LINE}r /tmp/get_last_review_sha.snippet" ~/.config/claude-scheduled/gitea-lib.sh +# The above puts it AFTER line AUTH_LINE — we want BEFORE, so: +# Actually `r` inserts AFTER the matched line. Re-do using the line before: +``` + +**Correction — use this instead:** + +```bash +AUTH_LINE=$(grep -n '^# ── Auth check' ~/.config/claude-scheduled/gitea-lib.sh | head -1 | cut -d: -f1) +INSERT_AT=$((AUTH_LINE - 1)) +sed -i "${INSERT_AT}r /tmp/get_last_review_sha.snippet" ~/.config/claude-scheduled/gitea-lib.sh +rm /tmp/get_last_review_sha.snippet +``` + +- [ ] **Step 3: Syntax check** + +```bash +bash -n ~/.config/claude-scheduled/gitea-lib.sh +``` + +Expected: no output. + +- [ ] **Step 4: Integration smoke test against a real PR** + +Pick any open or recently-closed PR in a tracked repo. Run: + +```bash +(export BASE_DIR=~/.config/claude-scheduled LOG_FILE=/dev/null && \ + source ~/.config/claude-scheduled/gitea-lib.sh && \ + get_last_review_sha "cal" "paper-dynasty-database" "Claude") +``` + +Expected: a 40-char SHA (if the PR has a review by `Claude`) or empty string (if it doesn't). Replace `` with a real PR number. + +- [ ] **Step 5: Negative test — non-existent reviewer** + +```bash +(export BASE_DIR=~/.config/claude-scheduled LOG_FILE=/dev/null && \ + source ~/.config/claude-scheduled/gitea-lib.sh && \ + get_last_review_sha "cal" "paper-dynasty-database" "no-such-user") +``` + +Expected: empty output (no error, just nothing printed). + +- [ ] **Step 6: Commit (if `.config/claude-scheduled` is ever version-controlled; skip if not)** + +This directory is NOT a git repo, so no commit step. Instead, verify the timestamped backup exists: + +```bash +ls -la ~/.config/claude-scheduled/gitea-lib.sh.bak.* +``` + +Expected: one or more backup files with today's date. + +### Task 7: Add `reviewing_label_age` helper to `gitea-lib.sh` + +**Files:** +- Modify: `~/.config/claude-scheduled/gitea-lib.sh` (append new helper function) + +Purpose: given an owner/repo/issue number, return the age in seconds of the most recent `ai-reviewing` label attachment. Used for the staleness timeout. Returns 0 if the label is not currently attached or the timeline is unavailable. + +- [ ] **Step 1: Draft the helper** + +```bash +# Return the age in seconds of the most recent "ai-reviewing" label attachment. +# Returns 0 if the label is not currently attached or timeline data is unavailable. +# Usage: age=$(reviewing_label_age "cal" "paper-dynasty-database" 42) +reviewing_label_age() { + local owner="$1" repo="$2" number="$3" + local output + output=$(gitea_get "repos/$owner/$repo/issues/$number/timeline?limit=50" 2>/dev/null) || { + echo 0 + return 0 + } + # Gitea timeline events: type="label" with label.name set. Find most recent + # "add" of ai-reviewing. Compute seconds since its created_at. + printf '%s' "$output" | python3 -c " +import json, sys +from datetime import datetime, timezone + +try: + events = json.load(sys.stdin) +except Exception: + print(0) + sys.exit(0) + +if not isinstance(events, list): + print(0) + sys.exit(0) + +# Filter to label-add events for ai-reviewing. Gitea reports label events +# with event.type == 'label' and a label object on the event. +# The label add/remove distinction is carried in 'label_action' or implied +# by sequence — here we walk chronologically and keep the most recent +# ADD event by timestamp comparison against the current attached state. +adds = [] +for e in events: + if e.get('type') != 'label': + continue + label = e.get('label') or {} + if label.get('name') != 'ai-reviewing': + continue + # Gitea uses 'created_at' on the event + ts = e.get('created_at') + if not ts: + continue + adds.append(ts) + +if not adds: + print(0) + sys.exit(0) + +latest = max(adds) +# Parse ISO 8601 — Gitea returns e.g. '2026-04-10T14:30:00Z' +try: + dt = datetime.fromisoformat(latest.replace('Z', '+00:00')) +except Exception: + print(0) + sys.exit(0) +now = datetime.now(timezone.utc) +print(int((now - dt).total_seconds())) +" 2>/dev/null || echo 0 +} +``` + +**Note on Gitea timeline events:** Gitea's timeline API returns label events but the shape varies by version. The snippet above assumes `type == "label"` with a `label` object on the event. If your Gitea version emits a different shape (e.g. `type == "label_added"` or a separate `events` collection), the helper returns 0 and falls back to "not stale" — safe default. If the staleness timeout doesn't fire when expected, inspect the timeline JSON directly to verify the shape. + +- [ ] **Step 2: Apply the edit** + +Same pattern as Task 6 — write to a temp snippet file, `sed -i "r /tmp/snippet"` before the auth check section. + +```bash +cat > /tmp/reviewing_label_age.snippet <<'SNIP' + +# Return the age in seconds of the most recent "ai-reviewing" label attachment. +# Returns 0 if the label is not currently attached or timeline data is unavailable. +reviewing_label_age() { + local owner="$1" repo="$2" number="$3" + local output + output=$(gitea_get "repos/$owner/$repo/issues/$number/timeline?limit=50" 2>/dev/null) || { + echo 0 + return 0 + } + printf '%s' "$output" | python3 -c " +import json, sys +from datetime import datetime, timezone +try: + events = json.load(sys.stdin) +except Exception: + print(0); sys.exit(0) +if not isinstance(events, list): + print(0); sys.exit(0) +adds = [] +for e in events: + if e.get('type') != 'label': + continue + label = e.get('label') or {} + if label.get('name') != 'ai-reviewing': + continue + ts = e.get('created_at') + if ts: + adds.append(ts) +if not adds: + print(0); sys.exit(0) +latest = max(adds) +try: + dt = datetime.fromisoformat(latest.replace('Z', '+00:00')) +except Exception: + print(0); sys.exit(0) +print(int((datetime.now(timezone.utc) - dt).total_seconds())) +" 2>/dev/null || echo 0 +} +SNIP + +AUTH_LINE=$(grep -n '^# ── Auth check' ~/.config/claude-scheduled/gitea-lib.sh | head -1 | cut -d: -f1) +INSERT_AT=$((AUTH_LINE - 1)) +sed -i "${INSERT_AT}r /tmp/reviewing_label_age.snippet" ~/.config/claude-scheduled/gitea-lib.sh +rm /tmp/reviewing_label_age.snippet +``` + +- [ ] **Step 3: Syntax check** + +```bash +bash -n ~/.config/claude-scheduled/gitea-lib.sh +``` + +Expected: no output. + +- [ ] **Step 4: Integration smoke test** + +Pick a PR that currently has the `ai-reviewing` label (or any other label — the function only checks `ai-reviewing` specifically; for a negative test pick any PR). A PR without `ai-reviewing` should return 0: + +```bash +(export BASE_DIR=~/.config/claude-scheduled LOG_FILE=/dev/null && \ + source ~/.config/claude-scheduled/gitea-lib.sh && \ + reviewing_label_age "cal" "paper-dynasty-database" ) +``` + +Expected: `0`. + +- [ ] **Step 5: Positive test — manually attach and check** + +```bash +# Attach ai-reviewing to a test PR (use one safe to poke) +TEST_PR= +TEST_REPO=cal/paper-dynasty-database +tea api -X POST "/repos/$TEST_REPO/issues/$TEST_PR/labels" -f labels=[] || true +# Use the existing add_label helper +(export BASE_DIR=~/.config/claude-scheduled LOG_FILE=/dev/null && \ + source ~/.config/claude-scheduled/gitea-lib.sh && \ + add_label "cal" "paper-dynasty-database" "$TEST_PR" "ai-reviewing" "#f39c12") + +# Wait a few seconds so the age is non-zero +sleep 5 + +# Read the age +(export BASE_DIR=~/.config/claude-scheduled LOG_FILE=/dev/null && \ + source ~/.config/claude-scheduled/gitea-lib.sh && \ + reviewing_label_age "cal" "paper-dynasty-database" "$TEST_PR") +``` + +Expected: a small integer ≥ 5. + +**Cleanup:** remove the test label after: + +```bash +(export BASE_DIR=~/.config/claude-scheduled LOG_FILE=/dev/null && \ + source ~/.config/claude-scheduled/gitea-lib.sh && \ + remove_label "cal" "paper-dynasty-database" "$TEST_PR" "ai-reviewing") +``` + +**If the age comes back as 0 despite the label being attached:** your Gitea timeline shape doesn't match the parser. Capture the raw timeline JSON for inspection: + +```bash +(export BASE_DIR=~/.config/claude-scheduled LOG_FILE=/dev/null && \ + source ~/.config/claude-scheduled/gitea-lib.sh && \ + gitea_get "repos/cal/paper-dynasty-database/issues/$TEST_PR/timeline?limit=50") \ + | jq '[.[] | select(.label.name == "ai-reviewing" or .label_name == "ai-reviewing")]' +``` + +Adjust the python parser in the helper to match the observed shape, then re-run Step 5. + +### Task 8: Replace the skip-label filter in `pr-reviewer-dispatcher.sh` + +**Files:** +- Modify: `~/.config/claude-scheduled/pr-reviewer-dispatcher.sh:170-207` (the `filtered` loop in the PR discovery section) + +Current behavior: any PR with `ai-reviewing`, `ai-reviewed`, or `ai-changes-requested` is skipped. New behavior: +- `ai-reviewing` → skip unless older than 1 hour +- `ai-reviewed` or `ai-changes-requested` → skip unless `last_reviewed_sha != current_head_sha` + +- [ ] **Step 1: Read the current filter block** + +```bash +sed -n '150,220p' ~/.config/claude-scheduled/pr-reviewer-dispatcher.sh +``` + +Identify the `filtered` loop — the inline python block that filters PRs by skip labels. This is inside a shell heredoc passed to `python3 -c`. + +- [ ] **Step 2: Take a timestamped backup** + +```bash +cp ~/.config/claude-scheduled/pr-reviewer-dispatcher.sh \ + ~/.config/claude-scheduled/pr-reviewer-dispatcher.sh.bak.$(date +%Y%m%d-%H%M%S) +``` + +- [ ] **Step 3: Replace the filter loop** + +The current inline python (roughly lines 170-207) is: + +```python +skip_labels = set('$SKIP_LABELS'.split()) +# ... +for pr in prs: + labels = set(l['name'] for l in pr.get('labels', [])) + if not labels.intersection(skip_labels): + filtered.append({...}) +``` + +Because the filter logic now requires calling Gitea (for `get_last_review_sha` and `reviewing_label_age`) and doing shell-level decisions, it's cleaner to do the filtering in bash *after* the initial python extracts all open PRs, rather than making the inline python script Gitea-aware. + +**New structure:** +1. Inline python extracts ALL open PRs (no skip-label filtering) with `head.sha`, labels, and PR number +2. Bash loop over the extracted list calls `reviewing_label_age` and `get_last_review_sha` helpers to decide skip vs include +3. Include list is used for the review loop + +Open `pr-reviewer-dispatcher.sh` in an editor and replace the extraction python block (the one that builds `filtered`) with the version below. The exact line range is around 170-207 — the whole `python3 -c "import json, sys ..."` block inside the per-repo `for REPO_NAME in $REPO_NAMES` loop. + +Replace with this block: + +```bash + # Extract ALL open PRs for this repo (filtering happens below in bash) + python3 -c " +import json, sys + +repo = '$REPO_NAME' +owner = '$OWNER' +all_prs_file = '$ALL_PRS_FILE' +raw_file = '$REPO_PRS_RAW' + +try: + with open(raw_file) as f: + prs = json.load(f) + + if not isinstance(prs, list): + sys.exit(0) + + extracted = [] + for pr in prs: + labels = [l['name'] for l in pr.get('labels', [])] + extracted.append({ + 'repo': repo, + 'owner': owner, + 'number': pr['number'], + 'title': pr.get('title', ''), + 'body': pr.get('body', ''), + 'labels': labels, + 'head_sha': (pr.get('head') or {}).get('sha', '') + }) + + if extracted: + with open(all_prs_file) as f: + existing = json.load(f) + existing.extend(extracted) + with open(all_prs_file, 'w') as f: + json.dump(existing, f) +except Exception as e: + print(f'WARNING: failed to parse PRs for {repo}: {e}', file=sys.stderr) +" 2>>"$LOG_FILE" || true +``` + +Then, **after** the `for REPO_NAME in $REPO_NAMES; do ... done` loop but **before** the `PR_COUNT=...` line, insert a bash filtering loop: + +```bash +# ── filter: re-review on SHA mismatch + staleness timeout ───────────────────── + +REVIEWER_USER=$(read_setting reviewer_username claude-reviewer) +log "applying SHA-mismatch filter (reviewer=$REVIEWER_USER)" + +FILTERED_FILE="$TMPDIR/filtered_prs.json" +echo "[]" >"$FILTERED_FILE" + +python3 -c " +import json +with open('$ALL_PRS_FILE') as f: + prs = json.load(f) +for pr in prs: + print(json.dumps(pr)) +" | while IFS= read -r pr_json; do + # Unpack fields we need + eval "$(python3 -c " +import json, shlex +pr = json.loads('''$pr_json''') +print(f'_OWNER={shlex.quote(pr[\"owner\"])}') +print(f'_REPO={shlex.quote(pr[\"repo\"])}') +print(f'_NUMBER={pr[\"number\"]}') +print(f'_HEAD={shlex.quote(pr[\"head_sha\"])}') +labels = pr.get('labels', []) +print(f'_HAS_REVIEWING={1 if \"ai-reviewing\" in labels else 0}') +print(f'_HAS_REVIEWED={1 if \"ai-reviewed\" in labels else 0}') +print(f'_HAS_CHANGES={1 if \"ai-changes-requested\" in labels else 0}') +")" + + # Skip in-flight reviews unless the label is stale (> 1 hour) + if [ "$_HAS_REVIEWING" -eq 1 ]; then + AGE=$(reviewing_label_age "$_OWNER" "$_REPO" "$_NUMBER") + if [ "${AGE:-0}" -gt 3600 ] 2>/dev/null; then + log "WARNING: stale ai-reviewing on $_REPO#$_NUMBER (${AGE}s) — force-reviewing" + else + continue + fi + fi + + # If previously reviewed, only re-review if head SHA moved + if [ "$_HAS_REVIEWED" -eq 1 ] || [ "$_HAS_CHANGES" -eq 1 ]; then + LAST_SHA=$(get_last_review_sha "$_OWNER" "$_REPO" "$_NUMBER" "$REVIEWER_USER") + if [ -n "$LAST_SHA" ] && [ "$LAST_SHA" = "$_HEAD" ]; then + continue # reviewed this exact commit already + fi + # Fall through — SHA moved, need re-review. Strip stale verdict labels first. + remove_label "$_OWNER" "$_REPO" "$_NUMBER" "ai-reviewed" || true + remove_label "$_OWNER" "$_REPO" "$_NUMBER" "ai-changes-requested" || true + log "re-reviewing $_REPO#$_NUMBER — head moved past last review" + fi + + # Append to filtered list + python3 -c " +import json +pr = json.loads('''$pr_json''') +with open('$FILTERED_FILE') as f: + existing = json.load(f) +existing.append(pr) +with open('$FILTERED_FILE', 'w') as f: + json.dump(existing, f) +" +done + +# Swap the file the downstream loop reads +mv "$FILTERED_FILE" "$ALL_PRS_FILE" +``` + +Place this new block **immediately after** the `for REPO_NAME in $REPO_NAMES; do ... done` loop (after its closing `done`). + +- [ ] **Step 4: Remove the now-unused `SKIP_LABELS` variable reference** + +Earlier in the dispatcher (around line 112): +```bash +SKIP_LABELS="$LABEL_AI_REVIEWED $LABEL_AI_CHANGES $LABEL_AI_REVIEWING" +``` + +This is no longer used by the extraction python but may still be referenced elsewhere. Search for usages: + +```bash +grep -n 'SKIP_LABELS' ~/.config/claude-scheduled/pr-reviewer-dispatcher.sh +``` + +If the only remaining reference is the declaration itself, delete the declaration. If other code still references it, leave it alone. + +- [ ] **Step 5: Add a default for `reviewer_username` setting** + +The new filter loop reads `REVIEWER_USER=$(read_setting reviewer_username claude-reviewer)`. Add the setting to the task's `settings.json`: + +```bash +SETTINGS=~/.config/claude-scheduled/tasks/pr-reviewer/settings.json +python3 -c " +import json +with open('$SETTINGS') as f: + s = json.load(f) +s.setdefault('reviewer_username', 'claude-reviewer') +with open('$SETTINGS', 'w') as f: + json.dump(s, f, indent=2) +" +cat "$SETTINGS" | jq .reviewer_username +``` + +Expected: `"claude-reviewer"`. + +- [ ] **Step 6: Syntax check** + +```bash +bash -n ~/.config/claude-scheduled/pr-reviewer-dispatcher.sh +``` + +Expected: no output. + +- [ ] **Step 7: Shellcheck (optional, if installed)** + +```bash +command -v shellcheck && shellcheck ~/.config/claude-scheduled/pr-reviewer-dispatcher.sh || echo "shellcheck not installed, skipping" +``` + +Warnings about the inline python here-strings are expected and fine. Actual errors should be addressed. + +### Task 9: Swap `pr-reviewer-dispatcher.sh` to the new secrets file + +**Files:** +- Modify: `~/.config/claude-scheduled/pr-reviewer-dispatcher.sh` (add `export SECRETS_FILE=...` before sourcing `gitea-lib.sh`) + +- [ ] **Step 1: Locate the `source gitea-lib.sh` line** + +```bash +grep -n 'gitea-lib.sh' ~/.config/claude-scheduled/pr-reviewer-dispatcher.sh +``` + +Expected: line ~110 `source "$(dirname "$0")/gitea-lib.sh"`. + +- [ ] **Step 2: Insert the export one line before** + +```bash +sed -i '/^source "$(dirname "$0")\/gitea-lib.sh"$/i export SECRETS_FILE="$BASE_DIR/secrets-claude-reviewer"' \ + ~/.config/claude-scheduled/pr-reviewer-dispatcher.sh +``` + +- [ ] **Step 3: Verify** + +```bash +grep -n -B1 -A1 'gitea-lib.sh' ~/.config/claude-scheduled/pr-reviewer-dispatcher.sh +``` + +Expected: the export line appears directly above the source line. + +- [ ] **Step 4: Syntax check** + +```bash +bash -n ~/.config/claude-scheduled/pr-reviewer-dispatcher.sh +``` + +Expected: no output. + +- [ ] **Step 5: Verify issue-dispatcher is untouched** + +```bash +grep -n 'SECRETS_FILE' ~/.config/claude-scheduled/issue-dispatcher.sh +``` + +Expected: no output. Issue-dispatcher should NOT have `SECRETS_FILE` set, so it continues to use the default `secrets` file (the `Claude` token). + +### Task 10: Dry-run the updated dispatcher + +**Files:** None + +- [ ] **Step 1: Run in dry-run mode** + +```bash +~/.config/claude-scheduled/pr-reviewer-dispatcher.sh --dry-run 2>&1 | tee /tmp/dispatcher-dry-run.log +``` + +Expected in the output: +- `auth check passed` — confirms the claude-reviewer token loaded correctly +- `applying SHA-mismatch filter (reviewer=claude-reviewer)` — confirms the new filter ran +- A list of PRs that would be reviewed +- No Python tracebacks, no "ERROR" lines in the body of the run +- `processed=` at the end + +- [ ] **Step 2: Verify auth was via claude-reviewer, not Claude** + +The dispatcher doesn't log the authenticated user explicitly, but you can verify by looking at which PRs it queued. Cross-check against a live API call: + +```bash +(export BASE_DIR=~/.config/claude-scheduled LOG_FILE=/dev/null \ + SECRETS_FILE=~/.config/claude-scheduled/secrets-claude-reviewer && \ + source ~/.config/claude-scheduled/gitea-lib.sh && \ + curl -sf -H "Authorization: token $GITEA_ACCESS_TOKEN" \ + https://git.manticorum.com/api/v1/user | jq -r '.login') +``` + +Expected: `claude-reviewer`. + +- [ ] **Step 3: Verify the SHA-mismatch filter works on known PRs** + +Cross-reference: a PR that currently has `ai-reviewed` and whose head SHA has NOT moved since the last review should NOT appear in the dry-run queue. A PR with `ai-reviewed` whose head HAS moved should appear. + +Find a candidate: + +```bash +tea api "/repos/cal/paper-dynasty-database/pulls?state=open&limit=50" \ + | jq '[.[] | select(any(.labels[]; .name == "ai-reviewed"))] | .[] | {number, head_sha: .head.sha}' +``` + +For each such PR, verify its presence (or absence) in `/tmp/dispatcher-dry-run.log` against whether the head matches the last review SHA. + +- [ ] **Step 4: If anything looks wrong, rollback** + +Rollback is to restore the backup and re-point at the old secrets: + +```bash +# Find the most recent backups +ls -t ~/.config/claude-scheduled/gitea-lib.sh.bak.* ~/.config/claude-scheduled/pr-reviewer-dispatcher.sh.bak.* + +# Restore (replace timestamps with the correct ones) +cp ~/.config/claude-scheduled/gitea-lib.sh.bak. ~/.config/claude-scheduled/gitea-lib.sh +cp ~/.config/claude-scheduled/pr-reviewer-dispatcher.sh.bak. ~/.config/claude-scheduled/pr-reviewer-dispatcher.sh + +# Dispatcher is back to using the Claude token via the default secrets file +``` + +### Task 11: Production run + observation window + +**Files:** None + +- [ ] **Step 1: Let the next scheduled run fire naturally** + +Check the systemd timer: + +```bash +systemctl --user list-timers | grep -i pr-reviewer +``` + +Expected: shows the next firing time. + +- [ ] **Step 2: Tail the log during the run** + +```bash +tail -f ~/.local/share/claude-scheduled/logs/pr-reviewer/$(ls -t ~/.local/share/claude-scheduled/logs/pr-reviewer/ | head -1) +``` + +Watch one full cycle. Success criteria: +- `auth check passed` +- `applying SHA-mismatch filter (reviewer=claude-reviewer)` +- One or more PRs reviewed +- Discord notification received (if webhook configured) +- `result: status=success verdict= ...` per PR + +- [ ] **Step 3: Verify the review appears under `claude-reviewer` in Gitea** + +Open one of the reviewed PRs in Gitea. The review timeline should show a new entry by `Claude Reviewer` (not `Claude`). The verdict should be `APPROVED` or `REQUEST_CHANGES` — NOT the "self-review restriction applies" COMMENT fallback from before. + +If you still see the COMMENT fallback, either: +- The agent prompt still has self-review disclaimer logic baked in (it shouldn't — the agent doesn't know about self-review) +- The token is still for the `Claude` user (verify with Step 2 of Task 10) + +- [ ] **Step 4: Phase 2 checkpoint** + +Phase 2 is complete when: +- At least one full production run has fired under the new token +- At least one APPROVED review has landed from `claude-reviewer` on a real PR +- No Python tracebacks or ERROR lines in the logs over 24 hours of normal operation + +**Do not proceed to Phase 3 until this checkpoint passes.** Phase 3 tightens branch protection, which depends on the reviewer actually working. + +--- + +## Phase 3: Branch Protection + +**Goal:** Enforce that nothing lands on main without an APPROVED review from `claude-reviewer`. Start with `claude-home` as the canary, then roll out. + +### Task 12: Write `dump-branch-protection.sh` + +**Files:** +- Create: `~/.config/claude-scheduled/scripts/dump-branch-protection.sh` + +Purpose: dump current branch protection JSON for every tracked repo into a timestamped backup dir, so rollback is one `tea api -X PATCH` away. + +- [ ] **Step 1: Create scripts dir** + +```bash +mkdir -p ~/.config/claude-scheduled/scripts +``` + +- [ ] **Step 2: Write the script** + +```bash +cat > ~/.config/claude-scheduled/scripts/dump-branch-protection.sh <<'SCRIPT' +#!/bin/bash +# Dump branch protection JSON for every tracked repo. +# Output: .claude/tmp/branch-protection-backup/-.json +# +# Usage: dump-branch-protection.sh +# +# Requires: tea CLI configured with admin access + +set -euo pipefail + +REPOS_FILE="$HOME/.config/claude-scheduled/tasks/pr-reviewer/repos.json" +BACKUP_DIR="/mnt/NV2/Development/claude-home/.claude/tmp/branch-protection-backup" +STAMP=$(date +%Y%m%d-%H%M%S) + +mkdir -p "$BACKUP_DIR/$STAMP" + +python3 -c " +import json +with open('$REPOS_FILE') as f: + repos = json.load(f) +for name, cfg in repos.items(): + owner = cfg.get('owner', 'cal') + print(f'{owner}/{name}') +" | while read slug; do + owner="${slug%/*}" + repo="${slug#*/}" + outfile="$BACKUP_DIR/$STAMP/${owner}-${repo}.json" + + if tea api "/repos/$slug/branch_protections" >"$outfile" 2>/dev/null; then + count=$(jq 'length' "$outfile") + echo "$slug → $count protection(s) dumped" + else + echo "$slug → FAILED to dump (may not have any protections yet)" + echo "[]" >"$outfile" + fi +done + +echo "" +echo "Backup written to: $BACKUP_DIR/$STAMP/" +ls -la "$BACKUP_DIR/$STAMP/" +SCRIPT +chmod +x ~/.config/claude-scheduled/scripts/dump-branch-protection.sh +``` + +- [ ] **Step 3: Syntax check** + +```bash +bash -n ~/.config/claude-scheduled/scripts/dump-branch-protection.sh +``` + +Expected: no output. + +### Task 13: Back up existing branch protections + +**Files:** +- Create: `.claude/tmp/branch-protection-backup//-.json` (per tracked repo) + +- [ ] **Step 1: Run the dump script** + +```bash +~/.config/claude-scheduled/scripts/dump-branch-protection.sh +``` + +Expected output: one line per tracked repo, ending with `Backup written to: /mnt/NV2/Development/claude-home/.claude/tmp/branch-protection-backup//` and a directory listing. + +- [ ] **Step 2: Inspect one of the dumps to confirm format** + +```bash +jq '.' /mnt/NV2/Development/claude-home/.claude/tmp/branch-protection-backup/*/cal-claude-home.json | head -40 +``` + +Expected: JSON object (or array) describing current protection rules. If empty `[]`, the repo has no protection today — also a valid baseline. + +### Task 14: Write `apply-branch-protection.sh` + +**Files:** +- Create: `~/.config/claude-scheduled/scripts/apply-branch-protection.sh` + +Purpose: idempotently apply the tightened protection block to one or more tracked repos. + +- [ ] **Step 1: Draft the script** + +```bash +cat > ~/.config/claude-scheduled/scripts/apply-branch-protection.sh <<'SCRIPT' +#!/bin/bash +# Apply tightened branch protection to tracked repos. +# +# Usage: +# apply-branch-protection.sh Apply to one repo +# apply-branch-protection.sh --all Apply to every tracked repo +# apply-branch-protection.sh --dry-run Print payload without applying +# +# Idempotent: re-running is safe. If a protection for 'main' already exists, +# it is updated (PATCH); otherwise it is created (POST). + +set -euo pipefail + +REPOS_FILE="$HOME/.config/claude-scheduled/tasks/pr-reviewer/repos.json" +DRY_RUN=0 + +if [ "${1:-}" = "--dry-run" ]; then + DRY_RUN=1 + shift +fi + +TARGET="${1:-}" +if [ -z "$TARGET" ]; then + echo "Usage: $0 [--dry-run] " >&2 + exit 1 +fi + +# Payload — Gitea branch protection schema. +# Key fields: +# required_approvals: 1 +# enable_approvals_whitelist: true (restricts which users' approvals count) +# approvals_whitelist_username: ["claude-reviewer"] +# dismiss_stale_approvals: true +# block_on_rejected_reviews: true (REQUEST_CHANGES actively blocks merge) +# enable_push: true (admin push allowed — cal is admin) +# enable_push_whitelist: true +# push_whitelist_usernames: ["cal"] (admin override escape hatch) +# enable_merge_whitelist: true +# merge_whitelist_usernames: ["cal", "Claude"] (cal + Claude can merge) +PAYLOAD=$(cat <<'JSON' +{ + "branch_name": "main", + "rule_name": "main", + "enable_push": true, + "enable_push_whitelist": true, + "push_whitelist_usernames": ["cal"], + "push_whitelist_deploy_keys": false, + "enable_merge_whitelist": true, + "merge_whitelist_usernames": ["cal", "Claude"], + "required_approvals": 1, + "enable_approvals_whitelist": true, + "approvals_whitelist_username": ["claude-reviewer"], + "dismiss_stale_approvals": true, + "ignore_stale_approvals": false, + "block_on_rejected_reviews": true, + "block_on_outdated_branch": false, + "require_signed_commits": false, + "protected_file_patterns": "", + "unprotected_file_patterns": "" +} +JSON +) + +apply_to_repo() { + local slug="$1" + + if [ "$DRY_RUN" -eq 1 ]; then + echo "→ $slug DRY-RUN payload:" + echo "$PAYLOAD" | jq . + return 0 + fi + + # Check if a protection for main already exists + local existing + existing=$(tea api "/repos/$slug/branch_protections/main" 2>/dev/null || echo "") + + if [ -n "$existing" ] && echo "$existing" | jq -e '.branch_name // .rule_name' >/dev/null 2>&1; then + # Update existing + echo "→ $slug UPDATE existing protection" + echo "$PAYLOAD" | tea api -X PATCH "/repos/$slug/branch_protections/main" --input - \ + >/dev/null && echo " OK" || echo " FAILED" + else + # Create new + echo "→ $slug CREATE new protection" + echo "$PAYLOAD" | tea api -X POST "/repos/$slug/branch_protections" --input - \ + >/dev/null && echo " OK" || echo " FAILED" + fi +} + +if [ "$TARGET" = "--all" ]; then + python3 -c " +import json +with open('$REPOS_FILE') as f: + repos = json.load(f) +for name, cfg in repos.items(): + owner = cfg.get('owner', 'cal') + print(f'{owner}/{name}') +" | while read slug; do + apply_to_repo "$slug" + done +else + apply_to_repo "$TARGET" +fi +SCRIPT +chmod +x ~/.config/claude-scheduled/scripts/apply-branch-protection.sh +``` + +- [ ] **Step 2: Syntax check** + +```bash +bash -n ~/.config/claude-scheduled/scripts/apply-branch-protection.sh +``` + +Expected: no output. + +**Note on `tea api --input -`:** depending on your `tea` version, the flag for reading a JSON body from stdin may be different (`--data @-`, `--input -`, or piping through a `-F` field list). If `tea api` chokes on the `--input -` flag, fall back to a direct curl call: + +```bash +curl -sf -X PATCH \ + -H "Authorization: token $(awk -F= '/GITEA_ACCESS_TOKEN/{gsub(/["]/,"",$2); print $2}' ~/.config/claude-scheduled/secrets)" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + "https://git.manticorum.com/api/v1/repos/$slug/branch_protections/main" +``` + +Use your admin token (from `~/.config/claude-scheduled/secrets` — the `Claude` token — OR your personal `tea` config) depending on which account has admin on the repo. + +### Task 15: Apply tightened protection to `claude-home` (canary) + +**Files:** None (remote Gitea state change) + +- [ ] **Step 1: Dry-run first** + +```bash +~/.config/claude-scheduled/scripts/apply-branch-protection.sh --dry-run cal/claude-home +``` + +Expected: prints the JSON payload with `required_approvals: 1` and the whitelists. + +- [ ] **Step 2: Apply** + +```bash +~/.config/claude-scheduled/scripts/apply-branch-protection.sh cal/claude-home +``` + +Expected: `→ cal/claude-home UPDATE existing protection` or `→ cal/claude-home CREATE new protection`, followed by `OK`. + +- [ ] **Step 3: Verify the applied state via API** + +```bash +tea api "/repos/cal/claude-home/branch_protections/main" | jq '{ + required_approvals, + approvals_whitelist_username, + dismiss_stale_approvals, + block_on_rejected_reviews, + push_whitelist_usernames, + merge_whitelist_usernames +}' +``` + +Expected: +```json +{ + "required_approvals": 1, + "approvals_whitelist_username": ["claude-reviewer"], + "dismiss_stale_approvals": true, + "block_on_rejected_reviews": true, + "push_whitelist_usernames": ["cal"], + "merge_whitelist_usernames": ["cal", "Claude"] +} +``` + +- [ ] **Step 4: End-to-end flow test — hand-authored PR through the new gate** + +1. In a clone of `claude-home`, create a branch and make a trivial doc change (e.g. add a line to a test file in a `.claude/tmp/` test location or a kb doc): + +```bash +cd /mnt/NV2/Development/claude-home +git checkout -b test/phase3-canary main +echo "canary test — safe to delete" > .claude/tmp/canary-phase3.md +git add .claude/tmp/canary-phase3.md +git commit -m "test: phase 3 branch protection canary" +git push -u origin test/phase3-canary +``` + +2. Open a PR in Gitea UI from `test/phase3-canary` → `main`. + +3. Attempt to merge immediately. Expected: merge button disabled with a message like "Required approvals: 0/1" or equivalent. + +4. Wait for the next `pr-reviewer-dispatcher` tick (or invoke manually with `~/.config/claude-scheduled/pr-reviewer-dispatcher.sh --max-prs 1`). + +5. Confirm `claude-reviewer` posts an APPROVED review (or REQUEST_CHANGES if it finds an issue — in which case your test needs to be cleaner; retry). + +6. Merge button should now be enabled. Merge the PR. + +7. Delete the branch and clean up the test file. + +- [ ] **Step 5: End-to-end flow test — re-review on push** + +1. Create another test branch: + +```bash +cd /mnt/NV2/Development/claude-home +git checkout -b test/phase3-rereview main +echo "line 1" > .claude/tmp/canary-rereview.md +git add .claude/tmp/canary-rereview.md +git commit -m "test: phase 3 rereview initial" +git push -u origin test/phase3-rereview +``` + +2. Open PR. Wait for dispatcher to review and APPROVE. + +3. Push a fixup commit WITHOUT merging: + +```bash +echo "line 2" >> .claude/tmp/canary-rereview.md +git add .claude/tmp/canary-rereview.md +git commit -m "test: fixup after approval" +git push +``` + +4. Verify in Gitea UI that the previous approval is now **dismissed** (this is `dismiss_stale_approvals: true` firing). + +5. Wait for the next dispatcher tick. Expected: the dispatcher re-reviews this PR because `last_reviewed_sha != head.sha`. Confirm a new review appears from `claude-reviewer` against the new head SHA. + +6. Merge, clean up. + +- [ ] **Step 6: Canary observation window** + +Live with the new protection on `claude-home` for at least 24 hours of normal use. Watch for: +- Any hand-authored PR that the dispatcher fails to review (check dispatcher logs for errors) +- Any merge attempt that is unexpectedly blocked +- Any stale-approval dismissals that don't correspond to real commit pushes (would indicate a Gitea bug, not our problem) + +If anything misbehaves, rollback: + +```bash +# Restore from the backup taken in Task 13 +BACKUP_DIR=/mnt/NV2/Development/claude-home/.claude/tmp/branch-protection-backup +LATEST=$(ls -1t $BACKUP_DIR | head -1) +cat "$BACKUP_DIR/$LATEST/cal-claude-home.json" | jq '.[0] // empty' \ + | tea api -X PATCH "/repos/cal/claude-home/branch_protections/main" --input - +``` + +Or, simpler rollback, just relax the approval count: + +```bash +echo '{"required_approvals": 0}' | tea api -X PATCH "/repos/cal/claude-home/branch_protections/main" --input - +``` + +### Task 16: Roll out tightened protection to remaining tracked repos + +**Files:** None + +- [ ] **Step 1: Only proceed if the canary window was clean** + +Do not proceed if Task 15 Step 6 surfaced issues. + +- [ ] **Step 2: Apply to all tracked repos** + +```bash +~/.config/claude-scheduled/scripts/apply-branch-protection.sh --all +``` + +Expected: one `OK` line per repo. + +- [ ] **Step 3: Verify each repo** + +```bash +python3 -c " +import json +with open('$HOME/.config/claude-scheduled/tasks/pr-reviewer/repos.json') as f: + repos = json.load(f) +for name, cfg in repos.items(): + print(f'{cfg.get(\"owner\", \"cal\")}/{name}') +" | while read slug; do + approvals=$(tea api "/repos/$slug/branch_protections/main" | jq -r '.required_approvals // 0') + whitelist=$(tea api "/repos/$slug/branch_protections/main" | jq -r '.approvals_whitelist_username // [] | join(",")') + echo "$slug → required=$approvals whitelist=[$whitelist]" +done +``` + +Expected: every line shows `required=1 whitelist=[claude-reviewer]`. + +- [ ] **Step 4: Phase 3 checkpoint** + +All tracked repos enforce the gate. Rollback script is available if needed. Safe to proceed to Phase 4. + +--- + +## Phase 4: Deploy Audit & Pre-Push Hook + +**Goal:** Block CalVer tag pushes when the commit range contains any unreviewed PR or unreviewed direct push. + +### Task 17: Write `deploy_audit_core.py` with TDD + +**Files:** +- Create: `~/.config/claude-scheduled/deploy_audit_core.py` +- Create: `~/.config/claude-scheduled/tests/test_deploy_audit_core.py` + +Pure-python core logic, no filesystem or network calls. Unit tested. The bash wrapper (Task 19) provides the inputs (git log output, Gitea API responses) and displays the output. + +- [ ] **Step 1: Create test directory and empty files** + +```bash +mkdir -p ~/.config/claude-scheduled/tests +touch ~/.config/claude-scheduled/deploy_audit_core.py +touch ~/.config/claude-scheduled/tests/__init__.py +touch ~/.config/claude-scheduled/tests/test_deploy_audit_core.py +``` + +- [ ] **Step 2: Write the first failing test — audit_pr_review happy path** + +```python +# ~/.config/claude-scheduled/tests/test_deploy_audit_core.py +""" +Unit tests for deploy_audit_core. + +The core module is pure python: all Gitea responses and git outputs are +injected as parameters so the tests don't hit the network or a real repo. +That makes the audit logic fast and deterministic to test. +""" + +import sys +from pathlib import Path + +# Allow importing the module from the parent directory +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import deploy_audit_core as core + + +def test_audit_pr_review_clean_approval(): + """ + A PR with a single APPROVED review by the expected reviewer, where + the review's commit_id is the last commit in the PR's commit list, + should pass the audit. + """ + reviews = [ + {"user": {"login": "claude-reviewer"}, "state": "APPROVED", "commit_id": "abc123", "submitted_at": "2026-04-10T10:00:00Z"}, + ] + pr_commits = [ + {"sha": "aaa000"}, + {"sha": "abc123"}, # head — this is what got merged + ] + result = core.audit_pr_review(reviews, pr_commits, reviewer="claude-reviewer") + assert result.passed, f"expected pass, got: {result.reason}" + assert result.state == "APPROVED" + assert result.reviewed_sha == "abc123" +``` + +- [ ] **Step 3: Run the test and see it fail** + +```bash +cd ~/.config/claude-scheduled +python3 -m pytest tests/test_deploy_audit_core.py -v +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'deploy_audit_core'` or `AttributeError: module 'deploy_audit_core' has no attribute 'audit_pr_review'`. + +If pytest is not installed: `pip install --user pytest`. + +- [ ] **Step 4: Write the minimal implementation** + +```python +# ~/.config/claude-scheduled/deploy_audit_core.py +""" +deploy_audit_core — pure python logic for the deploy-audit tool. + +Given a list of commits to be deployed and a map of their associated PR +data, decide whether every commit in the range was properly reviewed by +the expected reviewer. No filesystem or network access — the bash +wrapper injects all inputs. + +The "clean approval" check requires both: + 1. State is APPROVED (not REQUEST_CHANGES, COMMENT, or DISMISSED) + 2. The review's commit_id is the last commit in the PR's commit list, + meaning the reviewer saw the final pre-merge state. Anything earlier + means the author pushed after approval and the review is stale. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class ReviewAuditResult: + passed: bool + state: Optional[str] + reviewed_sha: Optional[str] + reason: str + + +def audit_pr_review(reviews: list, pr_commits: list, reviewer: str) -> ReviewAuditResult: + """ + Determine whether a PR was cleanly approved by the expected reviewer. + + A clean approval requires: + - Most recent non-dismissed review by `reviewer` has state == "APPROVED" + - That review's commit_id equals the sha of the last commit in pr_commits + (meaning the reviewer saw the final pre-merge state) + + Args: + reviews: list of review objects as returned by Gitea's + /repos/{owner}/{repo}/pulls/{idx}/reviews endpoint + pr_commits: list of commit objects as returned by Gitea's + /repos/{owner}/{repo}/pulls/{idx}/commits endpoint (in order, + last element is the PR head) + reviewer: username expected to have approved + + Returns: + ReviewAuditResult with passed=True iff clean approval found. + """ + # Filter to non-dismissed reviews by the expected reviewer + candidates = [ + r for r in reviews + if (r.get("user") or {}).get("login") == reviewer + and r.get("state") not in ("DISMISSED", "PENDING") + ] + + if not candidates: + return ReviewAuditResult( + passed=False, + state=None, + reviewed_sha=None, + reason=f"no review by {reviewer}", + ) + + # Most recent by submitted_at + latest = max(candidates, key=lambda r: r.get("submitted_at") or "") + state = latest.get("state") + reviewed_sha = latest.get("commit_id") + + if state != "APPROVED": + return ReviewAuditResult( + passed=False, + state=state, + reviewed_sha=reviewed_sha, + reason=f"latest review state is {state}, not APPROVED", + ) + + # Verify the review SHA is the last commit in the PR's commit list + if not pr_commits: + return ReviewAuditResult( + passed=False, + state=state, + reviewed_sha=reviewed_sha, + reason="PR commit list empty — cannot verify reviewed SHA", + ) + + final_sha = pr_commits[-1].get("sha") + if reviewed_sha != final_sha: + return ReviewAuditResult( + passed=False, + state=state, + reviewed_sha=reviewed_sha, + reason=f"reviewed {reviewed_sha[:7]}, but PR head is {final_sha[:7]} (pushed after approval)", + ) + + return ReviewAuditResult( + passed=True, + state=state, + reviewed_sha=reviewed_sha, + reason="clean approval", + ) +``` + +- [ ] **Step 5: Run the test and see it pass** + +```bash +cd ~/.config/claude-scheduled +python3 -m pytest tests/test_deploy_audit_core.py -v +``` + +Expected: `test_audit_pr_review_clean_approval PASSED`. + +- [ ] **Step 6: Add failing tests for the other branches** + +Append to `test_deploy_audit_core.py`: + +```python +def test_audit_pr_review_no_review(): + """ + A PR with no reviews by the expected reviewer fails the audit. + Reviews by other users are ignored. + """ + reviews = [ + {"user": {"login": "other-user"}, "state": "APPROVED", "commit_id": "abc123", "submitted_at": "2026-04-10T10:00:00Z"}, + ] + pr_commits = [{"sha": "abc123"}] + result = core.audit_pr_review(reviews, pr_commits, reviewer="claude-reviewer") + assert not result.passed + assert "no review by claude-reviewer" in result.reason + + +def test_audit_pr_review_request_changes(): + """ + A PR where the latest review is REQUEST_CHANGES fails the audit, + even if an older APPROVED review exists. + """ + reviews = [ + {"user": {"login": "claude-reviewer"}, "state": "APPROVED", "commit_id": "abc123", "submitted_at": "2026-04-10T10:00:00Z"}, + {"user": {"login": "claude-reviewer"}, "state": "REQUEST_CHANGES", "commit_id": "def456", "submitted_at": "2026-04-10T11:00:00Z"}, + ] + pr_commits = [{"sha": "abc123"}, {"sha": "def456"}] + result = core.audit_pr_review(reviews, pr_commits, reviewer="claude-reviewer") + assert not result.passed + assert result.state == "REQUEST_CHANGES" + + +def test_audit_pr_review_stale_approval(): + """ + A PR where claude-reviewer approved commit X but the author then + pushed commit Y (making Y the PR head) fails the audit — the review + is stale. This is the case dismiss_stale_approvals is supposed to + catch at Gitea layer; the audit is the defense-in-depth backstop. + """ + reviews = [ + {"user": {"login": "claude-reviewer"}, "state": "APPROVED", "commit_id": "abc123", "submitted_at": "2026-04-10T10:00:00Z"}, + ] + pr_commits = [ + {"sha": "abc123"}, # reviewed here + {"sha": "def456"}, # but then author pushed this + ] + result = core.audit_pr_review(reviews, pr_commits, reviewer="claude-reviewer") + assert not result.passed + assert "pushed after approval" in result.reason + + +def test_audit_pr_review_dismissed_review_ignored(): + """ + Dismissed reviews do not count. If the only review is dismissed, + the PR is treated as if it had no review. + """ + reviews = [ + {"user": {"login": "claude-reviewer"}, "state": "DISMISSED", "commit_id": "abc123", "submitted_at": "2026-04-10T10:00:00Z"}, + ] + pr_commits = [{"sha": "abc123"}] + result = core.audit_pr_review(reviews, pr_commits, reviewer="claude-reviewer") + assert not result.passed + assert "no review by claude-reviewer" in result.reason +``` + +- [ ] **Step 7: Run all tests — expect all to pass** + +```bash +cd ~/.config/claude-scheduled +python3 -m pytest tests/test_deploy_audit_core.py -v +``` + +Expected: 5 passed. + +If any fail, fix the implementation in `deploy_audit_core.py` until all pass. + +### Task 18: Add range resolution and reporting to `deploy_audit_core.py` with TDD + +**Files:** +- Modify: `~/.config/claude-scheduled/deploy_audit_core.py` +- Modify: `~/.config/claude-scheduled/tests/test_deploy_audit_core.py` + +- [ ] **Step 1: Write failing tests for `build_audit_report`** + +Append to the test file: + +```python +def test_build_audit_report_all_clean(): + """ + A report where every PR in range has a clean approval should pass + and produce a human-readable summary. + """ + pr_results = [ + { + "number": 208, + "title": "Add XYZ column", + "audit": core.ReviewAuditResult(passed=True, state="APPROVED", reviewed_sha="a1b2c3d", reason="clean"), + }, + { + "number": 209, + "title": "Fix null handling", + "audit": core.ReviewAuditResult(passed=True, state="APPROVED", reviewed_sha="e4f5g6h", reason="clean"), + }, + ] + direct_pushes = [] + report = core.build_audit_report( + slug="cal/paper-dynasty-database", + from_ref="2026.4.10", + to_ref="HEAD", + commit_count=5, + pr_results=pr_results, + direct_pushes=direct_pushes, + ) + assert report.passed + assert "PASS" in report.text + assert "PR #208" in report.text + assert "PR #209" in report.text + + +def test_build_audit_report_blocked_pr_fails(): + """ + A report with at least one PR that did not cleanly approve should + fail overall and surface the specific blocked PR in the output. + """ + pr_results = [ + { + "number": 208, + "title": "Add XYZ column", + "audit": core.ReviewAuditResult(passed=True, state="APPROVED", reviewed_sha="a1b2c3d", reason="clean"), + }, + { + "number": 210, + "title": "Rework scouting query", + "audit": core.ReviewAuditResult(passed=False, state="REQUEST_CHANGES", reviewed_sha=None, reason="latest review state is REQUEST_CHANGES"), + }, + ] + report = core.build_audit_report( + slug="cal/paper-dynasty-database", + from_ref="2026.4.10", + to_ref="HEAD", + commit_count=5, + pr_results=pr_results, + direct_pushes=[], + ) + assert not report.passed + assert "FAIL" in report.text + assert "PR #210" in report.text + assert "BLOCKED" in report.text + + +def test_build_audit_report_direct_push_fails(): + """ + An unreviewed direct push in the range should fail the audit and + be flagged as UNREVIEWED in the output. + """ + direct_pushes = [ + {"sha": "9z8y7x6", "subject": "quick typo fix on README"}, + ] + report = core.build_audit_report( + slug="cal/paper-dynasty-database", + from_ref="2026.4.10", + to_ref="HEAD", + commit_count=3, + pr_results=[], + direct_pushes=direct_pushes, + ) + assert not report.passed + assert "UNREVIEWED" in report.text + assert "9z8y7x6" in report.text +``` + +- [ ] **Step 2: Run tests, see them fail** + +```bash +cd ~/.config/claude-scheduled +python3 -m pytest tests/test_deploy_audit_core.py -v +``` + +Expected: 3 failures for the new tests (AttributeError on `build_audit_report` or `AuditReport`). + +- [ ] **Step 3: Implement `build_audit_report`** + +Append to `deploy_audit_core.py`: + +```python +@dataclass +class AuditReport: + passed: bool + text: str + + +def build_audit_report( + slug: str, + from_ref: str, + to_ref: str, + commit_count: int, + pr_results: list, + direct_pushes: list, +) -> AuditReport: + """ + Build a human-readable tabular audit report. + + Args: + slug: owner/repo identifier, e.g. "cal/paper-dynasty-database" + from_ref: starting ref of the range (e.g. previous tag) + to_ref: ending ref of the range (e.g. HEAD or new tag) + commit_count: total number of commits in the range + pr_results: list of dicts with keys: number, title, audit (ReviewAuditResult) + direct_pushes: list of dicts with keys: sha, subject + + Returns: + AuditReport(passed=bool, text=formatted_string) + """ + lines = [] + lines.append(f"Repo: {slug}") + lines.append(f"Range: {from_ref} → {to_ref} ({commit_count} commits, {len(pr_results)} PRs)") + lines.append("") + + all_passed = True + + for pr in pr_results: + number = pr["number"] + title = pr["title"] + audit = pr["audit"] + if audit.passed: + mark = "✓" + status = f"APPROVED at {(audit.reviewed_sha or '')[:7]}" + else: + mark = "✗ BLOCKED" + status = audit.reason + all_passed = False + lines.append(f"PR #{number} {title[:30]:30} {status} {mark}") + + for push in direct_pushes: + sha = push["sha"][:7] + subject = push.get("subject", "") + lines.append(f"Direct push: {sha} \"{subject}\" ⚠ UNREVIEWED") + all_passed = False + + lines.append("") + if all_passed: + lines.append("Audit: PASS") + else: + bad_prs = sum(1 for p in pr_results if not p["audit"].passed) + bad_pushes = len(direct_pushes) + parts = [] + if bad_prs: + parts.append(f"{bad_prs} PR(s) not cleanly approved") + if bad_pushes: + parts.append(f"{bad_pushes} unreviewed direct push(es)") + lines.append(f"Audit: FAIL ({', '.join(parts)})") + + return AuditReport(passed=all_passed, text="\n".join(lines)) +``` + +- [ ] **Step 4: Run tests, see them pass** + +```bash +python3 -m pytest tests/test_deploy_audit_core.py -v +``` + +Expected: 8 passed (5 existing + 3 new). + +### Task 19: Write the `deploy-audit` bash wrapper + +**Files:** +- Create: `~/.config/claude-scheduled/deploy-audit` + +This script is the user-facing entry point. It handles arg parsing, git log calls, Gitea API calls, and delegates pass/fail decisions to `deploy_audit_core`. + +- [ ] **Step 1: Write the wrapper** + +```bash +cat > ~/.config/claude-scheduled/deploy-audit <<'SCRIPT' +#!/bin/bash +# deploy-audit — verify every PR in a commit range has a clean approval +# from claude-reviewer before allowing a deploy tag to be pushed. +# +# Usage: +# deploy-audit [--from ] [--to ] [--tag ] +# +# Exit codes: +# 0 All PRs in range cleanly approved +# 1 One or more PRs lack clean approval, or unreviewed direct pushes +# 2 Tooling error (Gitea unreachable, git error, etc.) + +set -euo pipefail + +SLUG="" +FROM_REF="" +TO_REF="HEAD" +NEW_TAG="" +REPOS_FILE="$HOME/.config/claude-scheduled/tasks/pr-reviewer/repos.json" +REVIEWER="claude-reviewer" + +while [[ $# -gt 0 ]]; do + case "$1" in + --from) FROM_REF="$2"; shift 2 ;; + --to) TO_REF="$2"; shift 2 ;; + --tag) NEW_TAG="$2"; shift 2 ;; + --help|-h) + sed -n '3,15p' "$0" | sed 's|^# \?||' + exit 0 + ;; + *) + if [ -z "$SLUG" ]; then + SLUG="$1" + else + echo "Unexpected arg: $1" >&2 + exit 2 + fi + shift + ;; + esac +done + +if [ -z "$SLUG" ]; then + echo "Usage: deploy-audit [--from ] [--to ] [--tag ]" >&2 + exit 2 +fi + +# Short-circuit for non-tracked repos +if ! jq -e --arg repo "${SLUG#*/}" 'has($repo)' "$REPOS_FILE" >/dev/null 2>&1; then + echo "deploy-audit: $SLUG not tracked by pr-reviewer — skipping" + exit 0 +fi + +OWNER="${SLUG%/*}" +REPO="${SLUG#*/}" + +# Resolve the local repo path +LOCAL_PATH=$(jq -r --arg repo "$REPO" '.[$repo].local_path // empty' "$REPOS_FILE") +if [ -z "$LOCAL_PATH" ] || [ ! -d "$LOCAL_PATH" ]; then + echo "deploy-audit: no local_path found for $SLUG in repos.json" >&2 + exit 2 +fi + +# Resolve --from to the most recent CalVer tag if not given +if [ -z "$FROM_REF" ]; then + FROM_REF=$(git -C "$LOCAL_PATH" describe --tags --match '20[0-9][0-9].*' --abbrev=0 2>/dev/null || echo "") + if [ -z "$FROM_REF" ]; then + echo "deploy-audit: no prior CalVer tag found — auditing entire history" + FROM_REF=$(git -C "$LOCAL_PATH" rev-list --max-parents=0 HEAD | head -1) + fi +fi + +# Resolve the commit the tag points at (if --tag was given) +if [ -n "$NEW_TAG" ]; then + # If the tag exists locally, use its commit; else use TO_REF + if git -C "$LOCAL_PATH" rev-parse "$NEW_TAG^{commit}" >/dev/null 2>&1; then + TO_REF="$NEW_TAG^{commit}" + fi +fi + +# Fetch latest from origin for up-to-date commit data +git -C "$LOCAL_PATH" fetch --tags --quiet origin 2>/dev/null || true + +# Get commits in range +if ! COMMITS_RAW=$(git -C "$LOCAL_PATH" log --format='%H%x09%s' "${FROM_REF}..${TO_REF}" 2>&1); then + echo "deploy-audit: git log failed: $COMMITS_RAW" >&2 + exit 2 +fi + +if [ -z "$COMMITS_RAW" ]; then + echo "deploy-audit: range $FROM_REF..$TO_REF is empty — nothing to audit" + exit 0 +fi + +COMMIT_COUNT=$(echo "$COMMITS_RAW" | wc -l) + +# Build per-commit → PR mapping via Gitea API +# Uses the claude-reviewer token for read-only access +export SECRETS_FILE="$HOME/.config/claude-scheduled/secrets-claude-reviewer" +export BASE_DIR="$HOME/.config/claude-scheduled" +export LOG_FILE=/dev/null +source "$HOME/.config/claude-scheduled/gitea-lib.sh" + +# Collect PR numbers for each commit; any commit with no PR is a direct push. +PR_NUMBERS=() +declare -A SEEN_PR +DIRECT_SHAS=() +DIRECT_SUBJECTS=() + +while IFS=$'\t' read -r sha subject; do + pr_data=$(gitea_get "repos/$OWNER/$REPO/commits/$sha/pull" 2>/dev/null || echo "") + pr_num=$(printf '%s' "$pr_data" | jq -r '.number // empty' 2>/dev/null) + if [ -n "$pr_num" ]; then + if [ -z "${SEEN_PR[$pr_num]:-}" ]; then + PR_NUMBERS+=("$pr_num") + SEEN_PR[$pr_num]=1 + fi + else + DIRECT_SHAS+=("$sha") + DIRECT_SUBJECTS+=("$subject") + fi +done <<<"$COMMITS_RAW" + +# For each PR, fetch reviews + commits, run audit_pr_review via python core +PR_RESULTS_JSON="[]" +for pr_num in "${PR_NUMBERS[@]}"; do + pr_obj=$(gitea_get "repos/$OWNER/$REPO/pulls/$pr_num" 2>/dev/null) + pr_title=$(printf '%s' "$pr_obj" | jq -r '.title // ""') + pr_reviews=$(gitea_get "repos/$OWNER/$REPO/pulls/$pr_num/reviews" 2>/dev/null || echo "[]") + pr_commits=$(gitea_get "repos/$OWNER/$REPO/pulls/$pr_num/commits" 2>/dev/null || echo "[]") + + PR_RESULTS_JSON=$(python3 -c " +import json, sys +sys.path.insert(0, '$HOME/.config/claude-scheduled') +import deploy_audit_core as core + +results = json.loads('''$PR_RESULTS_JSON''') +reviews = json.loads('''$pr_reviews''') +commits = json.loads('''$pr_commits''') + +result = core.audit_pr_review(reviews, commits, reviewer='$REVIEWER') +results.append({ + 'number': $pr_num, + 'title': '''$pr_title'''.replace('\n', ' ')[:60], + 'audit': { + 'passed': result.passed, + 'state': result.state, + 'reviewed_sha': result.reviewed_sha, + 'reason': result.reason, + } +}) +print(json.dumps(results)) +") +done + +# Build direct-pushes JSON +DIRECT_JSON="[]" +for i in "${!DIRECT_SHAS[@]}"; do + DIRECT_JSON=$(python3 -c " +import json +existing = json.loads('''$DIRECT_JSON''') +existing.append({'sha': '${DIRECT_SHAS[$i]}', 'subject': '''${DIRECT_SUBJECTS[$i]}'''[:60]}) +print(json.dumps(existing)) +") +done + +# Format and print the report via python core +python3 -c " +import sys, json +sys.path.insert(0, '$HOME/.config/claude-scheduled') +import deploy_audit_core as core + +pr_results_raw = json.loads('''$PR_RESULTS_JSON''') +pr_results = [] +for p in pr_results_raw: + a = p['audit'] + pr_results.append({ + 'number': p['number'], + 'title': p['title'], + 'audit': core.ReviewAuditResult( + passed=a['passed'], + state=a['state'], + reviewed_sha=a['reviewed_sha'], + reason=a['reason'], + ) + }) + +direct_pushes = json.loads('''$DIRECT_JSON''') + +report = core.build_audit_report( + slug='$SLUG', + from_ref='$FROM_REF', + to_ref='${NEW_TAG:-$TO_REF}', + commit_count=$COMMIT_COUNT, + pr_results=pr_results, + direct_pushes=direct_pushes, +) + +print(report.text) +sys.exit(0 if report.passed else 1) +" +SCRIPT +chmod +x ~/.config/claude-scheduled/deploy-audit +``` + +- [ ] **Step 2: Syntax check** + +```bash +bash -n ~/.config/claude-scheduled/deploy-audit +``` + +Expected: no output. + +- [ ] **Step 3: Manual test against a tracked repo** + +```bash +~/.config/claude-scheduled/deploy-audit cal/paper-dynasty-database +``` + +Expected: a report — either PASS or FAIL. The first run against real data will likely FAIL because PRs from before the `claude-reviewer` migration don't have reviews from that user. That's expected and is the "baseline noise" noted in the spec. + +- [ ] **Step 4: Manual test against a non-tracked repo** + +```bash +~/.config/claude-scheduled/deploy-audit cal/some-other-repo +``` + +Expected: `deploy-audit: cal/some-other-repo not tracked by pr-reviewer — skipping` and exit 0. + +- [ ] **Step 5: Verify exit codes** + +```bash +~/.config/claude-scheduled/deploy-audit cal/paper-dynasty-database; echo "exit=$?" +``` + +Expected: `exit=1` if audit failed, `exit=0` if passed, `exit=2` on tooling error. + +### Task 20: Document the post-migration audit baseline + +**Files:** +- Create: `~/.config/claude-scheduled/tasks/pr-reviewer/audit-baseline.json` + +Purpose: capture a "this is where we start auditing from" baseline per repo so that future audits don't re-flag pre-migration PRs. + +- [ ] **Step 1: Record the current HEAD of each tracked repo's main as the baseline** + +```bash +REPOS_FILE=~/.config/claude-scheduled/tasks/pr-reviewer/repos.json +BASELINE_FILE=~/.config/claude-scheduled/tasks/pr-reviewer/audit-baseline.json + +echo "{}" >"$BASELINE_FILE" + +python3 -c " +import json, subprocess + +with open('$REPOS_FILE') as f: + repos = json.load(f) + +baseline = {} +for name, cfg in repos.items(): + path = cfg.get('local_path', '') + owner = cfg.get('owner', 'cal') + slug = f'{owner}/{name}' + if not path: + continue + try: + sha = subprocess.check_output(['git', '-C', path, 'rev-parse', 'HEAD'], text=True).strip() + baseline[slug] = {'baseline_sha': sha, 'recorded_at': '$(date -Iseconds)'} + except Exception as e: + print(f'WARNING: {slug}: {e}', file=__import__('sys').stderr) + +with open('$BASELINE_FILE', 'w') as f: + json.dump(baseline, f, indent=2) + +print(json.dumps(baseline, indent=2)) +" +``` + +- [ ] **Step 2: Update `deploy-audit` to use the baseline when no `--from` is given and no CalVer tag exists** + +Edit the range resolution section of `~/.config/claude-scheduled/deploy-audit`. Locate: + +```bash +if [ -z "$FROM_REF" ]; then + FROM_REF=$(git -C "$LOCAL_PATH" describe --tags --match '20[0-9][0-9].*' --abbrev=0 2>/dev/null || echo "") + if [ -z "$FROM_REF" ]; then + echo "deploy-audit: no prior CalVer tag found — auditing entire history" + FROM_REF=$(git -C "$LOCAL_PATH" rev-list --max-parents=0 HEAD | head -1) + fi +fi +``` + +Replace with: + +```bash +if [ -z "$FROM_REF" ]; then + FROM_REF=$(git -C "$LOCAL_PATH" describe --tags --match '20[0-9][0-9].*' --abbrev=0 2>/dev/null || echo "") + if [ -z "$FROM_REF" ]; then + # No CalVer tag yet — try the post-migration baseline + BASELINE_FILE="$HOME/.config/claude-scheduled/tasks/pr-reviewer/audit-baseline.json" + if [ -f "$BASELINE_FILE" ]; then + FROM_REF=$(jq -r --arg slug "$SLUG" '.[$slug].baseline_sha // empty' "$BASELINE_FILE" 2>/dev/null) + fi + if [ -z "$FROM_REF" ]; then + echo "deploy-audit: no prior CalVer tag and no baseline — auditing entire history" + FROM_REF=$(git -C "$LOCAL_PATH" rev-list --max-parents=0 HEAD | head -1) + else + echo "deploy-audit: using post-migration baseline $FROM_REF" + fi + fi +fi +``` + +- [ ] **Step 3: Re-run the audit against a tracked repo** + +```bash +~/.config/claude-scheduled/deploy-audit cal/paper-dynasty-database +``` + +Expected: reports `using post-migration baseline ` and the commit range is empty (or contains only commits AFTER the baseline was recorded). Audit should pass. + +### Task 21: Write the pre-push git hook + +**Files:** +- Create: `~/.config/claude-scheduled/git-hooks/pre-push` + +- [ ] **Step 1: Create the hooks dir and write the hook** + +```bash +mkdir -p ~/.config/claude-scheduled/git-hooks + +cat > ~/.config/claude-scheduled/git-hooks/pre-push <<'HOOK' +#!/bin/bash +# Pre-push hook — intercepts CalVer tag pushes and runs deploy-audit. +# +# Bypass: DEPLOY_AUDIT_BYPASS=1 git push origin + +set -euo pipefail + +remote="$1" +# $2 is the URL, unused + +while read -r local_ref local_sha remote_ref remote_sha; do + # Only intercept tag refs matching CalVer pattern (YYYY.M.D or similar) + case "$remote_ref" in + refs/tags/20[0-9][0-9].*) ;; + *) continue ;; + esac + + tag_name="${remote_ref#refs/tags/}" + + if [ "${DEPLOY_AUDIT_BYPASS:-0}" = "1" ]; then + echo "⚠ deploy-audit bypassed via DEPLOY_AUDIT_BYPASS=1 — tag: $tag_name" >&2 + continue + fi + + # Resolve repo slug from remote URL + # Handles: git@host:owner/repo.git and https://host/owner/repo.git + url=$(git config --get "remote.${remote}.url") + slug=$(echo "$url" | sed -E 's#.*[:/]([^/]+/[^/]+)(\.git)?$#\1#' | sed 's/\.git$//') + + echo "→ deploy-audit: $slug tag=$tag_name" >&2 + + if ! ~/.config/claude-scheduled/deploy-audit "$slug" --tag "$tag_name" >&2; then + echo "" >&2 + echo "✗ deploy-audit FAILED — tag push blocked." >&2 + echo " To override: DEPLOY_AUDIT_BYPASS=1 git push $remote $tag_name" >&2 + exit 1 + fi +done + +exit 0 +HOOK +chmod +x ~/.config/claude-scheduled/git-hooks/pre-push +``` + +- [ ] **Step 2: Syntax check** + +```bash +bash -n ~/.config/claude-scheduled/git-hooks/pre-push +``` + +Expected: no output. + +### Task 22: Install the hook via `core.hooksPath` + +**Files:** Git global config + +- [ ] **Step 1: Check current `core.hooksPath`** + +```bash +git config --global --get core.hooksPath || echo "(not set)" +``` + +Expected: `(not set)` or an existing path. + +**If already set** to something else, stop and investigate — another tool is managing global git hooks and we need to compose rather than overwrite. Options: +- Add our hook to the existing hook dir +- Create a chain script + +**If not set**, proceed. + +- [ ] **Step 2: Set the global hooks path** + +```bash +git config --global core.hooksPath ~/.config/claude-scheduled/git-hooks +``` + +- [ ] **Step 3: Verify** + +```bash +git config --global --get core.hooksPath +``` + +Expected: `/home/cal/.config/claude-scheduled/git-hooks`. + +- [ ] **Step 4: Confirm the hook is discoverable from a tracked repo clone** + +```bash +cd /mnt/NV2/Development/claude-home +git rev-parse --git-path hooks/pre-push +``` + +Expected: resolves to `/home/cal/.config/claude-scheduled/git-hooks/pre-push`. + +### Task 23: End-to-end deploy gate test + +**Files:** None + +- [ ] **Step 1: Dry-run tag push from a scratch branch** + +Use a tracked repo that has a real deploy flow (e.g. `paper-dynasty-database`). Create a throwaway CalVer tag on an existing main commit, attempt to push, expect either pass or fail depending on state: + +```bash +cd +git fetch origin +git checkout main +git pull origin main + +# Tag a commit — use a test-variant tag that won't clash with real CalVer +# Still matches the CalVer pattern so the hook fires +TEST_TAG="2026.4.10-audit-test" +git tag "$TEST_TAG" +``` + +- [ ] **Step 2: Attempt to push the tag** + +```bash +git push origin "$TEST_TAG" +``` + +**Expected outcomes:** +- If audit passes: push succeeds, `→ deploy-audit: cal/paper-dynasty-database tag=2026.4.10-audit-test` appears, then the normal push output. Delete the test tag from remote: `git push origin :refs/tags/$TEST_TAG`. +- If audit fails: push is blocked with the audit failure output and the bypass hint. This is the case when pre-migration PRs haven't been retroactively reviewed. + +- [ ] **Step 3: Test the bypass flag** + +```bash +DEPLOY_AUDIT_BYPASS=1 git push origin "$TEST_TAG" +``` + +Expected: `⚠ deploy-audit bypassed` message, push proceeds. + +Then clean up: + +```bash +git tag -d "$TEST_TAG" +git push origin :refs/tags/"$TEST_TAG" +``` + +- [ ] **Step 4: Test with a non-CalVer tag (should be ignored)** + +```bash +git tag test-ignore-me +git push origin test-ignore-me +``` + +Expected: no deploy-audit output, push proceeds normally. + +Cleanup: + +```bash +git tag -d test-ignore-me +git push origin :refs/tags/test-ignore-me +``` + +- [ ] **Step 5: Test from a non-tracked repo** + +Pick any non-tracked git repo on the workstation. Tag + push a CalVer tag. Expected: the hook fires, but `deploy-audit` short-circuits with "not tracked by pr-reviewer — skipping" and exits 0, so the push proceeds. + +- [ ] **Step 6: Phase 4 checkpoint** + +Phase 4 is complete when: +- The hook fires on CalVer tag pushes to tracked repos +- The audit output is clean (`PASS`) when the branch state is clean +- The audit blocks pushes when a PR in the range is not cleanly approved +- The bypass flag works for legitimate emergencies +- Non-CalVer tags and non-tracked repos pass through cleanly + +--- + +## Rollback Procedures + +Each phase has an independent rollback. Use only the minimum-necessary rollback for the issue: + +**Phase 4 rollback:** +```bash +git config --global --unset core.hooksPath +``` +Deploys proceed as before. Nothing else touched. + +**Phase 3 rollback (all repos):** +```bash +python3 -c " +import json +with open('$HOME/.config/claude-scheduled/tasks/pr-reviewer/repos.json') as f: + repos = json.load(f) +for name, cfg in repos.items(): + print(f'{cfg.get(\"owner\", \"cal\")}/{name}') +" | while read slug; do + echo '{"required_approvals": 0}' | tea api -X PATCH "/repos/$slug/branch_protections/main" --input - + echo "$slug → reverted" +done +``` +Branch protection returns to previous looseness. Audit can still run manually for visibility. + +**Phase 2 rollback:** +```bash +# Restore dispatcher and gitea-lib from the most recent backups +ls -t ~/.config/claude-scheduled/pr-reviewer-dispatcher.sh.bak.* | head -1 | xargs -I{} cp {} ~/.config/claude-scheduled/pr-reviewer-dispatcher.sh +ls -t ~/.config/claude-scheduled/gitea-lib.sh.bak.* | head -1 | xargs -I{} cp {} ~/.config/claude-scheduled/gitea-lib.sh +``` +Dispatcher reverts to using the default `secrets` file (Claude token). Re-review-on-push logic is removed. + +**Phase 1 rollback:** +```bash +# Revoke the claude-reviewer token and delete the user +tea api -X DELETE /admin/users/claude-reviewer/tokens/pr-reviewer-dispatcher +tea api -X DELETE /admin/users/claude-reviewer +rm ~/.config/claude-scheduled/secrets-claude-reviewer +``` + +--- + +## Plan Self-Review Notes + +**Spec coverage check:** +- Phase 1 Tasks 1-4 implement spec Section "Gitea setup" (new user, token, collaborator) +- Phase 2 Tasks 5-11 implement spec Sections "Dispatcher changes" (all three: token swap, re-review on push, staleness timeout) and spec Section "Testing" Steps 1-4 +- Phase 3 Tasks 12-16 implement spec Section "Branch protection tightening" and spec Rollout "Phase 3" +- Phase 4 Tasks 17-23 implement spec Sections "Pre-tag deploy audit", "Pre-push hook", "Hook installation" and spec Rollout "Phase 4" +- Rollback procedures in this plan correspond to spec "Rollback" notes in each component section + +**Gaps from spec:** +- Spec mentions "Discord notification webhook" as untouched — confirmed not modified in any task +- Spec mentions "cognitive memory storage" for review results — confirmed not modified +- Spec Section "Error handling" table: each failure mode has a task or explicit handling in this plan + +**Placeholder scan:** No TBDs, TODOs, or "fill in later". Code blocks contain complete implementations. + +**Type consistency check:** `audit_pr_review` returns `ReviewAuditResult` in both the test and the implementation. `build_audit_report` takes `pr_results` containing `{number, title, audit}` dicts in both test and implementation. `AuditReport` has `passed` and `text` fields consistently. Helper function names match between bash (`get_last_review_sha`, `reviewing_label_age`) and their callers. + +--- + +## Execution Handoff + +Plan complete. Two execution options: + +**1. Subagent-Driven (recommended)** — dispatch a fresh subagent per task with review between tasks. Good for a plan this long where context can drift. + +**2. Inline Execution** — execute tasks in this session with batch checkpoints. + +Which approach?