--- name: land description: "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)." user-invocable: true args: - name: target description: "PR number (e.g. 47), a PR URL, or omit to use the PR for the current branch" required: false --- # 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 `, 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.com` → **github**; 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=:" | 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=/` from origin, `$GITEA_TOKEN` in env (`source ~/.env.claude` if missing). Never put the token in a URL. - **Tracking issue `REF`** (optional): parse `Closes ` / `Closes #` 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 `/pr--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 merge over an unaddressed thread. --- ## GitHub variant **Baseline** (once — guard against re-entry): ```bash seen="$(git rev-parse --git-dir)/pr--seen" if [ ! -f "$seen" ]; then gh api graphql -f query='{repository(owner:"",name:""){pullRequest(number:){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 --watch --fail-fast ``` - Exit 0 → CI green, move on. - Non-zero → CI failed. Read only the failing job: `gh run view --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. ```bash # Monitor persistent: true seen="$(git rev-parse --git-dir)/pr--seen"; touch "$seen" while true; do gh api graphql -f query='{repository(owner:"",name:""){pullRequest(number:){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 (`` = comment id, `` = 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: ```bash rid=$(gh api repos///pulls//comments//replies -f body="" --jq .id) echo "$rid" >> "$seen" ``` - Resolve the addressed thread (also stops it re-emitting): ``` gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: ""}) { thread { isResolved } } }' ``` **Stop** (`TaskStop` the Monitor) when the terminal condition holds: CI green, approved, no pending review requests (`gh pr view --json reviewDecision,reviewRequests,reviews`), all threads resolved. **Merge.** A long review loop moves the base, so update first, let CI re-run, then merge: ```bash gh pr update-branch 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 --squash --delete-branch ``` Use `--auto` if branch protection requires it: `gh pr merge --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. ```bash 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. ```bash # 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. ```bash # 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: ```bash 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 "" '{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. ```bash 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 ` 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 `. Report to the user: PR merged, branch deleted, issue closed, and a one-line summary of what feedback was addressed.