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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Changed

- `pull-request` and `worktree` spokes name forge operations and link their GitHub commands, which now live in per-spoke provider files under `reference/providers/github/pull-request/` and in the provider README (#6941).
17 changes: 11 additions & 6 deletions plugins/source-control/reference/providers/github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ each of them, with the `gh` CLI. Read it before the first forge operation a skil
decision that skill bodies stay forge-neutral and the provider detail lives here is
[ADR 0060](../../../../../docs/adr/0060-abstract-provider-details-in-skills-behind-consumer-conventions-and-adapters.md).

The per-phase spokes under each skill's `reference/` directory (for example the pull-request
skill's `create.md`, `monitor.md`, `merge.md` and `readiness.md`, and the babysit-prs skill's
`loop.md` and `safety.md`) still carry their full GitHub procedures. This table is the index from
an operation to its command; a spoke is the authority on the full step around it.
This file is the index from an operation to its command; a skill's spoke is the authority on the
full step around it. A spoke whose steps need more than a table row has a provider file of the same
name under a directory named for its skill: [`pull-request/`](pull-request/) holds the GitHub
commands, scripts and `gh` facts behind the pull-request skill's `create.md`, `ready-for-review.md`,
`monitor.md`, `readiness.md` and `merge.md`, and each spoke links its steps there. The worktree
skill's spokes need only the rows below. The babysit-prs skill's `loop.md` and `safety.md` still
carry their full GitHub procedures.

A pull request is GitHub's change request; this file uses its terms.

Expand All @@ -19,6 +22,7 @@ A pull request is GitHub's change request; this file uses its terms.
|---|---|
| The current login | `gh api user --jq .login` |
| The current repository as `owner/repo` | `gh repo view --json nameWithOwner -q .nameWithOwner` |
| The repository's default branch | `gh repo view --json defaultBranchRef -q .defaultBranchRef.name`; in a sandboxed session, `gh api "repos/{owner}/{repo}" --jq '.default_branch'` |
| Read a tracked file from a repository's default branch | `gh api repos/<owner>/<repo>/contents/<path>` (the `content` field is base64) |

