Files
agent-skills/skills/land/SKILL.md
T
naps62 17677c18df feat(land): never merge, user clicks
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
2026-07-30 08:33:00 +00:00

11 KiB
Raw Blame History

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
name description required
target PR number (e.g. 47), a PR URL, or omit to use the PR for the current branch false

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:

  • remoteHost from config if set (github / gitea).
  • Else infer from git remote get-url origin: github.comgithub; anything else (e.g. git.naps.pt) → gitea.

PR number N:

  • From $ARGUMENTS if 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'
  • 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/REPO from origin. Set with gh.
  • gitea: BASE=$remoteBaseUrl (or the origin host), REPO=<owner>/<repo> from origin, $GITEA_TOKEN in env (source ~/.env.claude if missing). Never put the token in a URL.
  • Tracking issue REF (optional): parse Closes <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_background for one-shot "CI done"; Monitor persistent for 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.