Create PR

SkillDev tools

Create or update GitHub PR with gh CLI. Auto-extracts ticket ID from branch name, generates title/summary from commits. Auto-detects existing PR and switches to update mode. Supports --stack for stacked PR chains (per-layer PRs with chained bases; never executes push/rebase). Default: --dry-run (show command, don't execute). Use when: user asks to open/create/update a PR, says /create-pr, wants a stacked PR chain, wants to refresh PR description after new commits, or says 'update pr', 'update PR title', 'refresh PR body'.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Create PR skill

What this skill tells your AI

The instructions your AI receives, as published by sd0xdev/sd0x-harness in skills/create-pr/SKILL.md and read by ahel’s review.

Input

/create-pr [--head <branch>] [--base <branch>] [--title <title>] [--stack <branch...>] [--update] [--execute] [--dry-run]

  • --head: Source branch (default: current branch)
  • --base: Target branch (default: {TARGET_BRANCH} or main)
  • --title: Override auto-generated title (rejected with --stack)
  • --stack: Stacked PR chain mode, bottom layer first (mutually exclusive with --head; the bottom layer's base follows the same --base / {TARGET_BRANCH} / main resolution as normal mode) — see Stacked PR Mode
  • --update: Force update mode (re-generate title/body for existing PR)
  • --dry-run: Show command without executing (default). Scopes to mutating gh calls — the Step 1 git fetch --prune origin still runs, so the preview reflects the server's real refs
  • --execute: Actually create/update the PR (requires user confirmation)
  • No args: use current branch → default target, dry-run mode. Auto-detects existing PR → update mode

Workflow

0. Mode Dispatch (first, before anything else)

When --stack is present: read references/stack-mode.md and run Phases A–D from there. Skip generic Steps 1, 5, 6 and 7 entirely — Phase A must run git fetch --prune origin and classify sync state before any PR planning, so the generic ls-remote / local base..head path must not run first. What is reused, per layer, exactly as Phase C directs:

ReusedSkipped
Steps 2–4 content generation (ticket ID, title, body)Step 1 gather — Phase A replaces it
Step 4b sanitization, Step 7b post-creation verifyStep 5 pre-flight + mode detection — Phase A/B replace it
Step 5a's smart-diff update logic — the routing decision is Phase B's, the diff-and-update mechanics are Step 5a'sSteps 6–7 single-PR output/execute — Phase C emits per-layer commands instead

Otherwise continue with Step 1 below.

1. Gather Info

The fetch runs first and alone — the ref-range reads below it (git log and git diff over refs/remotes/origin/*) depend on the remote-tracking refs it refreshes, so it cannot join the parallel batch. It runs even in --dry-run (the dry-run promise at the top scopes to mutating gh calls; refreshing and pruning local remote-tracking refs is how the preview describes the commits that would actually ship). ls-remote in the next fence only LISTS the server's refs — it never updates refs/remotes/* — so without this fetch they can be missing or stale (another clone pushed, or this one never fetched) and the PR body would describe old commits. Same exact form and same discipline as stack mode's Phase A: the explicit exit keeps a failed fetch from being followed by reads of stale refs.

Emit this line in the turn that runs the fetch, before the fence — rules/git-workflow.md lists status | diff | log | branch | rev-parse as allowed and git fetch is in neither that list nor the forbidden one, which makes it a Default-tier deviation, and a deviation is declared per run, not once during development (same shape as stack-mode.md § Phase A; the stated reason differs because the fetch serves PR-body range generation here, sync classification there):

[DEVIATION] rule=rules/git-workflow.md § allowed ops default=fetch is not in the allowed list chosen=git fetch --prune origin
reason=the PR body is generated from origin/<base>..origin/<head>; without the fetch every range is computed from stale remote-tracking refs signal=fetch is absent from the forbidden closed set (add|commit|push|stash|reset --hard|rebase) and writes only remote-tracking refs — no working tree, no history
git fetch --prune origin || exit "$?"

The rest are independent — run them in parallel:

# Current branch
git rev-parse --abbrev-ref HEAD

# Remote repo (owner/repo)
gh repo view --json nameWithOwner --jq '.nameWithOwner'

# Check if head branch is pushed
git ls-remote --heads origin -- 'feat/PROJ-42-add-widget'

# Check existing PR
gh pr list --head 'feat/PROJ-42-add-widget' --base 'main' --json number,title,state

# Commits between base..head
git log --oneline 'refs/remotes/origin/main..refs/remotes/origin/feat/PROJ-42-add-widget'

# Full diff for summary
git diff 'refs/remotes/origin/main...refs/remotes/origin/feat/PROJ-42-add-widget' --stat

2. Extract Ticket ID

From branch name, extract ticket ID using {TICKET_PATTERN} (default: [A-Z]+-\d+):

Branch PatternTicket ID
fix/PROJ-520PROJ-520
fix/PROJ-520-2PROJ-520
feat/PROJ-123-some-descPROJ-123
refactor/PROJ-999PROJ-999

Regex: first match of {TICKET_PATTERN} — take first match. Strip trailing -N suffixes.

3. Generate Title

Format: <type>: [<TICKET>] <concise summary>

  • <type>: from branch prefix (fix/fix, feat/feat, docs/docs, refactor/refactor)
  • <TICKET>: extracted ticket ID (omit if none found)
  • <concise summary>: summarize commits in <60 chars, focus on main changes

4. Generate Body

## Summary

<3-5 bullet points summarizing changes from commits>

## Ticket

[<TICKET>]({ISSUE_TRACKER_URL}<TICKET>)

## Test plan

- [ ] <test items based on what changed>

Rules:

  • No AI-generated tags — enforced by Step 4b sanitization (see below)
  • Keep summary factual, based on actual commits
  • Use imperative mood in bullet points
  • Omit Ticket section if no ticket ID or {ISSUE_TRACKER_URL} not configured

Forbidden patterns (case-insensitive ERE with \b word boundaries — canonical source: scripts/commit-msg-guard.sh):

Pattern CategoryRegex
Co-Authored-By AICo-Authored-By:.*(Claude|Anthropic|\bAI\b|GPT|OpenAI|Copilot|Codex|Gemini|noreply@anthropic)
Generated-by tagGenerated[ -](by|with).*(Claude|Anthropic|\bAI\b|GPT|OpenAI|Copilot|Codex|Gemini)
Emoji robot tag🤖.*(Claude|Anthropic|\bAI\b|GPT|OpenAI|Copilot|Codex|Gemini)

Note: \| in the table above is Markdown table escaping. Actual ERE uses unescaped |. Only AI is \b-bounded — it prevents bare AI from matching inside ordinary words ("maintainer", "domain") under -i. GPT and OpenAI are intentionally left unbounded so they still match inside ChatGPT / GPT-4 (no English word contains "gpt"). Generated[ -] covers the hyphenated Generated-by: form, which the earlier space-only version accepted.

4b. AI Content Sanitization

After generating title and body (Step 3-4), scan for forbidden patterns and sanitize before any output or execution. Applies to all modes: dry-run/execute, create/update, --title override.

Sanitization is executed, not paraphrasedskills/create-pr/scripts/sanitize-pr-content.sh is the implementation, and it reads the three forbidden patterns out of scripts/commit-msg-guard.sh at runtime so the two can never drift. The path is resolved from the script's own location, after following any symlink chain, and from nothing else — PLUGIN_ROOT is deliberately not consulted, even though run-skill.sh exports it to exactly that value, because an environment variable that selects the policy source lets a caller swap in a guard declaring three never-matching patterns and get exit 0 on a real trailer. What self-location cannot cover is an invocation whose path is not the file's real location — a copy or a hardlink into a planted tree; the script says so in its own comment rather than implying otherwise, and run-skill.sh building an absolute target from its own location — after physically resolving its own symlink chain, without which a symlink to the wrapper planted in an attacker's tree selected that tree's copy of the policy script — is what closes that for the documented entrypoint. The documented invocation spells the interpreter absolutely (/bin/bash -p) for the same reason one layer earlier: a bare bash is resolved in the caller's shell, so an exported bash function answered the whole command with exit 0 and neither script ever started. A pattern set that reads as empty, as fewer entries than the guard declares, or as a line this parser cannot fully read aborts with exit 2 rather than reporting content clean.

The run directory is allocated before this step, in every mode — including dry-run. Sanitization operates on files, so a mode that writes no file cannot sanitize; a dry-run that skipped it would render an unsanitized body into its own report, which is precisely the text a user copies into gh. What dry-run must not do is leave the files behind or run a mutating gh call, and § Command Rendering's teardown fence is what guarantees the first. "No gh at all" would be the wrong contract and the wrong claim: stack mode's Phase B reads gh pr list and Phase D reads gh extension list — both read-only — in order to decide what to print. The line is gh pr create / gh pr edit: those are never run in dry-run. The lifecycle below is one sequence with one owner, and every exit from it — including a sanitizer failure — passes through the same teardown:

#StepOn failure
1Allocate the run directory (mktemp -d, § Command Rendering step 1)Nothing was created; stop
2Write pr-title.txt and pr-body-N.md out of band (Write tool) — stack mode writes one of each per layer (pr-title-N.txt), see references/stack-mode.mdRun the teardown fence, then stop
3title mode on the title fileExit 3 → regenerate once; still 3 → teardown, then hard fail
4body-inplace mode on each body fileExit 2 → teardown, then hard fail
5Operate (gh) — execute mode only; dry-run renders the report instead. The --title value is read back from the file step 3 scanned, not from the generator's copyThe guarded block already cleans and re-raises
6Teardown (§ Command Rendering) — in dry-run this is a step of its own, run after the report is rendered

Why step 5 reads the title back. --body-file names the very artifact body-inplace rewrote, so nothing in this workflow re-renders the body between the verdict and the send. gh has no --title-file, so the title is the one field this workflow could make diverge by itself: scan pr-title.txt, then render --title from a string the generator still holds, and the verdict belongs to bytes that were never published. Regenerating the title after the scan (step 3's "regenerate once" path) is exactly when that happens. The file is the artifact of record: re-scan it after any regeneration, and render the flag from what it holds.

What this does not establish — stated because the earlier wording overclaimed it. "Same pathname" is not "same bytes". Scan and publish are separate processes reading a mutable path, so between them any process running as the same user — a second agent, a stray editor, anything sharing the account — can replace either file, and the 0700 run directory does not help because it is that same user's own directory. The sanitizer has a narrower instance of the same gap internally: it scans the path, then reopens it to emit. Closing this properly means one hardened operation owning both the verdict and the send (sanitize and pipe those exact bytes into gh --body-file -), which gh's per-flag interface and this skill's agent-driven step sequence do not currently allow. So the honest contract is: this workflow never itself publishes unscanned bytes; it does not defend against a concurrent same-user writer. Step 7b's post-publication scan is what covers that residual, and it is detection — it runs after gh has already sent the content, so it bounds exposure rather than preventing it.

Steps 3 and 4, with <PR_BODY_DIR> replaced by the literal path from step 1:

/bin/bash -p scripts/run-skill.sh create-pr sanitize-pr-content.sh title '<PR_BODY_DIR>/pr-title.txt'
/bin/bash -p scripts/run-skill.sh create-pr sanitize-pr-content.sh body-inplace '<PR_BODY_DIR>/pr-body-1.md'

Title sanitization (regenerate/fail) — title mode exits 3 on a match, never rewrites, and reports [AI_DETECTED] line <n> matched pattern <k>:

  1. Scan title for forbidden patterns
  2. Exit 3 → regenerate title from commits (1 attempt, without AI attribution) and re-run
  3. If the regenerated title still exits 3 → HARD FAIL: tear down, abort with an error message. No gh command runs
  4. --title override: same scan-and-fail logic (no regeneration — user-provided text fails immediately if matched)

Body sanitization (line-strip + log) — body-inplace replaces the file with its sanitized content and logs each removal to stderr:

  1. Scan body line-by-line for forbidden patterns
  2. Remove matching lines
  3. Log each removal as [AI_STRIPPED] line <n> matched pattern <k>the matching line itself is never echoed. A PR body is attacker-influenced text and a matched line can carry a token (Generated by GPT-4; token=…); @rules/security.md forbids putting one into a log. The line number locates it in the file
  4. If all content lines removed → preserve template structure (Summary / Test plan headers only)

Use body-inplace, not body with a redirect: … body <file> > <file> truncates the file before the sanitizer reads it, so gh would receive an empty body. body mode (stdout) exists for previewing and for tests; body-inplace is what the workflow runs, and it replaces the file through a sibling temp file and an atomic rename. In --stack mode this happens per layer, on that layer's own pr-body-N.md, immediately before that layer's block.

5. Pre-flight Checks + Mode Detection

CheckAction if fails
Head branch not pushedWarn: "branch not pushed to remote, push first" and STOP
PR already existsEnter Update Mode (see section below)
--update flag + no existing PRWarn: "no PR found for this branch" and STOP
No commits between base..head (create mode)Warn: "no diff between branches" and STOP
No commits between base..head (update mode)Continue — PR may need title/body refresh from --title override

Mode detection logic:

ConditionMode
--update flag passedForce update mode (error if no PR exists)
Existing PR detected (auto)Update mode (auto-switch)
No existing PR, no --updateCreate mode (original workflow)

5a. Update Mode

When an existing PR is detected (or --update is passed):

Step 1: Fetch current PR state (use PR number from pre-flight gh pr list result):

gh pr view <PR-number> --json number,title,body,url,baseRefName

Step 2: Re-generate title and body from latest commits (same logic as Steps 2-4 above, using full commit range base..head). Run Step 4b AI Content Sanitization on the re-generated content before proceeding.

Step 3: Smart diff — compare current vs newly generated:

FieldCurrentNewAction
TitlesamesameSkip (no change needed)
TitlediffersdiffersShow before/after
BodysamesameSkip
BodydiffersdiffersShow before/after

Step 4: Decision — if both title and body are unchanged → report "PR is already up to date" and STOP. Step 4b has already allocated the run directory and written the title and body files by this point, so STOP means running the teardown fence first (§ Command Rendering, <PRIOR_STATUS> = 0). An early exit is still an exit from the lifecycle, and it is the one most easily mistaken for "nothing happened".

If changes detected, show the diff and decide what to update:

  • Title changed significantly: update title automatically. Criteria: type prefix changed (fix:feat:) or ticket ID changed.
  • Title changed trivially: AskUserQuestion — "Title changed slightly. Update?" (show before/after). Criteria: only the summary text after <type>: [<TICKET>] differs.
  • Body changed: always update (body reflects commit history, should stay current)
  • When --title is passed: override title regardless of diff

Step 5: Output (respects --dry-run / --execute):

Dry-run (default) — show the gh pr edit command with only changed fields included:

Every update is the canonical block below, instantiated — never a bare gh pr edit. A bare one is unguarded, so a caller's errexit exits at the failing gh before any cleanup runs, and Step 4b has already allocated the run directory and written the title and body files into it by this point. Only the two parameters the Cleanup row names vary, and no copy of the shape is kept here — a second copy is a second source, and this section is where it would drift from:

UpdateFlags on the block's gh pr editCleanup operand
Title only--title with the rendered title, no --body-file<PR_BODY_DIR> — nothing was written for this operation, but the Step 4b directory is still this path's to remove
Body--body-file naming the file in the run directory<PR_BODY_DIR>
Body + titleboth<PR_BODY_DIR>
Command Rendering (mandatory) ⚠️

Branch names, titles and bodies are all attacker-influenceable — a branch name is accepted by git check-ref-format --branch with ;, &, quotes and $( ) in it, and the body is generated from commit messages. Two rules, and both are load-bearing:

1. Single-quote rendering, never double quotes. Every dynamic value interpolated into a rendered command is wrapped in single quotes with embedded quotes escaped:

render(v) = ' + v.replace(every ' with '\'') + '

Double quotes are not a substitute: "$(id)" still runs id, so git rev-parse "refs/heads/$BRANCH" executes a command substitution embedded in a branch name. Templates in this skill and in references/stack-mode.md show values already rendered. Add -- before positional arguments wherever the CLI accepts it, so a value cannot be parsed as a flag.

2. Body text never appears inside shell syntax — no heredoc, ever. This is a prohibition, not a preference, and it has no "unless" clause. A heredoc terminates at the first line equal to its delimiter, so a body containing that line closes the heredoc early and every following line is parsed as shell input — arbitrary command execution, not a formatting bug. Quoting the delimiter (<<'X') only disables expansion inside the body; it does not prevent the collision. Nor does a random-looking fixed delimiter: fixed is fixed, and a body can contain it. --body-file /dev/stdin does not help either — termination happens in the shell before gh runs.

PathRule
RequiredWrite the body to a file out of band — the Write tool when the skill runs (it is in allowed-tools for exactly this), the user's editor when they copy-paste — then pass --body-file '<path>'. The command names the file and never contains the body
WhereA directory allocated by mktemp -d, one per run: mktemp -d is atomic, returns a unique name, and creates it 0700. Never invent the name — an invented path is not created, so Write fails on the missing parent, and on a shared /tmp a predictable name can be pre-created or symlinked by another user. Never under .git/ either: pre-edit-guard.sh rejects every .git/ path, and in a linked worktree .git is a file, not a directory
How the path is carriedRun mktemp -d once, read the path it prints, and substitute that literal absolute path into every later command. Each Bash invocation is a fresh shell, so DIR=$(mktemp -d) followed by $DIR in a later step silently resolves to nothing — and on macOS TMPDIR is an ambient variable pointing at the shared temp root, so a stray rm -rf "$TMPDIR" would target that root. Same rule and the same reasoning as skills/necessity-audit/SKILL.md § Phase 0. <PR_BODY_DIR> below marks where the returned literal goes
CleanupThe shell shape is defined once, by the canonical block below, and every body-file command in this skill — create, update, stacked, Step 7b — uses that shape unchanged. What varies between them is the operation and the cleanup operand, and only those: a single-PR command cleans the whole run directory, while a stacked layer cleans its own pr-body-N.md and leaves the directory for the layers above it (references/stack-mode.md § Phase C). The structure around them — subshell, seeded status, guarded operation, guarded cleanup, arithmetic re-raise — is copied verbatim. Do not restate it in prose; a restatement is a second source that can drift. What the shape buys, so it is not "simplified" away: the operation is guarded so a caller's errexit cannot exit at the failing gh before cleanup runs (an unguarded command followed by a capture line is skipped outright); the status is carried in a subshell's positional parameters rather than a named variable, because a named one belongs to the caller — readonly STATUS=9 would make the seeding assignment itself fail after allocation, leaking the directory, and an ordinary caller would silently lose its own value; the expansion is quoted, because a bare one is field-split with the caller's IFS and an IFS containing the status digit drops the status (bash and sh report a different code, zsh under SH_WORD_SPLIT reports success); and the subshell keeps the re-raise from closing an interactive shell. Cleanup runs on success and on failure — a PR body can carry private repository context even without credentials — but it must never mask the failure: an unconditional trailing rm succeeds, so the block would report 0 after a failed gh. Capture, clean, re-raise — and the cleanup is guarded in turn, because a failing rm would otherwise replace the operation's status with its own. -- guards the operand, and the operand is the exact literal mktemp -d printed: an empty one deletes nothing, while a wrong non-empty literal would delete the wrong directory — which is why the exact-output provenance rule above is load-bearing. Bash(mktemp:*) and Bash(rm:*) are in allowed-tools for exactly these two steps, and Bash(bash:*) for the Step 4b/7b sanitizer invocation
NeverAny << heredoc, echo/printf of body text, or body interpolated into a command string. With no delimiter in play, no body line can collide with one
Commands this skill executesPass arguments as an array — never interpolate body or title into a shell string

This applies to every mode: create, update, stacked, and the Step 7b remediation below.

Canonical cleanup block. Every body-file command below is this block with its own gh invocation substituted — the one authority for the shape. It is two fences, and the split is load-bearing: a single fence containing both the allocator and the placeholder has no correct way to be run. Execute it whole and mktemp -d's output is discarded while gh is handed the un-substituted literal <PR_BODY_DIR>/…, which does not exist; run the allocator separately and then execute the same fence "verbatim" and it allocates a second directory that nothing ever removes. A shell comment cannot pause execution while a body is written out of band — only a fence boundary can.

# Step 1 — allocate the run directory. Run this fence alone and read the literal
# path it prints. Nothing else belongs in it.
mktemp -d

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
188
Forks
24
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
create-pr-sd0xdev
Source
github.com/sd0xdev/sd0x-harness