## Resolve a pull request
Expand All @@ -33,6 +37,7 @@ A pull request is GitHub's change request; this file uses its terms.
| Head commit | `gh pr view <N> --json headRefOid -q .headRefOid` |
| Mergeability | `gh pr view <N> --json mergeable`; `CONFLICTING` means GitHub runs no workflows until the base is integrated |
| The PR for a branch | `gh pr list --head <branch> --json number,title,state` |
| Whether a branch's PR merged (worktree cleanup) | `gh pr list --state merged --head <branch>`; a non-empty result means merged |
| Every PR, one batched read for worktree cross-reference | `gh pr list --state all --json number,title,state,headRefName` |
| Your own open PRs here | `gh pr list --state open --author "@me" --json number` |
| Your own open PRs across an owner | `gh search prs --state open --author @me --owner <owner> --json repository` |
Expand All @@ -48,7 +53,7 @@ entry and pass it to every later `gh` call in that phase.
| Check out the PR head | `gh pr checkout <N>`, or `gh pr checkout <N> --detach` when the branch is locked in another worktree. It is fork-safe: a fork's head branch is not fetchable from `origin` by name |
| Merge the base into the head | `gh pr update-branch <N>` (pull-request `ready-for-review.md`) |
| Flip a draft ready | `gh pr ready <N>`, run only inside `/source-control:pull-request ready`, never bare |
| Merge pinned to the reviewed head | `gh pr merge <N> --squash --match-head-commit <sha>`, never `--admin` or `--auto` (pull-request `merge.md` §4.2.1 owns the merge-queue form). A babysit lane merges only through its merge-gate wrapper (babysit-prs `safety.md`) |
| Merge pinned to the reviewed head | `gh pr merge <N> --squash --match-head-commit <sha>`, never `--admin` or `--auto` (pull-request `merge.md` §4.2.1 owns the merge-queue form; its commands are in [pull-request/merge.md](pull-request/merge.md#merge-after-ai-review)). A babysit lane merges only through its merge-gate wrapper (babysit-prs `safety.md`) |
| Open a tracking issue | `gh issue create` |

## Checks and CI logs
Expand Down Expand Up @@ -96,7 +101,7 @@ text, and a quote, `$(…)` or backtick inside it can break out of an inline she
|---|---|
| Effective rules on a branch | `gh api repos/<owner>/<repo>/rules/branches/<branch>` |
| Classic protection fallback | `gh api repos/<owner>/<repo>/branches/<branch>/protection` |
| Whether head branches are deleted on merge | the repository's `delete_branch_on_merge` field |
| Whether head branches are deleted on merge | the repository's `delete_branch_on_merge` field: `gh api repos/{owner}/{repo} --jq '.delete_branch_on_merge'` |

## Sandboxed sessions: GraphQL is restricted

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# GitHub: pull-request create

The GitHub commands behind the pull-request skill's
[create phase](../../../../skills/pull-request/reference/create.md). The phase owns every step,
gate and prompt; this file holds the commands and the GitHub and `gh` facts behind them.

## Default branch over REST

`gh repo view --json` sends the same `RepositoryInfo` GraphQL query `gh pr create` does, so in a
sandboxed session it fails with `HTTP 403`. Substitute:

```bash
DEFAULT_BRANCH=$(gh api "repos/{owner}/{repo}" --jq '.default_branch')
```

## Closes line without a tracker

```bash
CLOSES_LINE=""
REFS_LINES="" # newline-separated `Refs #Y — <why>` bullets for the ## Related section, populated
# by the multi-issue or orphan-PR prompts below; never a closing keyword and
# not a linkage line — see §2.4.1 for routing.
if [[ "$ISSUE_NUM" =~ ^[0-9]+$ ]]; then # the GitHub default needs an issue number
# Validate issue exists in current repo BEFORE shipping `Closes #N`.
# GitHub auto-close only fires when the issue exists, lives in this repo,
# and is open at merge time. A typo'd branch like
# `feat/99999-foo` would otherwise ship a misleading `Closes #99999` line.
ISSUE_STATE=$(gh issue view "$ISSUE_NUM" --json state --jq '.state' 2>/dev/null || true)
if [[ "$ISSUE_STATE" == "OPEN" ]]; then
CLOSES_LINE="Closes #${ISSUE_NUM}"
else
echo "⚠ Branch suggests Closes #${ISSUE_NUM}, but that issue is missing or not open in this repo. Falling back to interactive prompt." >&2
ISSUE_NUM="" # fall through to orphan-PR 2-option prompt below
fi
fi
# If still empty, the orphan-PR prompt populates CLOSES_LINE below.
```

## Issue state over REST

`gh issue view --json` routes through GitHub's GraphQL API, and sandboxed sessions (Claude Code on
the web and remote execution) serve only a pinned set of GraphQL operations, refusing the rest with
`HTTP 403`. Verified 2026-09-06 against Claude Code 2.1.263 and
[Configure cloud environments](https://code.claude.com/docs/en/cloud-environments#github-proxy):
"the proxy serves only a pinned set of GraphQL operations for pull-request workflows", rejecting the
rest with a 403 reading `This GraphQL query is not enabled for this session` and naming the REST
fallback `gh api repos/{owner}/{repo}/...`. The restriction applies to every request through the
proxy regardless of the credentials supplied, so a `GH_TOKEN` gets the same 403. Recheck when that
page stops carrying that span, or when a release note names the GitHub proxy or its GraphQL
allowlist. The `2>/dev/null || true` in the block above swallows that 403. Substitute the REST
issues endpoint, which reports `state` in lower case:

```bash
ISSUE_STATE=$(gh api "repos/{owner}/{repo}/issues/${ISSUE_NUM}" --jq '.state' 2>/dev/null || true)
if [[ "$ISSUE_STATE" == "open" ]]; then # REST returns `open`/`closed`, not `OPEN`/`CLOSED`
```

## Placeholder anchoring

`gh api` has no `--repo` flag. `{owner}` and `{repo}` expand from the repository of the **current
directory**, or from `GH_REPO`. Out of tree, run the call from the worktree
(`( cd "$WT" && gh api … )`) or prefix `GH_REPO=<owner>/<repo>`.

## Body overrides the template

A body passed with `gh pr create --body-file` (or `--body`) fully overrides
`.github/PULL_REQUEST_TEMPLATE.md` (cli/cli #10751). `gh pr create` has no option that uploads a
file.

## Open the draft PR

```bash
# Identity: plain `gh` (the human PR author) by default.
# No reviewers are auto-requested by default — `gh pr create` runs without
# `--reviewer`.
PR_URL=$(gh pr create --draft --head "$BRANCH" --title "<type>: <description>" --body-file "$BODY_FILE")

# Extract PR number from URL (gh pr create outputs the URL on success).
# This number is the source of truth for the rest of this phase — pass it
# explicitly to every subsequent gh call.
PR_NUMBER=$(basename "$PR_URL")
```

## Open the PR over REST

`gh pr create` sends a `RepositoryInfo` GraphQL query as its repo-info preamble, before it touches
the pull-request API at all, so under the pinned-GraphQL restriction it returns `HTTP 403` having
created nothing. `POST /repos/{owner}/{repo}/pulls` is REST and works. It requires `head` and
`base`, and `title` unless an existing `issue` is being converted; `body`, `draft`, and
`maintainer_can_modify` are optional. Five differences from `gh pr create` matter:

- **`base` is required.** `gh pr create` defaults it to the repository's default branch; REST does not. Resolve it over REST as well, since §2.2's `gh repo view --json defaultBranchRef` reads the same GraphQL surface and 403s alongside the rest.
- **`head` is bare `<branch>` only for a same-repo PR.** From a fork (the triangular flow §2.7's remote resolver allows), it must be namespaced `<fork-owner>:<branch>`, and when both repositories belong to the same organization, REST additionally requires `head_repo=<fork-repo-name>`.
- **Send the body from its file with `-F body=@"$BODY_FILE"`.** `-F`/`--field` reads a value that starts with `@` from the named file. `-f`/`--raw-field` would send the path itself as the body.
- **The response carries the PR identity.** Read `.number` and `.html_url` from it rather than parsing the number back out of the URL.
- **`draft` is a boolean, so it goes with `-F`.** `-f draft=true` sends the string `"true"`, which the API rejects; `-F` is the flag that type-converts it. The other literal fields (`title`, `head`, `base`) stay on `-f`, so a title that reads `true` or a number is still sent as a string.

```bash
BASE=$(gh api "repos/{owner}/{repo}" --jq '.default_branch')
PR_JSON=$(gh api --method POST "repos/{owner}/{repo}/pulls" \
-f title="<type>: <description>" \
-f head="$BRANCH" \
-f base="$BASE" \
-F body=@"$BODY_FILE" \
-F draft=true)
PR_URL=$(printf '%s' "$PR_JSON" | jq -r '.html_url')
PR_NUMBER=$(printf '%s' "$PR_JSON" | jq -r '.number')
```

`--method POST` and `-X POST` are the same flag. Section numbers (§2.2, §2.7) are the create
phase's.

The REST form has no hook backstop: `pr-body-linkage-gate.sh` matches `gh pr create` /
`gh pr edit` and names `gh api …/pulls` among the invocations it deliberately does not see.

## PR identity

```bash
gh pr view --json number,url
```

In a sandboxed session, take identity from the create response, or re-read it with
`gh api "repos/{owner}/{repo}/pulls/<n>" --jq '{number, html_url}'`.

## Open the PR from an out-of-tree orchestrator

`gh pr create` MUST pass `--head "$BRANCH"` explicitly, since the invoker is not on the branch:

```bash
PR_URL=$(gh pr create --draft --head "$BRANCH" --title "<type>: <description>" --body-file "$BODY_FILE")
```

In a sandboxed session that 403s, the REST form, run from the worktree:

```bash
PR_JSON=$( cd "$WT" && gh api --method POST "repos/{owner}/{repo}/pulls" \
-f title="<type>: <description>" -f head="$BRANCH" -f base="$BASE" -F body=@"$BODY_FILE" -F draft=true )
```

Resolve the base branch the same anchored way:
`BASE=$( cd "$WT" && gh api "repos/{owner}/{repo}" --jq '.default_branch' )`.

On a triangular flow, name the base repository explicitly and namespace `head`:

```bash
BASE_REPO="<base-owner>/<repo>" # the PR's target, not the push destination
PR_JSON=$(GH_REPO="$BASE_REPO" gh api --method POST "repos/{owner}/{repo}/pulls" \
-f title="<type>: <description>" -f head="<fork-owner>:$BRANCH" \
-f base="$BASE" -F body=@"$BODY_FILE" -F draft=true)
```

`GH_REPO` overrides the cwd-derived placeholders outright, so this form needs no `cd` at all. On a
triangular flow resolve `$BASE` through `GH_REPO` too, as
`BASE=$(GH_REPO="$BASE_REPO" gh api "repos/{owner}/{repo}" --jq '.default_branch')`, not through the
`cd "$WT"` form above, which would read the fork's default branch.
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# GitHub: pull-request merge

The GitHub commands behind the pull-request skill's
[merge phase](../../../../skills/pull-request/reference/merge.md). The phase owns every condition,
hold and ordering; this file holds the commands and the GitHub and `gh` facts behind them.

## Re-check checks and comments before merge

```bash
# 1. Re-check all check runs for any state changes
gh pr checks <pr_number> --json name,state,bucket

# 2. Re-check for new comments since monitoring completed (all 3 sources, paginated)
gh api --paginate "repos/{owner}/{repo}/pulls/<pr_number>/reviews?per_page=100" | jq -r '.[].user.login'
gh api --paginate "repos/{owner}/{repo}/pulls/<pr_number>/comments?per_page=100" | jq -r '.[].user.login'
gh api --paginate "repos/{owner}/{repo}/issues/<pr_number>/comments?per_page=100" | jq -r '.[].user.login'
```

## Stale-base guard

```bash
gh pr view <pr_number> --json mergeStateStatus,baseRefName,headRefOid
gh api repos/{owner}/{repo}/compare/<baseRefName>...<headRefOid>
```

Update a behind branch with a merge-forward or `gh pr update-branch`.

## Squash merge in a regular checkout

```bash
gh pr merge <pr_number> --squash --delete-branch
```

## Squash merge in a linked worktree

Omit `--delete-branch`: on older gh it tries to check out the default branch locally, which fails
while another worktree holds it, and exits 1 even though the merge succeeded.

```bash
gh pr merge <pr_number> --squash && {
if [ "$(gh pr view <pr_number> --json state -q .state)" = MERGED ]; then
REMOTE=$(bash "<skill-dir>/scripts/resolve-remote.sh" --push <branch>) && git push "$REMOTE" --delete <branch>
else
echo 'PR not merged yet (merge queue or auto-merge); delete <branch> once it reads MERGED' >&2
fi
}
```

Verified 2026-09-29 against [cli/cli#14007](https://github.com/cli/cli/pull/14007), which ships in
gh 2.99.0 and makes `gh pr merge --delete-branch` skip the local delete when the head is checked out
in the current linked worktree; earlier gh, such as 2.98.0, fails as described. Recheck when the
minimum gh this plugin supports is 2.99.0 or later, at which point the split is no longer needed.

## Async merge API

`gh pr merge` and `gh pr view` run over GraphQL; the async merge API is REST. `gh` has no command
for it yet, so call it with `gh api`, pinned to the head verified before merge and never with
`bypass_rules` true:

```bash
HEAD_SHA=$(gh api "repos/{owner}/{repo}/pulls/<pr_number>" --jq .head.sha)
gh api -X PUT "repos/{owner}/{repo}/pulls/<pr_number>/merge-async" \
-f sha="$HEAD_SHA" -f merge_action=direct_merge -f merge_method=squash -F bypass_rules=false
```

For a merge queue send `-f merge_action=merge_queue` and drop `merge_method`. The response carries a
request `uuid` (a 409 means one is already pending and returns its `uuid`); poll
`gh api "repos/{owner}/{repo}/pulls/<pr_number>/merge-async/<uuid>" --jq .status` until it reads
`merged`, `enqueued`, or `failed`. Request fields and statuses:
[merge a pull request asynchronously](https://docs.github.com/rest/pulls/pulls?apiVersion=2026-03-10#merge-a-pull-request-asynchronously),
as of 2026-10-02; recheck when a `gh` release adds a command for it or that page changes a field or
status.

## Merge after AI review

```bash
[ "$(gh repo view --json owner --jq .owner.login)" = melodic-software ] \
|| { echo 'not a melodic-software repository: wait for the user' >&2; exit 1; }
HEAD_SHA=$(gh api "repos/{owner}/{repo}/pulls/<pr_number>" --jq .head.sha)
RULES=$(gh api --paginate --slurp "repos/{owner}/{repo}/rules/branches/<baseRefName>" | jq 'add')
jq -e 'any(.[]; .type == "required_status_checks"
and any(.parameters.required_status_checks[]; .context == "ci-status"))' <<<"$RULES" >/dev/null \
|| { echo 'base ruleset does not require ci-status: wait for the user' >&2; exit 1; }
RUNS=$(gh api --paginate --slurp "repos/{owner}/{repo}/commits/$HEAD_SHA/check-runs?per_page=100")
jq -e '[.[].check_runs[]] as $r
| def lane($names): [$r[] | select(.name | IN($names[]))]
| length > 0 and all(.status == "completed" and .conclusion == "success");
($r | length) == .[0].total_count
and lane(["pr-review / claude-review-status"])
and lane(["pr-review-security / security-review", "security-review / security-review",
"claude-security-review-status"])' <<<"$RUNS" >/dev/null \
|| { echo 'AI review not complete on the pinned head: hold' >&2; exit 1; }
[ "$(gh pr view <pr_number> --json headRefOid,mergeStateStatus \
--jq "select(.headRefOid == \"$HEAD_SHA\") | .mergeStateStatus")" = CLEAN ] \
|| { echo 'PR not CLEAN at the pinned head: hold' >&2; exit 1; }
if jq -e 'any(.[]; .type == "merge_queue")' <<<"$RULES" >/dev/null; then
gh pr merge <pr_number> --match-head-commit "$HEAD_SHA"
else
gh pr merge <pr_number> --squash --match-head-commit "$HEAD_SHA"
fi
```

Behind a merge queue `gh pr merge` enqueues and returns; had required checks not passed it would arm
auto-merge instead, which the `CLEAN` read rules out.

### Poll the merge queue

```bash
while :; do
S=$(gh api graphql -F owner='{owner}' -F name='{repo}' -F number=<pr_number> -f query='
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) { pullRequest(number: $number) { state isInMergeQueue } } }' \
--jq '.data.repository.pullRequest | "\(.state) \(.isInMergeQueue)"')
case "$S" in
MERGED*) break ;;
'OPEN true') sleep 30 ;;
*) echo "PR left the merge queue unmerged ($S)" >&2; exit 1 ;;
esac
done
```

- **Pointer**: how `gh pr merge` behaves on a merge-queue base and what `--match-head-commit` does:
[gh pr merge](https://cli.github.com/manual/gh_pr_merge); `isInMergeQueue`:
[GraphQL PullRequest object](https://docs.github.com/graphql/reference/objects#pullrequest).
- **As of**: 2026-10-09
- **Recheck trigger**: a gh release that changes `gh pr merge` flags or merge-queue handling, or a
rename of the AI review lanes' check names.

## Latest run on the default branch

```bash
gh run list --branch "$DEFAULT_BRANCH" --limit 1 --json conclusion,displayTitle \
--jq '.[0] | "\(.conclusion): \(.displayTitle)"'
```
Loading
Loading