#!/usr/bin/env bash set -euo pipefail temp_files=() cleanup() { rm -f "${temp_files[@]}" } trap cleanup EXIT # Create a temp file that ends in .md so editors detect markdown. new_md_tempfile() { local f f=$(mktemp -t pr-description.XXXXXX) mv "$f" "$f.md" printf '%s.md' "$f" } usage() { cat <<'EOF' Usage: pr-create [options] All options are optional. Anything not provided is asked for interactively (unless --yes is set, in which case sensible defaults are used). Options: --title PR title (must start with a conventional type, e.g. "feat: ...", "fix(api): ...") --base Base branch ("" = repo default) --draft / --no-draft Create as draft PR --reviewers Reviewer set --generate-description Generate PR body with pi --no-generate-description Skip body generation --body-file Use this file as the PR body (skips generation) --linear <"Closes ABC-1"> Add a Linear link line (repeatable) --edit / --no-edit Open body in $EDITOR before creating --push / --no-push Auto-confirm pushing the branch if needed -y, --yes Non-interactive: defaults for anything unspecified -h, --help Show this help EOF } # Defaults for --yes mode. title="" base="" draft="" # "yes" | "no" | "" reviewers="" # "default" | "cns" | "" gen_desc="" # "yes" | "no" | "" body_file="" edit_desc="" # "yes" | "no" | "" push_choice="" # "yes" | "no" | "" linear_links=() assume_yes=0 while [[ $# -gt 0 ]]; do case "$1" in --title) title=$2 shift 2 ;; --base) base=$2 shift 2 ;; --draft) draft="yes" shift ;; --no-draft) draft="no" shift ;; --reviewers) reviewers=$(printf '%s' "$2" | tr '[:upper:]' '[:lower:]') shift 2 ;; --generate-description) gen_desc="yes" shift ;; --no-generate-description) gen_desc="no" shift ;; --body-file) body_file=$2 gen_desc="no" shift 2 ;; --linear) linear_links+=("$2") shift 2 ;; --edit) edit_desc="yes" shift ;; --no-edit) edit_desc="no" shift ;; --push) push_choice="yes" shift ;; --no-push) push_choice="no" shift ;; -y | --yes) assume_yes=1 shift ;; -h | --help) usage exit 0 ;; *) echo "Unknown option: $1" >&2 usage >&2 exit 2 ;; esac done prompt_input() { local prompt=$1 if [[ $assume_yes -eq 1 ]]; then printf '' return fi if command -v gum >/dev/null 2>&1; then gum input --prompt "$prompt: " else local value read -r -p "$prompt: " value printf '%s\n' "$value" fi } prompt_menu() { local title=$1 shift if [[ $assume_yes -eq 1 ]]; then printf '%s\n' "$1" return fi if command -v gum >/dev/null 2>&1; then gum choose --header "$title" "$@" else local choice select choice in "$@"; do [[ -n "$choice" ]] && printf '%s\n' "$choice" && break done fi } # Normalize a yes/no value (case-insensitive) to "Yes"/"No"; empty stays empty. normalize_yn() { case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in yes | y | true | 1) printf 'Yes' ;; no | n | false | 0) printf 'No' ;; *) printf '' ;; esac } generate_pr_description() { local body_file=$1 local prompt_file prompt_file=$(mktemp) temp_files+=("$prompt_file") cat >"$prompt_file" <<'EOF' Use the humanizer skill's audit loop internally to self-check the prose, then return ONLY the final result. Do not output the draft, the "still-AI" bullets, or any summary of changes — output only the final PR body, nothing else. Generate a short GitHub pull request description for the current branch. Requirements: - Concise and natural. - Summarize actual code/config changes in this PR. - Start directly with the bullets. No summary or intro sentence; the title is a separate field. - Prefer 2-4 bullets. - Do not invent details. - Do not include Linear ticket lines. - Output only the final PR description body. EOF local prompt prompt=$(cat "$prompt_file") local models=(anthropic-extra/claude-opus-4-8 anthropic/claude-opus-4-8) local model for model in "${models[@]}"; do if command -v gum >/dev/null 2>&1 && [[ $assume_yes -eq 0 ]]; then gum spin --title "Generating PR description with pi ($model)..." -- pi -p --no-session --model "$model" --thinking off "$prompt" >"$body_file" else echo "Generating PR description with pi ($model)..." >&2 pi -p --no-session --model "$model" --thinking off "$prompt" >"$body_file" fi [[ -s $body_file ]] && return 0 done return 1 } edit_pr_description() { local body_file=$1 local editor=${EDITOR:-vi} "$editor" "$body_file" } ensure_branch_pushed() { local choice if ! git rev-parse --verify --quiet '@{u}' >/dev/null; then choice=$(normalize_yn "$push_choice") [[ -z "$choice" ]] && choice=$(prompt_menu "Current branch has no upstream. Push branch now?" "Yes" "No") if [[ "$choice" != "Yes" ]]; then echo "Current branch has no upstream. Push branch first." >&2 exit 1 fi git push --set-upstream origin "$(git branch --show-current)" return fi local unpushed unpushed=$(git rev-list --count '@{u}..HEAD') if [[ "$unpushed" -ne 0 ]]; then choice=$(normalize_yn "$push_choice") [[ -z "$choice" ]] && choice=$(prompt_menu "Current branch has $unpushed unpushed commit(s). Push branch now?" "Yes" "No") if [[ "$choice" != "Yes" ]]; then echo "Current branch has $unpushed unpushed commit(s). Push branch first." >&2 exit 1 fi git push fi } ensure_branch_pushed # Conventional commit types allowed in PR titles. allowed_types=(feat fix docs style refactor perf test build ci chore revert deps) validate_title() { local t=$1 # Match: type(optional-scope)!?: subject local re='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|deps)(\([^)]+\))?!?: .+' [[ "$t" =~ $re ]] } title_hint="format: [(scope)]: | types: ${allowed_types[*]}" if [[ -n "$title" ]] && ! validate_title "$title"; then echo "Invalid PR title: '$title'" >&2 echo "$title_hint" >&2 exit 1 fi while [[ -z "$title" ]]; do title=$(prompt_input "Pull Request Title ($title_hint)") if [[ -z "$title" ]]; then echo "PR title is required." >&2 [[ $assume_yes -eq 1 ]] && exit 1 continue fi if ! validate_title "$title"; then echo "Invalid title. $title_hint" >&2 [[ $assume_yes -eq 1 ]] && exit 1 title="" fi done # Only prompt for base if user did not pass --base at all (empty is a valid explicit value when --yes). if [[ -z "$base" && $assume_yes -eq 0 ]]; then # Allow an empty answer to mean "default branch". base=$(prompt_input "Base Branch (leave empty for default branch)") fi draft_norm=$(normalize_yn "$draft") [[ -z "$draft_norm" ]] && draft_norm=$(prompt_menu "Create as draft PR?" "No" "Yes") if [[ -z "$reviewers" ]]; then reviewer_choice=$(prompt_menu "Add Reviewers" "Default" "CNS") else case "$reviewers" in default) reviewer_choice="Default" ;; cns) reviewer_choice="CNS" ;; *) echo "Unknown --reviewers value: $reviewers (expected default|cns)" >&2 exit 2 ;; esac fi if [[ -z "$body_file" ]]; then gen_norm=$(normalize_yn "$gen_desc") [[ -z "$gen_norm" ]] && gen_norm=$(prompt_menu "Generate PR description with pi?" "No" "Yes") if [[ "$gen_norm" == "Yes" ]]; then body_file=$(new_md_tempfile) temp_files+=("$body_file") generate_pr_description "$body_file" fi fi # Interactive Linear collection only when no --linear flags were passed and not --yes. if [[ ${#linear_links[@]} -eq 0 && $assume_yes -eq 0 ]]; then add_linear=$(prompt_menu "Any Linear ticket(s) linked to this PR?" "No" "Yes") while [[ "$add_linear" == "Yes" ]]; do linear_ticket=$(prompt_input "Linear ticket ID (example: ABC-123)") linear_relation=$(prompt_menu "Relationship for $linear_ticket" "Closes" "Ref") if [[ -n "$linear_ticket" ]]; then linear_links+=("$linear_relation $linear_ticket") fi add_linear=$(prompt_menu "Add another Linear ticket?" "No" "Yes") done fi linear_appended=0 append_linear_links() { { printf '\n\n' printf '%s\n' "${linear_links[@]}" } >>"$1" linear_appended=1 } edit_norm=$(normalize_yn "$edit_desc") [[ -z "$edit_norm" ]] && edit_norm=$(prompt_menu "Open PR description in \$EDITOR before creating PR?" "No" "Yes") if [[ "$edit_norm" == "Yes" ]]; then if [[ -z "$body_file" ]]; then body_file=$(new_md_tempfile) temp_files+=("$body_file") fi # Append Linear links before editing so they are part of the description the # user customizes (and are not lost when a fresh body file is edited). [[ ${#linear_links[@]} -gt 0 ]] && append_linear_links "$body_file" edit_pr_description "$body_file" fi # Append Linear links if we have a body file and haven't done so already. if [[ $linear_appended -eq 0 && -n "$body_file" && ${#linear_links[@]} -gt 0 ]]; then append_linear_links "$body_file" fi args=(pr create --assignee @me --title "$title" --fill) [[ -n "$body_file" ]] && args+=(--body-file "$body_file") [[ -n "$base" ]] && args+=(--base "$base") [[ "$draft_norm" == "Yes" ]] && args+=(--draft) [[ "$reviewer_choice" == "CNS" ]] && args+=(--reviewer "peterbornerup,farhadh,jdejnek,finoutlook,befe-oh-no") pr_url=$(gh "${args[@]}") printf '%s\n' "$pr_url" # If we created the PR without a body but have Linear links, append them after creation. if [[ -z "$body_file" && ${#linear_links[@]} -gt 0 ]]; then edit_body_file=$(new_md_tempfile) temp_files+=("$edit_body_file") gh pr view "$pr_url" --json body --jq '.body' >"$edit_body_file" { printf '\n\n' printf '%s\n' "${linear_links[@]}" } >>"$edit_body_file" gh pr edit "$pr_url" --body-file "$edit_body_file" >/dev/null echo "Added Linear links to PR description." >&2 fi