183 lines
11 KiB
Markdown
183 lines
11 KiB
Markdown
---
|
||
name: land
|
||
description: "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)."
|
||
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 + 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.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=<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):
|
||
```bash
|
||
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
|
||
```
|
||
|
||
**Copilot review** (once, right after baselining): if Copilot has neither reviewed nor been requested, request it. Its comments then flow through the normal comment watcher like any other bot reviewer.
|
||
```bash
|
||
if ! gh pr view <N> --json reviews,reviewRequests --jq '.. | .login? // empty' | grep -qi copilot; then
|
||
gh api -X POST repos/<OWNER>/<REPO>/pulls/<N>/requested_reviewers \
|
||
-f 'reviewers[]=copilot-pull-request-reviewer[bot]' >/dev/null 2>&1 || true # repo may not have Copilot review enabled
|
||
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.
|
||
```bash
|
||
# 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:
|
||
```bash
|
||
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:
|
||
```bash
|
||
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.
|
||
```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 "<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.
|
||
```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
|
||
```
|
||
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 #<N> 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.
|