The GitHub commands behind the pull-request skill's
create phase. The phase owns every step,
gate and prompt; this file holds the commands and the GitHub and gh facts behind them.
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:
DEFAULT_BRANCH=$(gh api "repos/{owner}/{repo}" --jq '.default_branch')The repository level of pr_open_state comes from the PR's base branch in the base repository,
never from the working tree or the branch being opened. A 404 means the level is unset.
gh api "repos/<base-owner>/<repo>/contents/docs/conventions/source-control.yaml?ref=<base-branch>" \
-H "Accept: application/vnd.github.raw"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.
ISSUE_BODY=""
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.
# One read returns the body beside the state, for the Summary's Problem and intent fallback.
ISSUE_JSON=$(gh issue view "$ISSUE_NUM" --json state,body 2>/dev/null || true)
ISSUE_STATE=$(printf '%s' "$ISSUE_JSON" | jq -r '.state // empty' || true)
ISSUE_BODY=$(printf '%s' "$ISSUE_JSON" | jq -r '.body // empty' || 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
ISSUE_BODY="" # no Problem and intent fallback from an item the PR does not close
fi
fi
# If still empty, the orphan-PR prompt populates CLOSES_LINE below.gh issue view --json routes through GitHub's GraphQL API, and a cloud session behind the GitHub
proxy is served no GraphQL operation at all, so the call fails with HTTP 403 whatever token is
set. Pointer:
Configure cloud environments § GitHub proxy,
which names the refusal and the REST fallback. As-of: 2026-10-11. Recheck trigger: that section
changes its GraphQL entry, or a release note names the GitHub proxy. The 2>/dev/null || true in the block above swallows that 403. Substitute the REST
issues endpoint, which reports state in lower case and body as a string or null. Pointer:
Get an issue, whose schema is
Create an issue's. As-of:
2026-10-26. Recheck trigger: either field's type changes there.
ISSUE_JSON=$(gh api "repos/{owner}/{repo}/issues/${ISSUE_NUM}" 2>/dev/null || true)
ISSUE_STATE=$(printf '%s' "$ISSUE_JSON" | jq -r '.state // empty' || true)
ISSUE_BODY=$(printf '%s' "$ISSUE_JSON" | jq -r '.body // empty' || true)
if [[ "$ISSUE_STATE" == "open" ]]; then # REST returns `open`/`closed`, not `OPEN`/`CLOSED`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>.
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.
# Identity: plain `gh` (the human PR author) by default.
# No reviewers are auto-requested by default — `gh pr create` runs without
# `--reviewer`.
# Always a draft: a `ready` open state is reached by Phase 2.5's flip.
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")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 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:
baseis required.gh pr createdefaults it to the repository's default branch; REST does not. Resolve it over REST as well, since §2.2'sgh repo view --json defaultBranchRefreads the same GraphQL surface and 403s alongside the rest.headis 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 requireshead_repo=<fork-repo-name>.- Send the body from its file with
-F body=@"$BODY_FILE".-F/--fieldreads a value that starts with@from the named file.-f/--raw-fieldwould send the path itself as the body. - The response carries the PR identity. Read
.numberand.html_urlfrom it rather than parsing the number back out of the URL. draftis a boolean, so it goes with-F.-f draft=truesends the string"true", which the API rejects;-Fis the flag that type-converts it. The other literal fields (title,head,base) stay on-f, so a title that readstrueor a number is still sent as a string.
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.
gh pr view --json number,urlIn 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}'.
gh pr create MUST pass --head "$BRANCH" explicitly, since the invoker is not on the branch:
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:
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:
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.