Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/land-pr
1 change: 1 addition & 0 deletions .agents/skills/open-pr
2 changes: 2 additions & 0 deletions .claude/skills/land-pr/.pr-kit
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Installed by the PR kit (open-pr/kit.ts). Edit the kit at its source, not here.
version 24 b18d559e4c1c3dece07b9486b5418a2cff7cad63e65a328bf4c11db8be7f1c44
62 changes: 62 additions & 0 deletions .claude/skills/land-pr/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
name: land-pr
description: Drive an open pull request to merge-ready (reviewed, CI green, comments resolved, spec fidelity checked, review bot passed on the head), then merge only on Ossie's word. Owns the review-bot pass signal, the convergence rule for fix rounds, spec fidelity, and merge proof. Use after open-pr, for "babysit", "get it green", "address the review comments", or any `gh pr merge`.
metadata:
category: git/pr
lanes: [claude, codex, pi]
machines: [universal]
requires: [bun, git, gh]
---

# land-pr

The PR gate refuses a merge unless the PR body passes the contract and the review bot passed the exact head. This skill gets the PR to merge-ready, the point where Ossie can say "merge". Merging is a separate step that takes his word.

## Merge policy

| Condition | Rule |
|---|---|
| Pass signal | The `pullfrog-approval` check-run from the Pullfrog app concludes `success` on the exact head SHA. Missing, running, failed, or stale verdicts do not pass, and neither does a second run of that name or one from another app. The kit's source repo is Ossie's curated settings repo and is never gated by a review bot. |
| Source repo merges | Name the repo in the merge command: `-R <owner>/<repo>` or the PR URL. The gate grants the exemption only then. |
| Conflict fix | When the bot approved the previous head and the only new commit merges the base to resolve conflicts, prove the resolution and merge on that approval. The gate allows this case. |
| CI | Required checks pass on the same head. |
| Threads | Fix actionable findings. Resolve bot threads with evidence. Resolve or disposition every other thread. |
| Authorization | Merge only on Ossie's word: an explicit "merge", or a request whose wording implies it ("ship it", "get it merged", "merge when green"). Without it, stop at merge-ready and report the head SHA, CI, bot verdict, threads, and spec fidelity. Never merge when the repo's AGENTS.md forbids agent merges. |
| Other bots | Open Code Review, CodeRabbit, Greptile, and Codex are advisory unless branch rules require them. |

## Convergence rule

A stale PR costs more than one more round. Keep fixing while each round makes progress.

- Fix only confirmed, in-scope blockers. Verify each bot finding against the source first. Dismiss a false positive with a written reason.
- Count the reviews each open finding has been through. A push or a restart does not reset the count.
- The loop spins when one finding is still open at its fourth review, after three fixes.
- The first spin means the approach is wrong, not the patch. Throw out the fix, redesign from the original problem, and push the new approach as the next round. Do not patch the patch.
- If the redesign spins too, stop. Open an issue that names the root cause, link it in the PR, and report. The cause is usually a miss in the local review, or a disagreement between the local review and Pullfrog. A spinning loop is a bug to fix, not a reason to leave the PR.
- When a verdict never starts (no `pullfrog-approval` check-run on the head after 60 minutes), stop and report. That is a runner or app fault, not a review round.

## Steps

1. **Read the state.** `gh api repos/{owner}/{repo}/pulls/<n>` for the head SHA and draft flag, then `gh api repos/{owner}/{repo}/commits/<sha>/check-runs` for CI and `pullfrog-approval`. In a cloud session use REST only, because the proxy blocks GraphQL.
2. **Fix CI.** Read the failing log (`gh run view <id> --log-failed`). Tell a real break from a known infra flake. Fix real breaks in a new commit.
3. **Handle review comments.** Fetch them with `gh api repos/{owner}/{repo}/pulls/<n>/comments` and `.../reviews`. Fix, decline with a reason, or defer to an issue. Add each to the PR body's Review table.
- Mark each Pullfrog finding that the local review missed with `missed locally` in its Reason cell, so the local review instructions can learn from it.
- List unresolved threads with variables. Set `owner`, `repo`, and `pr` from the PR, then copy the returned thread ID into `thread_id`. Pagination includes threads after the first 50.

