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
84 changes: 84 additions & 0 deletions .claude/scripts/merge-ready.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# merge-ready.sh — merge every open PR the repo OWNER has approved and that is
# safe to merge, then delete the branch. The human Approve on GitHub is the ONLY
# gate; this script never approves anything — it just acts on approvals. Pair it
# with notify-poll.sh in a cron to close the loop: review → approve → auto-merge.
#
# A PR is merged iff ALL hold:
# - base is the configured baseBranch (gates.json merge.baseBranch), not a draft
# - latest review by the OWNER is APPROVED
# - that approval was submitted at/after the PR's last commit (so it covers the
# current head) — guards against commits pushed after an approval. A private
# repo on a free plan has no branch protection to auto-dismiss stale
# approvals, so we enforce "approval covers head" here instead.
# - mergeable (no conflicts)
# - every CI check is green (none failing, none still pending)
# Anything else is SKIPPED with a reason. Output is JSON lines a cron summarizes.
#
# Auth: uses ambient `gh` auth (the owner's `gh auth login`), same as
# notify-poll.sh — merging is an owner action. Only PR *creation* uses the bot
# (bot-gh.sh). Repo is derived from the git remote; override with $1 (owner/repo).
# The approver defaults to the repo owner; override with $MERGE_APPROVER.
# Pre-approve `bash .claude/scripts/merge-ready.sh` in .claude/settings.json.
set -euo pipefail
export PATH="$HOME/.local/bin:$PATH"

root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}"
owner="${MERGE_APPROVER:-${repo%%/*}}" # the approver whose APPROVED review authorizes a merge
gates="$root/.claude/gates.json"
base="$(node -e "try{const g=require('$gates');process.stdout.write((g.merge&&g.merge.baseBranch)||'main')}catch(e){process.stdout.write('main')}")"

# Decide MERGE / SKIP:<reason> for one PR's JSON (read on stdin).
decide() {
node -e '
const base = process.argv[1], owner = process.argv[2];
function verdict(p) {
if (p.isDraft) return "SKIP:draft";
if (p.baseRefName !== base) return "SKIP:base-is-"+p.baseRefName;
if (p.mergeable !== "MERGEABLE") return "SKIP:mergeable="+p.mergeable;

// CI: every check green; none failing or pending.
for (const c of (p.statusCheckRollup||[])) {
if (c.conclusion !== undefined && c.conclusion !== null && c.conclusion !== "") { // CheckRun
if (["FAILURE","CANCELLED","TIMED_OUT","ACTION_REQUIRED","STARTUP_FAILURE","STALE"].includes(c.conclusion))
return "SKIP:check-failed:"+(c.name||"");
if (c.status && c.status !== "COMPLETED") return "SKIP:check-pending:"+(c.name||"");
} else if (c.state) { // legacy StatusContext
if (["FAILURE","ERROR"].includes(c.state)) return "SKIP:status-failed:"+(c.context||"");
if (c.state === "PENDING") return "SKIP:status-pending:"+(c.context||"");
}
}

// Latest review by the owner must be APPROVED and cover the current head.
const mine = (p.reviews||[]).filter(r => r.author && r.author.login === owner && r.submittedAt)
.sort((a,b) => a.submittedAt.localeCompare(b.submittedAt));
const last = mine[mine.length-1];
if (!last) return "SKIP:no-owner-review";
if (last.state !== "APPROVED") return "SKIP:owner-review="+last.state;
const commits = p.commits||[];
const head = commits.length ? commits[commits.length-1].committedDate : null;
if (head && last.submittedAt < head) return "SKIP:approval-stale (re-approve current head)";
return "MERGE";
}
let p; try { p = JSON.parse(require("fs").readFileSync(0,"utf8")); } catch(e){ console.log("SKIP:bad-json"); process.exit(0); }
console.log(verdict(p));
' "$base" "$owner"
}

merged=0; skipped=0
for n in $(gh pr list -R "$repo" --base "$base" --state open --json number -q '.[].number'); do
data="$(gh pr view "$n" -R "$repo" --json number,title,isDraft,baseRefName,mergeable,reviews,statusCheckRollup,commits)"
verdict="$(printf '%s' "$data" | decide)"
title="$(printf '%s' "$data" | node -e 'process.stdout.write((JSON.parse(require("fs").readFileSync(0,"utf8")).title)||"")')"
if [ "$verdict" = "MERGE" ]; then
if gh pr merge "$n" -R "$repo" --merge --delete-branch >/dev/null 2>&1; then
echo "{\"pr\":$n,\"action\":\"merged\",\"title\":\"$title\"}"; merged=$((merged+1))
else
echo "{\"pr\":$n,\"action\":\"merge-failed\",\"title\":\"$title\"}"
fi
else
echo "{\"pr\":$n,\"action\":\"skip\",\"reason\":\"${verdict#SKIP:}\",\"title\":\"$title\"}"; skipped=$((skipped+1))
fi
done
echo "=== merge-ready: merged=$merged skipped=$skipped ==="
18 changes: 18 additions & 0 deletions .claude/scripts/notify-poll.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ set -euo pipefail

root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}"
owner="${MERGE_APPROVER:-${repo%%/*}}" # the human whose APPROVED review gates a merge
state_dir="$root/.claude/state" # add .claude/state/ to .gitignore
cursor_file="$state_dir/notify-cursor"

Expand All @@ -39,5 +40,22 @@ for n in $(gh pr list -R "$repo" --json number -q '.[].number'); do
--jq "[.[] | select(.submitted_at > \"$cursor\") | {pr:$n,user:.user.login,state:.state,body:.body,submitted:.submitted_at,url:.html_url}]"
done

