--- name: land description: "Drive a PR you authored to ready-to-merge: fix CI failures, address every review comment, resolve conflicts, push, until green + approved. Merges it on Gitea; on GitHub it stops and leaves the click to the user. Event-driven — the PR daemon wakes it. 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 your own PR to ready-to-merge Takes a PR **you authored** and shepherds it to the merge: green CI, every review thread addressed, approved, branch up to date. For PRs someone else authored, use `review-pr` instead — it reads and comments and never pushes. Read `pr-common/COMMON.md` (sibling skill, same skills root) first. It defines hints, the seen file, the state file, and forge resolution. This document only covers what to *do*. **Merging is forge-scoped.** On **GitHub**, never merge — the final click is the user's. No `gh pr merge`, no `--auto`. On **Gitea**, merge the PR yourself once section 3's conditions all hold; those are the user's own self-hosted repos and the click adds nothing. **Spend nothing while idle.** You do not wait, poll, or arm watchers. The daemon wakes you with a hint when something changes. Do the work the hint points at, then end the turn. The exception is the no-daemon fallback in `COMMON.md`. Entered three ways: a hint (`skill=land`), a handoff from `/work` or `/yolo` right after the PR is opened, or by hand — `/land 47`, `/land `, `/land` on a branch with an open PR. **Config:** `.claude/tracker.json` (or legacy `.claude/linear.json`) at the repo root, if present — see `tracker-common/COMMON.md`. Only needed for tracker-issue closing and `remoteHost`. ## 1. Setup pass Runs once per PR, on first entry. Everything here is work no event will ever trigger, which is why `/work` still calls this skill at PR-open time instead of leaving it to the first hint. **Resolve `N`:** from `$ARGUMENTS` if given (parse the trailing number of a URL), else the PR for the current branch — `gh pr view --json number --jq .number`, or on gitea `GET /repos/$REPO/pulls?state=open&head=:`. No open PR: say so and stop. Do not open one; that's `/work`'s job. **Baseline the seen file**, guarded against re-entry: ```bash seen="$(git rev-parse --git-dir)/pr--seen" if [ ! -f "$seen" ]; then # github 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 ``` Gitea equivalent — issue comments plus reviews: ```bash { curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/issues/$N/comments" | jq -r '.[]?.id' for r in $(curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/pulls/$N/reviews" | jq -r '.[]?.id'); do echo "$r" curl -sS -H "Authorization: token $GITEA_TOKEN" "$BASE/api/v1/repos/$REPO/pulls/$N/reviews/$r/comments" | jq -r '.[]?.id' done; } 2>/dev/null > "$seen" || : > "$seen" ``` **Request the Copilot review** (github only, once). Its comments then arrive as ordinary `reason=comments` hints. Re-request only if its last review is 2+ days old. ```bash if ! gh pr view --json reviews,reviewRequests --jq '.. | .login? // empty' | grep -qi copilot; then gh api -X POST repos///pulls//requested_reviewers \ -f 'reviewers[]=copilot-pull-request-reviewer[bot]' >/dev/null 2>&1 || true fi ``` **Write the state file** — `/pr--state.md` with phase, head SHA, and anything already outstanding. Then check whether CI is already running and handle it as `reason=ci` below. Then **end the turn**. Do not wait for anything. ## 2. Handling a hint Each reason is one query. Nothing new: return silently, per `COMMON.md`. Update the state file whenever the phase or head SHA changes. Every body you post ends with the metadata marker from `COMMON.md`, verbatim — the `agent-meta` JSON object, never an invented tag: ``` ``` ### `reason=comments` List review and issue comments, drop every id already in the seen file, and act on what's left: - **Valid feedback** — fix the code, commit, push. Record the id. - **Misunderstanding** — reply, and record the reply's own id in the same step: ```bash # github rid=$(gh api repos///pulls//comments//replies -f body="" --jq .id) echo "$rid" >> "$seen" ``` ```bash # gitea 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 reply endpoint, so that lands as a loose PR comment. To answer a code comment inside its own thread, post a review instead whose `comments[]` entry repeats the same `path` and `new_position` — gitea groups code comments by position into one conversation (`new_position: 0` for a file-level comment). Record the review id and its comment ids. - **Resolve the thread** once addressed — fix pushed or reply posted: ```bash # github gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: ""}) { thread { isResolved } } }' ``` ```bash # gitea (1.26+; on 404 fall back to a confirming reply as the signal) curl -sS -X POST -H "Authorization: token $GITEA_TOKEN" \ "$BASE/api/v1/repos/$REPO/pulls/comments//resolve" ``` Every unresolved thread gets an action — a fix or a reply. Bot reviewers (Copilot, CodeRabbit, crit) count. Never declare the PR ready over an unaddressed thread. ### `reason=ci` The head SHA moved, so checks are running or done. - github: `gh pr checks ` for the state, `gh run view --log-failed` for a failure. Read only the failing job. - gitea: `GET /repos/$REPO/commits/$SHA/status` — the combined state aggregates every context, not just the newest. Failing: fix, commit, push. That produces another `reason=ci` hint when the new head lands, so don't wait for it. Still pending: return silently. The next hint carries the result. **No checks at all a few minutes after a push** is worth surfacing to the user rather than assuming — silence is not success. You have no watcher to time out, so judge it from the timestamps you can see. ### `reason=conflicts` The base moved under the PR. Merge base into the branch — never rebase and force-push mid-review, which detaches every existing review comment. ```bash git fetch origin && git merge origin/ --no-edit # resolve, commit, push ``` `gh pr update-branch ` (github) or `POST /repos/$REPO/pulls/$N/update` (gitea) does the same thing server-side when there's nothing to resolve by hand. ### `reason=state` Read the PR state. Merged or closed: write the outcome to the state file and go to close-out. Draft flipped to ready: nothing to do beyond noting it. Anything else — usually a label change — is the empty case: return silently. ## 3. Ready All of these must hold: CI green, every thread resolved, approved with no pending review requests, branch not behind the base. On gitea, a PR with no reviewer ever requested and no review posted counts as approved — otherwise a solo PR waits forever for a review that is never coming. A requested or posted review still has to land. Update the branch if the base moved (above), let CI re-run, and wait for the resulting hint. **On GitHub**, that is where you stop — the user's click is the only step left. **On Gitea**, merge it. Squash, server-side so the PR reads "merged" and not "closed": ```bash curl -sS -X POST -H "Authorization: token $GITEA_TOKEN" \ -H "Content-Type: application/json" \ "$BASE/api/v1/repos/$REPO/pulls/$N/merge" -d '{"Do":"squash"}' ``` Do not delete the branch or remove the worktree — the user handles cleanup. ## 4. Close out **Merged (gitea):** report the merge with the PR link, CI green, threads resolved, and one line on what feedback was addressed. **Ready but not merged (github):** report PR ready to merge with the same detail. The merge, the branch delete, and the tracking-issue close are the user's. The review window is often hours and the user may be away. Push a notification so the click can happen from a phone — `mcp__ha-mcp__ha_call_service`, `domain: "notify"`, service `mobile_app_pixel_7_naps` (or `blitz.notifyService` from config), message "PR # ready to merge" plus the URL. On gitea, the same notification instead says the PR merged. `Closes ` in the PR body closes a GitHub or Gitea tracking issue on merge. Only Linear needs follow-up: tell the user to move the issue to Done after merging, or do it yourself if you're still around. **Draft PRs on client repos** publish only on an explicit green light from the user.