```bash
gh api graphql --paginate -f query='query($owner:String!,$repo:String!,$n:Int!,$endCursor:String){repository(owner:$owner,name:$repo){pullRequest(number:$n){reviewThreads(first:50,after:$endCursor){pageInfo{hasNextPage endCursor} nodes{id isResolved comments(first:1){nodes{databaseId}}}}}}}' -f owner="$owner" -f repo="$repo" -F n="$pr" --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)'
gh api graphql -f query='mutation($id:ID!){resolveReviewThread(input:{threadId:$id}){thread{isResolved}}}' -F id="$thread_id"
```

4. **Wait on the real state change.** Watch the check-run, not a timer. Count the round and apply the convergence rule.
- When a T3 watch is the wake source and Bash supports `run_in_background`, also arm one bounded `until` loop on the head's `pullfrog-approval` check-run as a backstop.
5. **Check spec fidelity before merge.** Diff the result against the issue, the plan, or the mock. Post every deviation, or "no deviations", as a PR comment.
6. **Merge, on Ossie's word.** Without it, stop here and report merge-ready. With it, first check allowed methods with `gh api repos/{owner}/{repo} --jq '{allow_squash_merge,allow_merge_commit,allow_rebase_merge}'`. Follow the repo's required method if allowed. Otherwise fetch main and run `git log origin/main --first-parent -10 --format='%h %p %s'`. Two parents show a merge commit. Squash and rebase history can look the same. Use an allowed method supported by policy or history, and prefer squash when neither names one. Stop if the evidence conflicts with the settings. Run `gh pr merge <n> <method flag>` alone in its call, with `-R <owner>/<repo>` when the current directory is not that repo's checkout. The gate checks a merge only when nothing runs before it but a literal `cd` or a `set` of `-e`, `-u`, `-x`, or `-o pipefail`, and nothing runs beside or after it, not even a pipe into a filter, since any other command can move the head. The merge is the only command in its call, so `git pull` or `git checkout main` runs in a later call. The gate checks the policy again.
7. **Prove the merge.** Only `gh api repos/{owner}/{repo}/pulls/<n> --jq .merged` returning `true` proves it. A tool's success message does not.

## Never

- Never pass `--no-verify`, skip a hook, or bypass the gate. Fix the cause or report the blocker.
- Never force-push, amend, or rebase a branch others fetched without Ossie's yes for that push.
- Never write `CHANGELOG.md` on a PR branch. The release on main writes it.
- Never open or leave a PR as a draft. Drafts get no review-bot coverage.
89 changes: 89 additions & 0 deletions .claude/skills/land-pr/merge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* The merge decision: may this PR head merge, judged from the review bot's check-runs.
* Pure, so the gate and tests share it. The gate fetches the state over the REST API.
*
* Pullfrog is the active review bot. Its `pullfrog-approval` check-run concluding
* `success` on the exact head is the pass signal. The gate reads the latest run per check
* suite, and Pullfrog has one suite per commit, so its reruns collapse to one run. A second
* run of that name, or a run from another app, means another producer used the name, and
* no verdict on that commit is trusted. One exception: when the bot approved
* the previous head and the head is a merge commit on top of it (a conflict fix), the
* approval carries forward, provided the merged-in parent is on the base branch and no
* finished verdict on the head itself says otherwise.
*
* The kit's source repo is Ossie's curated settings repo, and no review bot gates it. The
* list lives here in the kit, never in a file the target repo could edit. It holds SHA-256
* digests of lowercase `owner/name`, so the kit can ship to public repos without naming a
* private one. The exemption holds only when the merge command names the repo itself,
* because a repo guessed from the cwd or the environment can differ from the one gh merges.
*/

