From ce62655a81376697e86f37350eb80f9f17e7a0cf Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:38:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(orchestration):=20merge-ready.sh=20?= =?UTF-8?q?=E2=80=94=20auto-merge=20owner-approved=20PRs=20+=20loop=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backports the missing *merge* step of the autonomous PR loop from the reDeploy instance. Complements the existing notify-poll.sh (poll) and pr-feedback.sh (address change-requests, from #15): nothing yet merged owner-approved PRs. - merge-ready.sh (new): merges every open PR the owner has APPROVED that is mergeable and CI-green, then deletes the branch. The human Approve is the only gate — the script never approves. Safety: 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 unreviewed commits. Generic: repo via git remote (or $1), approver = repo owner (or $MERGE_APPROVER), base from gates.json merge.baseBranch. Uses ambient gh auth (merging is an owner action; only PR creation uses the bot). - notify-poll.sh: adds a cursor-independent "open pr status" section (per PR: latest owner review, CI rollup, mergeable) — merge-readiness is a state, not an event. - settings.json: pre-approve merge-ready.sh. - docs/USAGE.md: document the full 3-script loop (poll → pr-feedback → merge-ready) and the serialized "advance to next issue only when no PRs open" step. - docs/GETTING_STARTED.md: note that required status checks need a paid plan on private repos; convention-based enforcement via approval+green still holds. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/scripts/merge-ready.sh | 84 ++++++++++++++++++++++++++++++++++ .claude/scripts/notify-poll.sh | 18 ++++++++ .claude/settings.json | 1 + .gitignore | 7 +++ docs/GETTING_STARTED.md | 6 +++ docs/USAGE.md | 30 ++++++++---- 6 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 .claude/scripts/merge-ready.sh create mode 100644 .gitignore diff --git a/.claude/scripts/merge-ready.sh b/.claude/scripts/merge-ready.sh new file mode 100644 index 0000000..191bc85 --- /dev/null +++ b/.claude/scripts/merge-ready.sh @@ -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: 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 ===" diff --git a/.claude/scripts/notify-poll.sh b/.claude/scripts/notify-poll.sh index 381d698..abeda1d 100755 --- a/.claude/scripts/notify-poll.sh +++ b/.claude/scripts/notify-poll.sh @@ -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" @@ -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 ===" diff --git a/.claude/settings.json b/.claude/settings.json index b72447a..7f6bcbe 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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:*)", diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e8e127 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# 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 diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 5460df6..6e80a85 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -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. diff --git a/docs/USAGE.md b/docs/USAGE.md index 3c582bd..0e13121 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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 `` 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. ## Merge discipline - **`pr-per-agent`** (default): each worker → branch → PR. You (or a merge step) integrate; conflicts surface