PR Title and Description Generator
Generate or update a PR title and description based on the actual changes in the current branch.
Core Principle
Document ONLY what exists in the final state of the code, not the development history.
The "before" you describe is what the base branch shipped (origin/$BASE) — never an intermediate branch commit. A bug introduced and fixed within the same branch was never shipped: describe the net effect, not the intra-branch detour. Any "fixes / was broken / now works / adds" statement is implicitly a claim about the baseline — verify it against origin/$BASE (§4) before writing it. ($BASE is resolved in §2.)
Bash Command Rule
NEVER prefix bash commands with comment lines. Permission patterns in ~/.claude/settings.json match against the start of the command. A leading # comment breaks the match and triggers a manual permission check. Put descriptions in the Bash tool's description parameter instead.
If a feature was added in one commit and removed in another, it should NOT be in the PR description. Always verify features exist in HEAD before documenting them.
Analysis Process
1. Identify Current Branch and PR
git branch --show-current
# Fetch PR information including state for validation
pr_info=$(gh pr view --json number,title,state,mergedAt,headRefName,baseRefName 2>/dev/null)
if [[ -n "$pr_info" ]]; then
# PR exists - extract state and metadata for validation
pr_state=$(echo "$pr_info" | jq -r '.state // "UNKNOWN"')
pr_merged_at=$(echo "$pr_info" | jq -r '.mergedAt // "null"')
pr_number=$(echo "$pr_info" | jq -r '.number')
pr_title=$(echo "$pr_info" | jq -r '.title')
pr_head=$(echo "$pr_info" | jq -r '.headRefName')
pr_base=$(echo "$pr_info" | jq -r '.baseRefName')
# Security Fix #4: Validate pr_state is a known GitHub PR state (prevent injection)
case "$pr_state" in
OPEN|CLOSED|MERGED|UNKNOWN)
# Valid state, proceed
;;
*)
echo "WARNING: Unexpected PR state from GitHub API: $pr_state" >&2
pr_state="UNKNOWN"
;;
esac
# Security Fix #4: Validate pr_merged_at is either "null" or valid ISO 8601 timestamp
if [[ "$pr_merged_at" != "null" ]] && [[ ! "$pr_merged_at" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T ]]; then
echo "WARNING: Unexpected mergedAt format from GitHub API: $pr_merged_at" >&2
pr_merged_at="null"
fi
# Security Fix #5: Validate pr_number is present and is a positive integer
if [[ -z "$pr_number" ]] || [[ ! "$pr_number" =~ ^[0-9]+$ ]]; then
if [[ -n "$pr_info" ]]; then
echo "ERROR: PR data incomplete (missing or invalid PR number)" >&2
fi
pr_info="" # Treat as no PR exists
fi
fi
1.1. PR State Validation
CRITICAL SAFEGUARD: Before updating any PR, verify it is in a safe state to modify.
When PR exists (pr_info is not empty):
Step 1: Check if PR is OPEN - safe to proceed immediately:
if [[ "$pr_state" == "OPEN" ]]; then
# Safe to proceed with normal update workflow
# Continue to Step 2
fi
Step 2: If PR is NOT OPEN - stop and ask user for confirmation:
Determine the specific non-open state:
if [[ "$pr_state" == "MERGED" || "$pr_merged_at" != "null" ]]; then
pr_status_type="MERGED"
pr_status_detail="merged"
pr_status_note="Note: Updating a merged PR only changes its historical record, not the code."
elif [[ "$pr_state" == "CLOSED" ]]; then
pr_status_type="CLOSED"
pr_status_detail="closed without merging"
pr_status_note="Updating a closed PR is unusual. Most cases should create a new PR instead."
else
pr_status_type="UNKNOWN"
pr_status_detail="in an unclear state ($pr_state)"
pr_status_note="Cannot verify PR state safely. Manual investigation recommended."
fi
Step 3: Present confirmation prompt to user:
Stop execution and display this message (substitute variables with actual values):
⚠️ **PR State Warning**
The detected pull request is **{pr_status_type}**:
- **PR**: #{pr_number} - {pr_title}
- **Branch**: {pr_head} → {pr_base}
- **State**: {pr_status_detail}
**Options:**
1. **Create new PR** - Open a fresh pull request for these changes (recommended for most cases)
2. **Update {pr_status_type} PR anyway** - Modify the PR's title/description (rarely needed)
3. **Cancel** - Stop without making changes
{pr_status_note}
**What would you like to do?**
Step 4: Wait for explicit user response and validate input:
# Read user choice
read -p "Enter your choice (1, 2, or 3): " user_choice
# Security Fix #2: Validate input is exactly 1, 2, or 3
if [[ ! "$user_choice" =~ ^[123]$ ]]; then
echo "Invalid choice. Please enter 1, 2, or 3." >&2
exit 1
fi
Step 5: Handle user choice based on validated input:
case "$user_choice" in
1)
# Option 1: Create new PR
pr_info=""
echo "Creating new PR instead..."
;;
2)
# Option 2: Update anyway
user_confirmed_update="true"
echo "Proceeding with update to $pr_status_type PR..."
;;
3)
# Option 3: Cancel
user_cancelled="true"
echo "PR update cancelled by user. No changes made."
exit 0
;;
esac
Note on Interactive vs Non-Interactive Handling:
If implementing this skill as a non-interactive script, Claude should directly handle the user's verbal choice without prompting:
- If user says "create new PR": Set
pr_info="" - If user says "update anyway": Set
user_confirmed_update="true" - If user says "cancel": Exit gracefully
When no PR exists (pr_info is empty): Skip validation entirely - the skill will create a new PR (normal workflow).
API Failure Handling:
If gh pr view returns an error OTHER than "no pull requests found", treat as UNKNOWN state and ask user before proceeding.
2. Analyze Final State Changes
Resolve the base branch first. All analysis below diffs against BASE, not a hardcoded main — so a PR targeting a non-main base (e.g. a worktree PR against its source branch) is documented accurately. Resolve it once, from the final PR state (after the §1.1 decision):
if [[ -n "$pr_info" ]]; then
BASE="$pr_base" # updating an existing PR — use its actual base
else
BASE="$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name)" # no PR / creating — default branch
fi
# Guard: never proceed with an empty base. An empty $BASE silently turns every diff
# below into "no changes" (git log/diff against ""..HEAD exit 0 with empty output) and
# makes `gh pr create --base ""` fall through to gh's default-branch guess — shipping a
# vacuous PR instead of erroring. Stop instead (mirrors /finish Step 9 sub-step 1).
if [[ -z "$BASE" ]]; then
echo "ERROR: could not resolve base branch (gh unauthenticated / no GitHub remote). Cannot analyze or open a PR." >&2
exit 1
fi
Shell-variable persistence. Each Bash tool call is a fresh shell — BASE (and the pr_* variables captured in §1) do not survive across calls. Resolve BASE in, or re-establish it within, the same Bash invocation that consumes it (the analysis commands below, and the final gh pr create / gh pr edit step). When updating a non-open PR via §1.1 option 2, $pr_base is the base the PR had — the diff against it may be approximate; this is rare and updating a non-open PR is already discouraged.
Get commit count:
git log "$BASE"..HEAD --oneline | wc -l
Get file change summary:
git diff "$BASE"...HEAD --stat
Identify major areas of change:
git diff "$BASE"...HEAD --name-only | cut -d/ -f1 | sort | uniq -c | sort -rn
3. Code Impact Summary
Generate a categorized line-count breakdown to quantify the PR's impact. This section helps reviewers quickly understand the scope and nature of changes.
Step 1: Get the raw totals (with rename detection):
git diff --shortstat -M -l0 "$BASE"...HEAD
Step 2: Categorize by purpose using git diff --numstat.
The categorization program lives in scripts/code-impact.awk, and
scripts/code-impact.sh runs it: one git diff --numstat -M -l0 fed through the
awk, its TOTAL reconciled against the same numstat (filtered by the same lock-file test, so renames like
{a => b}/pnpm-lock.yaml are handled identically on both sides), and on a mismatch an error and exit 1
without emitting the table — cheap insurance that the awk file resolved and ran. It is a
total-reconciliation check, not a per-category validator. Do not paste either program inline here. Two
hazards, one fix: transcribed into a Bash one-liner, awk's positional field references get expanded away by
the shell; and written into this file at all, they are rewritten by the Skill tool, which substitutes the
invocation arguments into every literal dollar-digit token of a SKILL.md body at load time (measured: the
former inline block rendered as s+=existing+PR under /pr-update update existing PR …). A script called
by path is subject to neither:
"$HOME/.claude/skills/pr-update/scripts/code-impact.sh" "$BASE"
A second argument diffs against something other than HEAD — code-impact.sh origin/main origin/hotfixes
when analyzing a PR from outside its checkout.
Notes:
- Use
-M -l0for rename detection:-Mmatches moved files so renames don't inflate counts, and-l0lifts git's default ~1000-file rename cap so moves still net out on large diffs (otherwise git gives up and a pure move double-counts as add + remove) - "App code" captures source code files (ts, tsx, rb, etc.) — config/asset files (yml, json, css, lock, svg, md) are separated into "Config/Assets" to keep the signal clean, and test code is split into its own Unit tests / E2E tests buckets so production churn reads true
- Category patterns live in
code-impact.awk(first-match-wins, most-specific first) — edit them there to match project structure:\.stories\.— Storybook story filesmock|Mock— Test mocksgenerated|packages\/graphql\/— Generated code (GraphQL codegen, etc.)packages\/e2e\/— E2E tests (whole Playwright package: specs, page objects, fixtures, support, config)\.test\.[jt]sx?$|_spec\.rb$— Unit tests (Vitest*.test.ts[x]+ RSpec*_spec.rb)ops\/— Infrastructure/operationslock\.yaml$|lock\.json$|\.lock$— Lock files (skipped entirely)\.(yml|yaml|json|css|scss|svg|md)$— Config, locales, styles, assets
- The categorized breakdown often reveals a different story than raw totals (e.g., production code reduced while test coverage increased)
- If
clocis available,cloc --diff "$BASE" HEAD --gitprovides an alternative per-language breakdown, but lacks categorization and doesn't handle renames
Include the resulting table in the PR description under a Code Impact section.
4. Verify What's Actually in the Code
Verify the baseline before describing what changed
Before writing any "fixes / was broken / now works / adds" narrative, confirm what the base branch actually shipped. The diff shows the net change between $BASE and HEAD, but it does not tell you which side was production reality — infer that wrong and you describe a bug that never shipped (or an "add" that already existed).
Resolve the baseline ref once. "What's live in production now" is the remote tip origin/$BASE (the local $BASE can lag the remote and would then misreport the baseline) — but a worktree PR's base may exist only locally with no origin/ counterpart (§2 permits BASE="$pr_base"), so fall back to the local $BASE when the remote ref is absent:
git fetch --quiet origin "$BASE" 2>/dev/null || true
if git rev-parse --verify --quiet "origin/$BASE^{commit}" >/dev/null; then
BASE_REF="origin/$BASE" # normal case: fetched remote tip == production
elif git rev-parse --verify --quiet "$BASE^{commit}" >/dev/null; then
BASE_REF="$BASE" # base-only-local (e.g. a worktree PR's source branch)
else
echo "ERROR: base '$BASE' resolves neither as origin/$BASE nor locally — cannot verify the baseline. Fetch or check out the base, then retry." >&2
exit 1
fi
Read the claimed-broken (or claimed-new) behavior at $BASE_REF:
git show "$BASE_REF":path/to/file.ts | grep -A5 "relevant_logic"
$BASE_REF is a verified commit (above), so a git show error here means the path doesn't exist at the baseline — the file is new in this branch (a genuine addition), not an unresolved ref. If git show succeeds but grep is empty, the pattern didn't match (a renamed symbol, reformatting) — widen the pattern; don't conclude the behavior is absent.
Decision rule:
- Broken behavior is in
$BASE_REF→ it's a real fix; describe the production impact. $BASE_REFalready has the correct behavior → the bug was introduced and fixed within this branch and never shipped. Do not claim it broke production; describe the net change (refactor / hardening / cohesion) instead.- Behavior claimed as "added" already exists in
$BASE_REF→ it's a modification, not an addition; say so.
Separately, to confirm a line that looks changed was actually changed by this branch (not just reformatted, or changed-then-restored so the net diff hides it), inspect the merge base — where this branch diverged. This answers "did this branch touch this line," not "what's in production" (the merge base lags production whenever the base advanced after the branch was cut, so never use it for production-impact claims). Capture the merge base first so a resolution failure can't silently fall through to the index blob:
mb=$(git merge-base "$BASE_REF" HEAD) && git show "$mb":path/to/file.ts
Verify the feature exists in the final state
CRITICAL: For each area of apparent change, verify if it's in the final state:
Check if a feature is in final code:
git show HEAD:path/to/file.ts | grep -q "feature_name" && echo "PRESENT" || echo "REMOVED"
Example — check for authentication plugin:
git show HEAD:cloud/database/src/SqlDatabase.ts | grep "authentication_plugin"
Example — check if a function exists:
git show HEAD:src/utils.ts | grep -A10 "function myFunction"
If a feature doesn't appear in the final state, DO NOT include it in the PR description.
5. Categorize Changes by Impact
Organize changes into categories based on what's actually present:
- Infrastructure Changes: Cloud resources, deployments, architecture
- Developer Experience: Tooling, documentation, local development setup
- CI/CD: Pipeline changes, automation workflows
- Breaking Changes: API changes, configuration requirements, migration needs
- Dependencies: Package updates that remain in final package.json/lock files
- Documentation: New or updated docs (verify files exist)
6. Document Only Present Changes
For each change area:
- Verify existence: Run
git show HEAD:path/to/fileto confirm - Link to files: Use markdown links with relative paths from repo root
- Files:
[filename.ts](path/to/filename.ts) - Specific lines:
[filename.ts:42](path/to/filename.ts#L42) - Line ranges:
[filename.ts:42-51](path/to/filename.ts#L42-L51)
- Files:
- Include code snippets: For configuration changes, show actual values
- Provide context: Explain why the change was made, not just what changed
Quality Verification Checklist
Before finalizing the description, verify:
- Every feature mentioned exists in
git show HEAD:path/to/file - Every "fixes / was broken / now works" claim is verified against the §4 baseline ref (
origin/$BASE, or its local fallback when the base isn't on origin) — not inferred from the diff - No bug is described as production-affecting if it was introduced and resolved within this branch
- No references to features that were added then removed during development
- All file links use relative paths from repo root (not absolute paths)
- Configuration examples reflect actual current state in HEAD
- Breaking changes are clearly marked with "Breaking Changes" section
- Testing sections describe actual tests that currently pass
- Code snippets are from actual files in HEAD, not from memory
- No issue ID other than this PR's own is preceded by a Linear close keyword (standards/git.md § Linear auto-close keywords)
- Every shipped Linear ID from the census appears in the Executive Summary exactly once — a roster bullet or the internal line — and no referenced-only ID appears at all
- Every Executive Summary roster item goes live on merge — nothing under a mirror of an externally managed system (Descope snapshots, vendor-console exports) is claimed as in this release
PR Title Formats
See resources/title-patterns.md for comprehensive title format examples and patterns.
Quick reference:
- Infrastructure: "Enterprise [resource] with [key feature] and [secondary feature]"
- Features: "Add [feature] with [benefit]"
- Bug Fixes: "Fix [specific issue] in [area]"
- Refactoring: "Refactor [area] to [improvement]"
Avoid vague titles like "PR deployment", "Various fixes", or "Update code".
Description Structure
For detailed templates, see:
- templates/feature.md - Feature additions
- templates/bugfix.md - Bug fixes
- templates/infrastructure.md - Infrastructure changes
Executive Summary (lead with this)
Every description opens with an ## Executive Summary — a self-contained,
plain-language block the user can copy out whole and share with the business team.
It precedes the technical ## Summary (the two serve different audiences: Executive
Summary = business outcome; Summary = technical TL;DR for reviewers).
Rules for the Executive Summary — voice and concision follow the
exec-summary skill (problem first, then the fix; concise per item,
complete per shipped issue; backticks for user-visible strings; no deferments or process
detail). This block is that skill's PR-description variant: it keeps the ## Executive Summary heading and
the trailing PR link in place of the Slack-style underlined title. Plus:
- Business language only. Translate the change into its outcome. No file paths, no code, no line-count tables, minimal jargon. Issue IDs are the one identifier that stays — they are the business's handle for "did our customer's issue ship?".
- Census first, roster on a multi-issue PR. Run the
exec-summary skill's census — every Linear ID in the commit
subjects and bodies, the head branch name, and the existing body — and sort each ID:
shipped-and-customer-visible, shipped-and-internal, or referenced-only. "Shipped" is two
tests, and a commit subject satisfies neither on its own: the change is in the
$BASE...HEADdiff (§4 — an issue whose content already reached$BASEthrough another PR nets to zero and is referenced-only, however many of its commits the branch carries), and merging makes it live (a file mirroring an externally managed system — a Descope snapshot, a vendor-console export — ships nothing on merge: a production mirror changed by export was live before the PR, a dev/staging-only change ships on a later promote; neither is release content, so leave it out or state it as already live). An ID Linear marksDuplicateorCanceledis referenced-only; credit its work to the absorbing issue. On a hotfix or release bundle the Solution is a roster — one bullet per shipped customer-visible issue,BF-1763: one plain sentence— complete over the census, with shipped internal-only issues on one trailingAlso in this release, with no customer-visible change:line as bare IDs, and referenced-only IDs nowhere. Every shipped ID appears exactly once. Concision cuts within a bullet, never a bullet. Measured before this rule: three bundled hotfix PRs shipping 2, 5, and 6 issues, and the Executive Summary named zero; measured after its first draft: a hotfix bundle whose roster listed three Descope console changes already live in production, each commit body saying so. - ID first, colon after, never a close verb before it. The PR body is a surface Linear
scans —
Fixed BF-1763in a roster line moves that issue on merge (standards/git.md § Linear auto-close keywords; the checklist above gates it). - Lead with impact — what is better for users or the business now.
- The For users / Business impact / Security & quality lines are suggestions, not required sections. Include only those that genuinely apply; keep each to one line; omit the rest. A simple change may be 2–3 sentences with no bullets at all. On a multi-issue PR the roster already says what changed for users — drop For users rather than restate it.
- Accuracy is paramount here. This is the most-shared, highest-visibility text, so over-claiming is the worst case — every impact / "fixes" statement must pass the same baseline check from §4 before it's written.
- End with the PR link so the shared block is self-contained (see "After Generating Description" for how the URL is resolved).
- Don't pad. On a small PR where the technical
## Summarywould merely restate the Executive Summary, drop the## Summaryand go straight to the detailed sections — two near-identical openers read as bloat. Keep both only when the technical Summary adds reviewer-facing detail the Executive Summary deliberately omits.
Use this general template structure:
## Executive Summary
**Problem:** [1–2 plain sentences: what users or the business hit, and why it matters
now. Concrete numbers where real. No file paths, no code, minimal jargon.]
**Solution:** [Single-issue PR: 1–2 plain sentences, what changed in outcome terms,
verbatim user-visible strings in backticks where the change IS the copy. Multi-issue PR:
the roster below instead — one bullet per shipped customer-visible issue, complete over
the census; a rode-along change with no issue still gets its bullet.]
- BF-1763: [one plain sentence — what the customer or support can now see]
- BF-1716: [one plain sentence — the verbatim new string in backticks where the change is the copy]
Also in this release, with no customer-visible change: BF-1703, BF-1698.
[Optional one-liners — include only those that genuinely apply, omit the rest:]
- **For users:** [what changes in their experience]
- **Business impact:** [revenue, risk, cost, compliance, or operational effect]
- **Security & quality:** [notable hardening, test coverage, or reliability gains]
🔗 **Pull request:** [#<number> — <title>](<pr-url>)
## Summary
[1-2 sentence overview of what this PR accomplishes and why]
## [Major Category 1 - e.g., Infrastructure Changes]
### [Subcategory - e.g., Cloud SQL Enterprise Plus]
**[Feature Name]:**
- [Implementation detail verified in HEAD]
- [Configuration detail with actual values]
- [Benefit or impact]
**Implementation:**
- File: [link to main file](path/to/file.ts)
- Configuration: [link to config](path/to/config.yaml)
**Stack Commands:**
- `./stack command_name` - Description
**Documentation:**
- [Link to relevant docs](doc/path/to/doc.md)
[Repeat structure for each major category]
## Code Impact
| Category | Added | Removed | Net |
|---|---|---|---|
| App code | X | X | X |
| Unit tests | X | X | X |
| E2E tests | X | X | X |
| Stories | X | X | X |
| Mocks | X | X | X |
| Generated | X | X | X |
| Ops/Infra | X | X | X |
| Config/Assets | X | X | X |
| **Total** | **X** | **X** | **X** |
[1-2 sentence interpretation of what the numbers mean — e.g., "Production code reduced by X lines through Y. Net increase driven by new test coverage."]
## Breaking Changes
### [Area Affected]
- **What changed**: [Specific change]
- **Migration**: [Steps to migrate]
- **Impact**: [Who/what is affected]
## Dependencies
- Updated `package-name` to version X.Y.Z
- Added `new-package` for [specific purpose]
- Removed `old-package` (no longer needed)
## Testing
**[Test Category]:**
- ✅ [Specific test that validates the change]
- ✅ [Another specific test]
- ✅ [Integration test description]
**[Another Test Category]:**
- ✅ [Test description]
## Cost Impact
[If applicable - infrastructure cost changes]
**Production:**
- Current: $X/month
- Planned: $Y/month (with optimization Z)
- Benefit: [SLA/performance/reliability improvements]
**[Environment]:**
- Base: $X/month (shared infrastructure)
- Per-[unit]: +$Y/month
Verification Workflow
For step-by-step examples of how to analyze and verify PR changes, see resources/analysis-workflow.md.
You can also use the verification script:
Check if a feature exists in final state:
./scripts/verify-feature.sh path/to/file.ts "feature_name"
Check if a file exists:
./scripts/verify-feature.sh packages/api/README.md ""
Important Rules
- Verify before documenting - Always use
git show HEAD:fileto confirm features exist in final state - Verify the baseline before claiming a fix - Before writing "fixes X" / "was broken" / "now works", confirm the broken behavior actually exists in production via §4's baseline check (
git show "origin/$BASE":file, with a local fallback when the base isn't on origin). A bug introduced and fixed within this branch never shipped — don't describe it as a production failure - Never mention removed features - If a commit added something but it was later removed or reverted, don't include it
- Focus on outcomes, not process - Describe the result, not the development journey
- Link to actual code - Every major feature should have a file reference that users can click
- Be specific - "Add MySQL native password authentication" not "Update database config"
- Test your claims - If you say "CI runs connectivity checks", verify the CI file actually shows that
- Use present tense - "Adds X", "Implements Y", not "Added X", "Implemented Y"
- Quantify when possible - "3x performance improvement", "99.99% SLA", "$460/month cost"
Common Mistakes to Avoid
❌ Documenting Removed Features
# Commit history shows:
# - Commit A: Add feature X
# - Commit B: Remove feature X
# Final state: No feature X
# WRONG: "Added feature X"
# RIGHT: Don't mention feature X at all
❌ Vague Descriptions
# WRONG: "Updated database configuration"
# RIGHT: "Set default_authentication_plugin to mysql_native_password for Cloud SQL Proxy v2 compatibility"
❌ Missing Verification
# WRONG: Assume a feature exists because you saw it in commit messages
# RIGHT: git show HEAD:path/to/file.ts | grep "feature_name"
❌ Broken Links
# WRONG: [config.ts](/Users/kross/project/src/config.ts)
# RIGHT: [config.ts](src/config.ts)
After Generating Description
Security Fix #1 - Command Injection Prevention: When generating title and description variables, ensure they are assigned using proper quoting to prevent command injection:
Safe assignment (use quotes):
title="Generated title text"
description="$(cat <<'EOF'
Multi-line description
EOF
)"
UNSAFE — never do this: title=$(some_command) without quotes could execute commands in the title.
Create or update the PR using GitHub CLI — branch on whether a PR already exists.
Case A — no PR exists (create)
pr_info is empty: either none was found in §1, or the user chose "create new" in §1.1. Push the branch (idempotent), then create the PR against the resolved $BASE:
branch="$(git branch --show-current)"
if [[ -z "$branch" ]]; then
echo "ERROR: detached HEAD — check out a branch before opening a PR." >&2
exit 1
fi
if ! git push -u origin HEAD; then
echo "ERROR: push failed (diverged remote, or no write access). Resolve manually — do NOT force-push — then retry." >&2
exit 1
fi
gh pr create --base "$BASE" --head "$branch" --title "$title" --body "$(cat <<'EOF'
[Full description. The Executive Summary link line is a placeholder on this first pass — e.g. 🔗 **Pull request:** _(link below)_ — patched to the real URL in the required follow-up edit below.]
EOF
)"
git push -u origin HEAD sets the upstream on first push and is a no-op when the branch is already up to date with its remote. It is not unconditionally safe — so the if ! gate stops before gh pr create runs whenever the push fails (a diverged remote rejects it non-fast-forward; never force-push to recover). The branch guard above rejects a detached HEAD up front (so --head can never collapse to an empty value) and supplies --head "$branch", which also keeps gh from dropping into its interactive "where should I push this branch?" prompt that would hang a non-interactive session. $BASE is the base resolved in §2 (the repo's default branch when creating).
Fill the Executive Summary PR link. A new PR's URL doesn't exist until it's created, so create the PR with the Executive Summary's link line as a placeholder (e.g. 🔗 **Pull request:** _(link below)_), then resolve the real URL and patch the body in one required follow-up edit — skip it and the PR permanently shows the placeholder in its most-shared line:
pr_url=$(gh pr view --json url -q .url)
if [[ -z "$pr_url" ]]; then
echo "ERROR: could not resolve the new PR's URL — leave the placeholder and retry the edit; do not ship it." >&2
exit 1
fi
gh pr edit "$pr_url" --body "$(cat <<'EOF'
[Full description with the real PR URL written into the Executive Summary link line]
EOF
)"
gh pr view with no argument resolves the PR for the current branch. The body heredoc is quoted (<<'EOF'), so it does not expand shell variables — write the resolved URL text into the Executive Summary link line (e.g. [#42 — Title](https://github.com/org/repo/pull/42)), never the literal token $pr_url or the template's <pr-url>, which would ship verbatim. This second edit is create-only; an update (Case B) already knows the URL and writes it on the first pass.
Case B — a PR already exists (update)
pr_info is non-empty: an OPEN PR, or a non-open PR the user confirmed updating in §1.1.
Fill the Executive Summary PR link. The URL is already known here — resolve it before composing the body and write its resolved value straight into the Executive Summary link line, so the single gh pr edit below carries it (no second pass needed):
pr_url=$(gh pr view "$pr_number" --json url -q .url)
if [[ -z "$pr_url" ]]; then
echo "ERROR: could not resolve PR #$pr_number URL — resolve before composing the body; do not ship an empty link." >&2
exit 1
fi
The gh pr edit body below is a quoted heredoc (<<'EOF') and will not expand variables — paste the actual URL text (e.g. https://github.com/org/repo/pull/42) into the link line, not the token $pr_url or the template placeholder <pr-url>.
Pre-Update State Verification (prevent TOCTOU race condition) — re-check PR state immediately before the edit:
# $pr_number / $pr_state were captured in §1, possibly in an earlier (now-gone) Bash
# call. If they didn't persist into this shell, stop cleanly — otherwise the TOCTOU
# guard below is silently skipped and the later checks emit a misleading "non-open PR"
# error. Re-run §1's capture and this block together in one Bash invocation.
if [[ -z "$pr_number" || -z "$pr_state" ]]; then
echo "ERROR: PR identity not present in this shell — re-run §1's capture and this block in a single Bash call." >&2
exit 1
fi
if [[ -n "$pr_number" ]]; then
current_pr_info=$(gh pr view "$pr_number" --json state 2>/dev/null)
if [[ -n "$current_pr_info" ]]; then
current_state=$(echo "$current_pr_info" | jq -r '.state // "UNKNOWN"')
# Verify state hasn't changed since initial check
if [[ "$current_state" != "$pr_state" ]]; then
echo "ERROR: PR state changed during processing" >&2
echo " Initial state: $pr_state" >&2
echo " Current state: $current_state" >&2
echo " Aborting update to prevent unintended modification" >&2
exit 1
fi
else
echo "ERROR: PR no longer exists (may have been deleted)" >&2
exit 1
fi
fi
if [[ "$user_cancelled" == "true" ]]; then
echo "ERROR: Attempted to continue after user cancellation" >&2
exit 1
fi
if [[ "$pr_state" != "OPEN" ]] && [[ "$user_confirmed_update" != "true" ]]; then
echo "ERROR: Attempted to update non-open PR #$pr_number (state: $pr_state) without user confirmation."
echo "This indicates a bug in the PR state validation logic."
exit 1
fi
gh pr edit "$pr_number" --title "$title" --body "$(cat <<'EOF'
[Your full description here]
EOF
)"
Confirm
Either path — view the PR for the current branch:
gh pr view