Auto-merge burned a client PR meant for manual merge. Human gate needed on public/client repos; one click on private is cheap, so rule is universal. Land now stops at green + approved + updated branch, notifies, and leaves the merge button alone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLkYHLzszmzq5fMGpFAkHh
11 KiB
name, description, user-invocable, args
| name | description | user-invocable | args | |||||||
|---|---|---|---|---|---|---|---|---|---|---|
| land | Drive an existing PR to ready-to-merge: wait for CI + reviews, fix failures, resolve every comment, push, iterate until green + approved, then hand the merge click to the user. Never merges. Forge-agnostic (GitHub or Gitea). | true |
|
Land - Drive a PR to green + ready-to-merge
Takes an already-open PR and shepherds it to the merge button: green CI, all review threads resolved, approved, branch up to date. Spends zero model tokens idling — waits by arming background watchers that wake on real events, never by polling on a timer.
Never merge. The final merge click is always the user's — on every repo, every forge. Public repos with other contributors and client repos need a human gate, and a single click on private repos is cheap. No gh pr merge, no merge API call, no --auto.
This is the canonical review/CI-iteration loop. /work opens a PR then hands off here; /yolo can hand off here when a PR flow is wanted. It also stands alone: /land 47, /land <url>, or /land on a branch that already has a PR.
Config: reads .claude/tracker.json (or legacy .claude/linear.json) at the repo root if present — see linear-common/COMMON.md (sibling skill, same skills root). No config needed to just land a PR; config only adds tracker-issue closing and remoteHost selection.
1. Resolve the target
Forge — pick the API:
remoteHostfrom config if set (github/gitea).- Else infer from
git remote get-url origin:github.com→ github; anything else (e.g.git.naps.pt) → gitea.
PR number N:
- From
$ARGUMENTSif a number or URL was given (parse the trailing number from a URL). - Else the PR for the current branch:
- github:
gh pr view --json number --jq .number - gitea:
curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/pulls?state=open&head=<owner>:<branch>" | jq -r '.[0].number'
- github:
- If none found, tell the user there's no open PR for this branch and stop. Do not open one — that's
/work's job.
Derive (used throughout):
- github:
OWNER/REPOfrom origin. Set withgh. - gitea:
BASE=$remoteBaseUrl(or the origin host),REPO=<owner>/<repo>from origin,$GITEA_TOKENin env (source ~/.env.claudeif missing). Never put the token in a URL. - Tracking issue
REF(optional): parseCloses <REF>/Closes #<n>from the PR body. Used only for the Linear follow-up note in close-out; skip silently if absent.
Then run the variant for your forge below. Both share these terminal conditions (all must hold before handing off):
- CI checks pass
- All review threads resolved
- Approved, no pending review requests
2. Efficiency rules (both variants)
- Baseline once. Right after resolving
N, snapshot existing review-comment IDs to<git-dir>/pr-<N>-seen. Every later pass processes only IDs not in that file — handled feedback is never re-read. Guard it so a re-entry after a wake never truncates + reseeds (that would reprocess everything). - Wake on events, not a clock. CI is minutes; human review is hours. Block a background watcher (Bash
run_in_backgroundfor one-shot "CI done";Monitor persistentfor the whole review window) and stay idle until something actually happens. Handle exactly what the watcher reports, then re-arm. - Stall guard. A watcher must never hang forever on a stuck pipeline. Bound every CI wait: if no check appears within ~3 min of a push, or a run sits in-progress past a sane ceiling (default ~20 min, or the repo's known CI duration ×2), stop waiting and surface it to the user — don't keep idling. Silence is not success.
- Fix everything. Every unresolved thread gets an action — a code fix or a reply. Bot reviewers (crit, CodeRabbit, Copilot, etc.) count. Don't declare ready over an unaddressed thread.
GitHub variant
Baseline (once — guard against re-entry):
seen="$(git rev-parse --git-dir)/pr-<N>-seen"
if [ ! -f "$seen" ]; then
gh api graphql -f query='{repository(owner:"<OWNER>",name:"<REPO>"){pullRequest(number:<N>){reviewThreads(first:100){nodes{comments(first:50){nodes{id}}}}}}}' \
--jq '.data.repository.pullRequest.reviewThreads.nodes[].comments.nodes[].id' > "$seen" 2>/dev/null || : > "$seen"
fi
Wait for CI (after every push): block in the background — one wake when checks reach a terminal state. Do NOT poll statusCheckRollup in a loop.
# Bash run_in_background: true
gh pr checks <N> --watch --fail-fast
- Exit 0 → CI green, move on.
- Non-zero → CI failed. Read only the failing job:
gh run view <run-id> --log-failed. Fix, commit, push, re-arm this watcher. - No checks appear within the stall window → surface to user (see stall guard).
Wait for review comments (whole review window): arm one persistent Monitor that emits a line per new comment on any unresolved thread, with its thread id.
# Monitor persistent: true
seen="$(git rev-parse --git-dir)/pr-<N>-seen"; touch "$seen"
while true; do
gh api graphql -f query='{repository(owner:"<OWNER>",name:"<REPO>"){pullRequest(number:<N>){reviewThreads(first:100){nodes{id isResolved comments(first:50){nodes{id author{login} body}}}}}}}' \
--jq '.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved==false)|.id as $tid|.comments.nodes[]|"\(.id)\t\($tid)\t\(.author.login): \(.body)"' 2>/dev/null \
| while IFS=$'\t' read -r cid tid rest; do grep -qxF "$cid" "$seen" || { echo "NEW COMMENT $cid (thread $tid) — $rest"; echo "$cid" >> "$seen"; }; done
sleep 30
done
On each NEW COMMENT event (<cid> = comment id, <tid> = thread):
- Valid feedback: fix the code, commit, push (re-triggers the CI watcher).
- Misunderstanding: reply explaining, and immediately record your reply's own id so the watcher never treats it as new feedback:
rid=$(gh api repos/<OWNER>/<REPO>/pulls/<N>/comments/<cid>/replies -f body="<reply>" --jq .id) echo "$rid" >> "$seen" - Resolve the addressed thread (also stops it re-emitting):
gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "<tid>"}) { thread { isResolved } } }'
Stop (TaskStop the Monitor) when the terminal condition holds: CI green, approved, no pending review requests (gh pr view <N> --json reviewDecision,reviewRequests,reviews), all threads resolved.
Ready. A long review loop moves the base, so update the branch and let CI re-run — the user's click should be the only step left:
gh pr update-branch <N> 2>/dev/null || true # rebase/merge default into the PR branch if behind
# if it updated, the CI watcher re-arms on the new head; wait for green again
Then go to step 3 (close out). Do not run gh pr merge in any form.
Gitea variant
Requires $GITEA_TOKEN and remoteBaseUrl (or origin host). Set BASE, REPO, $GITEA_TOKEN, N in the environment first. Gitea has no --watch, no GraphQL, no per-thread resolve — same principle, plain REST.
Baseline (once — guard against re-entry): seed with existing review/issue comment ids.
seen="$(git rev-parse --git-dir)/pr-$N-seen"
if [ ! -f "$seen" ]; then
{ curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/issues/$N/comments"; \
curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/pulls/$N/reviews"; } 2>/dev/null \
| jq -r '.[]?.id' > "$seen" || : > "$seen"
fi
Wait for CI (after every push): one Monitor that polls the head commit's combined status and exits on any terminal state. Covers success and failure.
# Monitor persistent: false (one-shot); re-arm after each push
SHA=$(curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/pulls/$N" | jq -r .head.sha)
while true; do
# combined state aggregates ALL contexts (lint + test + ...), not just the newest single status
st=$(curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/commits/$SHA/status" | jq -r '.state // "pending"')
case "$st" in success|failure|error) echo "CI $st"; break;; esac
sleep 30
done
On CI failure/CI error: read the failing job's log, fix, commit, push, re-arm. Apply the stall guard — bound the wait.
Wait for review comments (whole window): persistent Monitor emitting each new review/issue comment since the last check.
# Monitor persistent: true
seen="$(git rev-parse --git-dir)/pr-$N-seen"; touch "$seen"
while true; do
{ curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/issues/$N/comments"; \
curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/pulls/$N/reviews"; } 2>/dev/null \
| jq -r '.[]? | "\(.id)\t\(.user.login): \(.body // .content // "")"' \
| while IFS=$'\t' read -r id rest; do grep -qxF "$id" "$seen" || { echo "NEW $id — $rest"; echo "$id" >> "$seen"; }; done
sleep 30
done
On each NEW event:
- Valid feedback: fix the code, commit, push (re-arms the CI watcher).
- Misunderstanding: reply, and record your reply's own id so it isn't re-surfaced:
rid=$(curl -sS -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \ "$BASE/api/v1/repos/$REPO/issues/$N/comments" -d "$(jq -nc --arg body "<reply>" '{body:$body}')" | jq -r .id) echo "$rid" >> "$seen" - Gitea has no per-thread resolve — signal addressed by replying with a short confirmation and pushing the fix.
After approval + green CI (TaskStop the review Monitor first): if the base moved, update the branch and wait for CI green again — the user's click should be the only step left.
curl -sS -X POST -H "Authorization: token $GITEA_TOKEN" \
"$BASE/api/v1/repos/$REPO/pulls/$N/update" >/dev/null 2>&1 || true
# if it updated, re-arm the CI watcher and wait for green
Then go to step 3 (close out). Do not call the merge API.
Never embed $GITEA_TOKEN in URLs or commit messages — Authorization header only.
3. Close out
Report to the user: PR ready to merge (link it), CI green, approved, all threads resolved, and a one-line summary of what feedback was addressed. The merge — and the branch delete + tracking-issue close that follow it — is theirs.
The review window is often hours; the user may be away when the PR goes green. Push a notification so the one click can happen from their phone: mcp__ha-mcp__ha_call_service with domain: "notify", service mobile_app_pixel_7_naps (or blitz.notifyService from config), message "PR # ready to merge" + the PR URL.
Closes <REF> in the PR body closes the tracking issue automatically on merge (GitHub/Gitea). Only Linear needs follow-up: if config points at Linear, tell the user the issue must be moved to Done after they merge, or move it yourself if you're still around post-merge.