# Standing status of EVERY open PR (cursor-independent): merge-readiness is a
# state, not an event — an approval may have landed a tick ago and CI only just
# gone green. A cron uses this to decide which PRs need feedback addressed;
# merge-ready.sh acts on the approved+green ones.
echo "=== open pr status ==="
for n in $(gh pr list -R "$repo" --state open --json number -q '.[].number'); do
gh pr view "$n" -R "$repo" --json number,title,author,baseRefName,isDraft,mergeable,reviews,statusCheckRollup \
--jq "{
pr: .number, title: .title, author: .author.login, base: .baseRefName, draft: .isDraft, mergeable: .mergeable,
ownerReview: ([.reviews[] | select(.author.login==\"$owner\")] | sort_by(.submittedAt) | last | .state // \"none\"),
checks: ([.statusCheckRollup[]? | (.conclusion // .state)] | {
failing: (map(select(. == \"FAILURE\" or . == \"ERROR\" or . == \"CANCELLED\" or . == \"TIMED_OUT\")) | length),
pending: (map(select(. == \"PENDING\" or . == \"QUEUED\" or . == \"IN_PROGRESS\" or . == null)) | length),
total: length })
}"
done

echo "$now" > "$cursor_file"
echo "=== cursor advanced to $now ==="
1 change: 1 addition & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"Bash(bash .claude/scripts/gate.sh:*)",
"Bash(bash .claude/scripts/notify-poll.sh:*)",
"Bash(bash .claude/scripts/pr-feedback.sh:*)",
"Bash(bash .claude/scripts/merge-ready.sh:*)",

"Bash(git status:*)",
"Bash(git diff:*)",
Expand Down
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
# Claude Code local runtime state (machine-specific, not source)
# Runtime state written by the loop scripts (notify-poll.sh cursor, etc.)
.claude/state/

# Secrets — bot token (GH_BOT_TOKEN) and any local env. Commit .env.example only.
.env
.env.*
!.env.example
6 changes: 6 additions & 0 deletions docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ adapter-driven — you configure `gates.json`, not the YAML.
```
Without this step the workflow only *reports* pass/fail; required checks are what block the merge button.

> **Free private repos can't enforce.** Required status checks (and branch protection) need a paid plan
> on a private repo — GitHub will reject the call above with *"upgrade to GitHub Team/Enterprise"*. Options:
> make the repo **public** (enforcement is free), upgrade the plan, or run **convention-based**: the checks
> still run and are visible on every PR, and `merge-ready.sh` only merges a PR once the owner has approved
> it *and* CI is green — so the approval+green gate holds even though GitHub doesn't hard-block the button.

## Verification checklist
- [ ] `CLAUDE.md` describes the project and lists modules.
- [ ] `.claude/gates.json` has real commands; `gate.sh build|lint|test` behave correctly.
Expand Down
30 changes: 22 additions & 8 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,28 @@ With `pr-per-agent`, the standing loop per ticket looks like:
5. **Merge** — owner approves, merge per `gates.json.merge`, clean the worktree (below).

**Closing the loop automatically:** webhooks rarely reach a dev box, so poll. Either a Claude Code cron
(`CronCreate`, durable) or an in-session `/loop` that every ~10–15 min runs
`bash .claude/scripts/notify-poll.sh` — it prints new issues and PR comments/reviews since a cursor file
(`.claude/state/notify-cursor`, gitignored) — then summarizes them and offers to kick the orchestrator.
**Wrap the poll in that script, don't inline it:** an inline compound command (loops, `$()`, redirects)
never matches a permission rule, so an inlined poll blocks on a permission prompt every firing; the script
gives one stable command to pre-approve in `settings.json`
(`"Bash(bash .claude/scripts/notify-poll.sh)"`). Caveats: cron jobs fire only while Claude Code is
running, auto-expire after 7 days, and may be session-scoped on some versions — re-arm at session start.
(`CronCreate`, durable) or an in-session `/loop` that every ~10–15 min runs the three loop scripts in order
— each is a single stable command to pre-approve in `settings.json`, since an inline compound command
(loops, `$()`, redirects) never matches a permission rule and would block on a prompt every firing:

1. **`bash .claude/scripts/notify-poll.sh`** — prints new issues and PR comments/reviews since a cursor file
(`.claude/state/notify-cursor`, gitignored), plus a cursor-independent **`open pr status`** section (per
open PR: latest owner review, CI rollup, mergeable) so the loop sees merge-readiness, which is a *state*,
not an event. Summarize new items.
2. **`bash .claude/scripts/pr-feedback.sh`** — lists open bot PRs with *unaddressed* `CHANGES_REQUESTED`
feedback (deduped via a `<!-- claude-addressed -->` marker). For each, dispatch the orchestrator to
address the comments on the same branch and push — the implementer posts the marker after pushing.
3. **`bash .claude/scripts/merge-ready.sh`** — merges every open PR the owner has **APPROVED** that is
mergeable and CI-green, then deletes the branch. The human Approve is the only merge gate; the script
never approves. **Safety:** it merges only if the approval was submitted *at/after* the PR's last commit,
so a free private repo (no branch protection to dismiss stale approvals) never auto-merges commits you
haven't reviewed — pushing after approval requires re-approval. Uses ambient `gh` auth (merging is an
owner action; only PR *creation* uses the bot).

With all three wired, the loop runs hands-off: **add issues → review → approve → it merges and advances**.
A natural step 4 is to start the next `module:*` issue only when **no PRs are open**, so work stays
serialized (one issue in flight) and bounded. Caveats: cron jobs fire only while Claude Code is running,
auto-expire after 7 days, and may be session-scoped on some versions — re-arm at session start.

**Running it fully hands-off?** Polling still leaves a human approving each tool call. To let the loop
run unattended (Claude Code `bypassPermissions`), first harden the environment so the prompt is replaced
Expand Down
Loading