Opening a GitHub PR via gh
When to use
The user says "open a PR" / "send a pull request" and gh is
available (which gh succeeds). Works equally for first-party repos
and forks.
Problem
Getting all of these right on the first try:
- Do I need to fork, or can I push a branch directly?
- What's the
basebranch (mainvsmaster)? - Body with multiple paragraphs, backticks, and lists — how to pass?
Solution
Determine push access and the fork question.
gh auth status # who am I? git -C <repo> remote -v # what does origin point to?- If
originis owned by the authenticated user → push directly to a branch, no fork needed. - Otherwise → fork first:
gh repo fork --remote=true.
- If
Create a descriptive branch, commit, push.
git -C <repo> checkout -b <slug> git -C <repo> add <paths> git -C <repo> commit -F .commit_msg.tmp # see related skill git -C <repo> push -u origin <slug>Check the default branch, don't assume
main:git -C <repo> symbolic-ref refs/remotes/origin/HEAD | sed 's@^.*/@@'Create the PR with a body file (see related skill):
gh pr create \ --repo <owner>/<repo> \ --base <default-branch> \ --head <slug> \ --title "<one-line title>" \ --body-file pr_body.mdghprints the PR URL on success. Capture it.
Example
gh pr create --repo chen3feng/cn-doc-style-guide \
--base master --head add-md-style-tools \
--title 'Add Python tools for checking and auto-fixing Chinese Markdown docs' \
--body-file pr_body.md
# → https://github.com/chen3feng/cn-doc-style-guide/pull/1
Pitfalls
Anti-pattern — never do this:
gh pr create --body "$(cat <<'EOF' … multi-line body with ``` fences, $(…), backslashes … EOF )"This triple-nests double quotes, command substitution, and a heredoc. In zsh (the default agent shell on macOS) any backtick fence or
$(…)inside the body will unbalance the parser and the command hangs atcmdand dquote>/cmdand quote>— the agent sees "command interrupted" and the PR is never filed. Always write the body to a file via the editor tool and pass--body-file <path>. See shell-heredoc-and-multiline-strings.--titlewith backticks or$through the shell is still fragile; prefer--body-fileand keep the title short enough to put in a single-quoted string.--body "…\n…"does NOT give you line breaks.ghtakes the value verbatim;\nstays as a two-character literal and the PR body on GitHub will show\nin the middle of sentences. Always use--body-file <path>for anything longer than one line (see shell-heredoc-and-multiline-strings).Very long
gh pr createinvocations get backgrounded by some agent shells — the command appears to "hang" and the PR URL never comes back. Keep the command short (use--body-fileinstead of a giant inline--body), and verify withgh pr list --repo <owner>/<repo> --state openafterwards.If you already created the PR and the body is malformed (e.g. literal
\nshowing up), fix it without a new PR:gh pr edit <N> --repo <owner>/<repo> --body-file pr_body.mdIf
gh pr createcomplains about "no commits between base and head", you forgot to push the branch first, or you branched off the wrong base — see rebase-on-fresh-base-after-merge.If the target repo has branch protection or required checks, the command still succeeds — the PR is created but will sit in a pending state. Mention this to the user if relevant.
gh pr view <N> --json mergeable,mergeStateStatuscan return"UNKNOWN"/"UNKNOWN"right after another PR lands on the same base. GitHub computes mergeability asynchronously; the value is not populated instantly. If you need a decision (e.g. "does this PR still merge cleanly after the one we just merged?"), wait a few seconds and re-query:gh pr view <N> --json mergeable,mergeStateStatus # → {"mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN"} sleep 5 gh pr view <N> --json mergeable,mergeStateStatus # → {"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN"}Don't interpret a single
UNKNOWNas "there's a conflict" — it just means GitHub hasn't finished the background check yet. Thegh pr checksand the web UI exhibit the same delay.gh run rerun --job <id>wants the display name upstream, not the YAML job key. When you select a specific workflow job to rerun (or view),--jobtakes the job's numericdatabaseIdand that ID is resolved against the display name shown in the GitHub Checks UI — which is thename:field from the workflow YAML, not thejobs.<key>identifier above it. Pattern-matching the YAML key will silently miss:# .github/workflows/python-package.yml jobs: e2e-smoke: # ← YAML key name: E2E smoke (blade-test) # ← display name (what gh sees)# WRONG — filters on the YAML key, returns empty list, rerun fails. JOB_ID=$(gh run view <run> --json jobs \ | jq -r '.jobs[] | select(.name=="e2e-smoke") | .databaseId') gh run rerun <run> --job "$JOB_ID" # → IndexError / "Flags: ... -j, --job string ..." # RIGHT — list jobs first, copy the actual display name. gh run view <run> --json jobs \ | jq -r '.jobs[] | "\(.databaseId) \(.name) \(.conclusion)"' # 73167567958 E2E smoke (blade-test) success gh run rerun <run> --job 73167567958The same applies to
gh run view <run> --log --job <id>when you want to grep a specific job's log. Always list.jobs[].namefirst and copy the exact string; never assume it equals the YAML key.