- skills/ shared by both tools (open Agent Skills standard) - portable cross-skill refs (root-relative, no ~/.claude hardcode) - bin/link.sh bootstrap for non-Nix machines - nix/home.nix + flake.nix for home-manager Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SK5Lo7LQfwLRVdCv1A8uF
10 KiB
name, description, user-invocable, args
| name | description | user-invocable | args | |||||||
|---|---|---|---|---|---|---|---|---|---|---|
| land | Drive an existing PR to merge: wait for CI + reviews, fix failures, resolve every comment, push, iterate until green + approved, then merge. Forge-agnostic (GitHub or Gitea). | true |
|
Land - Drive a PR to green + merged
Takes an already-open PR and shepherds it to done: green CI, all review threads resolved, approved, merged, tracking issue closed. Spends zero model tokens idling — waits by arming background watchers that wake on real events, never by polling on a timer.
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 to mark the issue done after merge; skip silently if absent.
Then run the variant for your forge below. Both share these terminal conditions (all must hold before merge):
- 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 merge 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.
Merge. A long review loop moves the base, so update first, let CI re-run, then merge:
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 before merging
gh pr merge <N> --squash --delete-branch
Use --auto if branch protection requires it: gh pr merge <N> --squash --auto --delete-branch.
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, wait for CI green again, then merge.
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 before merging, then:
curl -sS -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
"$BASE/api/v1/repos/$REPO/pulls/$N/merge" -d '{"Do":"merge"}'
Never embed $GITEA_TOKEN in URLs or commit messages — Authorization header only.
3. Close out
Merging with Closes <REF> in the body closes the tracking issue automatically. If a REF was found and the issue is still open after merge, close it:
- linear: move the issue to "Done" (via the configured Linear MCP).
- gitea:
curl -sS -X PATCH -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" "$BASE/api/v1/repos/$REPO/issues/$N" -d '{"state":"closed"}'. - github:
gh issue close <n>.
Report to the user: PR merged, branch deleted, issue closed, and a one-line summary of what feedback was addressed.