export const PASS_SIGNAL = "pullfrog-approval";
/** Slug of the GitHub App that publishes PASS_SIGNAL. */
export const PASS_APP = "pullfrog";

const BOT_EXEMPT = new Set([
"6159f38c55dea314c96c15a2b5ddf2edd32a5b51019c9d0b4521e2ff077c0815",
"8425c2e91b73ef6b8e23370c43b9c5d4bb3db376684cbadcc131c269dff47bbe",
]);

/** True for a repo that no review bot gates. `repo` is `owner/name`, any case. */
export const botExempt = (repo: string): boolean =>
BOT_EXEMPT.has(new Bun.CryptoHasher("sha256").update(repo.toLowerCase()).digest("hex"));

export interface CheckRun {
name: string;
status: string;
conclusion: string | null;
/** Slug of the GitHub App that created the run. */
app: string;
}

export interface HeadState {
/** The PR's base repo as GitHub reports it, `owner/name`. */
repo: string;
/** The merge command named the repo with `-R`, a PR URL, or a literal REST path. */
pinned: boolean;
sha: string;
parents: string[];
/** Latest check-runs named PASS_SIGNAL on the head. */
runs: CheckRun[];
/** Latest check-runs named PASS_SIGNAL on parents[0]. Fetched only for a merge commit. */
parentRuns?: CheckRun[];
/** For a merge commit: parents[1] is the base branch head or one of its ancestors. */
mergesBase?: boolean;
}

export type MergeVerdict =
| { ok: true; via: "approval" | "conflict-fix" | "exempt" }
| { ok: false; reason: string };

/**
* The bot's current verdict on one commit. `open` (missing or running) lets a conflict fix
* inherit its parent's approval. `refused` (finished non-success, or untrusted) never does.
*/
export type Verdict = { state: "pass" } | { state: "open" | "refused"; why: string };

export function verdictOf(runs: CheckRun[]): Verdict {
const named = runs.filter((r) => r.name === PASS_SIGNAL);
if (named.length === 0) return { state: "open", why: `no ${PASS_SIGNAL} check-run exists` };
if (named.length > 1) {
return { state: "refused", why: `${named.length} latest ${PASS_SIGNAL} check-runs exist (apps: ${named.map((r) => r.app).join(", ")}), so the verdict is ambiguous` };
}
const [run] = named;
if (run.app !== PASS_APP) return { state: "refused", why: `${PASS_SIGNAL} comes from the ${run.app} app, not ${PASS_APP}` };
if (run.status !== "completed") return { state: "open", why: `${PASS_SIGNAL} is ${run.status}` };
return run.conclusion === "success" ? { state: "pass" } : { state: "refused", why: `${PASS_SIGNAL} concluded ${run.conclusion}` };
}

export function decideMerge(head: HeadState): MergeVerdict {
if (botExempt(head.repo)) {
if (head.pinned) return { ok: true, via: "exempt" };
return { ok: false, reason: `${head.repo} skips the review bot only when the merge names it. Pass -R ${head.repo} or the PR URL.` };
}
const verdict = verdictOf(head.runs);
if (verdict.state === "pass") return { ok: true, via: "approval" };
const conflictFix = verdict.state === "open" && head.parents.length === 2 && head.mergesBase === true;
if (conflictFix && head.parentRuns && verdictOf(head.parentRuns).state === "pass") return { ok: true, via: "conflict-fix" };
return { ok: false, reason: `${verdict.why} on head ${head.sha.slice(0, 7)}. A missing, running, failed, or untrusted verdict does not pass.` };
}
2 changes: 2 additions & 0 deletions .claude/skills/open-pr/.pr-kit
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Installed by the PR kit (open-pr/kit.ts). Edit the kit at its source, not here.
version 24 b18d559e4c1c3dece07b9486b5418a2cff7cad63e65a328bf4c11db8be7f1c44
Loading