#!/usr/bin/env bash set -euo pipefail temp_files=() cleanup() { rm -f "${temp_files[@]}" } trap cleanup EXIT # Ctrl+C: gum handles SIGINT itself and exits 130, so also handle the case where # the signal does reach us (plain read/select fallbacks). abort() { echo "Aborted." >&2 exit 130 } trap abort INT TERM die() { echo "$*" >&2 exit 1 } # Bail out when an interactive prompt was cancelled with Ctrl+C (gum exits 130); # other non-zero statuses (e.g. Esc = no selection) are treated as empty input. check_cancelled() { local status=$1 [[ $status -eq 130 ]] && abort return 0 } has_gum() { command -v gum >/dev/null 2>&1 } # new_tempfile [suffix]: create a temp file, register it for cleanup and # store its path in (no subshell, so temp_files stays in sync). new_tempfile() { local f f=$(mktemp -t pr-create.XXXXXX) if [[ -n "${2:-}" ]]; then mv "$f" "$f$2" f+=$2 fi temp_files+=("$f") printf -v "$1" '%s' "$f" } trim() { local s=$1 s=${s#"${s%%[![:space:]]*}"} s=${s%"${s##*[![:space:]]}"} printf '%s' "$s" } usage() { cat <<'EOF' Usage: git pr [options] Fills in every field automatically, then shows a review screen where you can change anything before the PR is created. --yes skips the review. Defaults: title last commit subject (single conventional commit), else generated by pi body generated by pi from the branch diff base repo default branch linear "Closes " for a ticket ID at the start of the branch name draft no, reviewers none, push yes Options: --title PR title (conventional, e.g. "feat: ...", "fix(api): ...") --base Base branch (default: repo default) --draft / --no-draft Create as draft PR --reviewers Reviewer group from config, comma separated handles, or "none" (repeatable) --body-file Use this file as the PR body (skips generation) --linear <"Closes ABC-1"> Linear link line (repeatable, replaces branch detection; "" = none) --push / --no-push Push the branch if needed (default: push) --no-generate Do not call pi for title or body -y, --yes Skip the review screen and create directly -h, --help Show this help Title and body come from the /pr-title and /pr-body pi commands (pi-extensions pr-create), using the "fast" chain in ~/.pi/agent/extension-models.json. Reviewer groups are read from: ${GIT_PR_REVIEWERS_FILE:-${XDG_CONFIG_HOME:-~/.config}/git-pr/reviewers.conf} EOF } title="" base="" draft="no" reviewer_args=() selected_reviewers=() body_file="" body_source="none" linear_links=() linear_given=0 push="yes" generate="yes" 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) reviewer_args+=("$2") shift 2 ;; --body-file) [[ -f $2 ]] || die "Body file not found: $2" # Work on a copy so edits and Linear lines never touch the caller's file. new_tempfile body_file .md cp "$2" "$body_file" body_source="file" shift 2 ;; --linear) [[ -n $2 ]] && linear_links+=("$2") linear_given=1 shift 2 ;; --push) push="yes" shift ;; --no-push) push="no" shift ;; --no-generate) generate="no" shift ;; -y | --yes) assume_yes=1 shift ;; -h | --help) usage exit 0 ;; *) echo "Unknown option: $1" >&2 usage >&2 exit 2 ;; esac done # --- prompts --------------------------------------------------------------- # Echo the question/answer pair so the transcript stays visible (gum clears its # own UI once an answer is given). record_answer() { local label=$1 answer=$2 [[ -z "$answer" ]] && answer="(empty)" if has_gum; then gum style --foreground 2 "✓ $label: $answer" >&2 else printf '✓ %s: %s\n' "$label" "$answer" >&2 fi } prompt_input() { local prompt=$1 placeholder=${2:-} initial=${3:-} value if has_gum; then value=$(gum input --width 0 --prompt "$prompt: " --value "$initial" ${placeholder:+--placeholder "$placeholder"}) || check_cancelled $? else read -r -e -i "$initial" -p "$prompt: " value || abort fi record_answer "$prompt" "$value" printf '%s\n' "$value" } prompt_menu() { local title=$1 choice shift if has_gum; then choice=$(gum choose --header "$title" --cursor "❯ " --header.foreground "#cba6f7" --header.bold --cursor.foreground "#f5c2e7" "$@") || check_cancelled $? else select choice in "$@"; do [[ -n "$choice" ]] && break done fi printf '%s\n' "$choice" } # Multi-select with fuzzy search; prints one selection per line. prompt_multi() { local title=$1 selection answer shift [[ $# -eq 0 ]] && return if has_gum; then selection=$(gum filter --no-limit --height 15 --header "$title" --placeholder "type to search, tab to select, enter to confirm" "$@") || check_cancelled $? else read -r -p "$title (space separated, empty for none): " answer || abort # shellcheck disable=SC2086 # intentional word splitting selection=$(printf '%s\n' $answer) fi record_answer "$title" "$(printf '%s' "$selection" | tr '\n' ' ')" printf '%s\n' "$selection" } # --- reviewers ------------------------------------------------------------- reviewers_file=${GIT_PR_REVIEWERS_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/git-pr/reviewers.conf} reviewer_group_names=() reviewer_group_members=() all_reviewers=() all_reviewer_labels=() # dedupe_list : print items once each, preserving order. dedupe_list() { local out=() item existing seen for item in "$@"; do [[ -z "$item" ]] && continue seen=0 for existing in "${out[@]:-}"; do [[ "$existing" == "$item" ]] && seen=1 && break done [[ $seen -eq 0 ]] && out+=("$item") done [[ ${#out[@]} -gt 0 ]] && printf '%s\n' "${out[@]}" return 0 } # Config format (INI-ish, sections optional; default section is [people]): # [groups] # Core Network Squad = onomondo/core-network-squad # [people] # peterbornerup = Peter S. Bornerup add_person() { local handle=$1 name=$2 existing [[ -z "$handle" ]] && return 0 for existing in "${all_reviewers[@]:-}"; do [[ "$existing" == "$handle" ]] && return 0 done all_reviewers+=("$handle") if [[ -n "$name" ]]; then all_reviewer_labels+=("$handle - $name") else all_reviewer_labels+=("$handle") fi } load_reviewer_config() { [[ -f "$reviewers_file" ]] || return 0 local line section=people key value handle local handles=() while IFS= read -r line || [[ -n "$line" ]]; do line=$(trim "${line%%#*}") [[ -z "$line" ]] && continue if [[ "$line" =~ ^\[(.+)\]$ ]]; then section=$(printf '%s' "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]') continue fi if [[ "$line" == *=* ]]; then key=$(trim "${line%%=*}") value=$(trim "${line#*=}") else key=$line value="" fi [[ -z "$key" ]] && continue if [[ "$section" == "groups" ]]; then [[ -z "$value" ]] && continue value=$(printf '%s' "$value" | tr -d '[:space:]') reviewer_group_names+=("$key") reviewer_group_members+=("$value") IFS=',' read -r -a handles <<<"$value" for handle in "${handles[@]}"; do # Team refs (org/team) are not individually selectable. [[ -n "$handle" && "$handle" != */* ]] && add_person "$handle" "" done else add_person "$key" "$value" fi done <"$reviewers_file" } group_members() { local want=$1 i for i in "${!reviewer_group_names[@]}"; do if [[ "${reviewer_group_names[$i]}" == "$want" ]]; then printf '%s' "${reviewer_group_members[$i]}" return 0 fi done return 1 } add_reviewers() { local spec=$1 members handle local handles=() [[ "$(printf '%s' "$spec" | tr '[:upper:]' '[:lower:]')" == "none" ]] && return 0 if members=$(group_members "$spec"); then spec=$members fi IFS=',' read -r -a handles <<<"$spec" for handle in "${handles[@]}"; do handle=$(trim "$handle") [[ -n "$handle" ]] && selected_reviewers+=("$handle") done return 0 } pick_reviewers() { local choices=() label group manual for group in "${reviewer_group_names[@]}"; do choices+=("Group: $group") done choices+=("${all_reviewer_labels[@]}") selected_reviewers=() while IFS= read -r label; do [[ -z "$label" ]] && continue if [[ "$label" == "Group: "* ]]; then add_reviewers "${label#Group: }" else # Labels look like "handle - Full Name"; keep the handle. selected_reviewers+=("${label%% *}") fi done < <(prompt_multi "Reviewers" "${choices[@]}") manual=$(prompt_input "Extra handles" "comma separated, optional") [[ -n "$manual" ]] && add_reviewers "$manual" return 0 } reviewers_csv() { local out out=$(dedupe_list "${selected_reviewers[@]:-}" | paste -sd, -) printf '%s' "$out" } # --- title ----------------------------------------------------------------- allowed_types=(feat fix docs style refactor perf test build ci chore revert deps) types_alt=$( IFS='|' printf '%s' "${allowed_types[*]}" ) title_re="^(${types_alt})(\([^)]+\))?!?: .+" loose_title_re="^(${types_alt})(\([^)]+\))?(!)?[[:space:]]+(.+)$" title_hint="[(scope)][!]: | types: ${allowed_types[*]}" # Print a valid conventional title for $1 (fixing gitmoji prefixes and a missing # colon), or fail. normalize_title() { local t t=$(trim "$1") [[ "$t" =~ $title_re ]] && printf '%s\n' "$t" && return 0 # shortcut: strips :gitmoji: codes only, not unicode emoji; extend if those show up. while [[ "$t" =~ ^:[a-z0-9_+-]+:[[:space:]]*(.*)$ ]]; do t=${BASH_REMATCH[1]} done if [[ "$t" =~ $loose_title_re ]]; then t="${BASH_REMATCH[1]}${BASH_REMATCH[2]}${BASH_REMATCH[3]}: ${BASH_REMATCH[4]}" fi [[ "$t" =~ $title_re ]] && printf '%s\n' "$t" && return 0 return 1 } # --- branch context -------------------------------------------------------- default_branch() { git default-branch 2>/dev/null && return 0 gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null } resolve_base_ref() { local b=${1#origin/} ref [[ -n "$b" ]] || return 1 for ref in "origin/$b" "$b"; do if git rev-parse --verify --quiet "$ref" >/dev/null; then printf '%s' "$ref" return 0 fi done return 1 } base_ref="" # Resolve the base ref; generation is disabled when it cannot be resolved. build_context() { base_ref=$(resolve_base_ref "${base:-$default_base}") || { echo "Could not resolve base '${base:-$default_base}': generation disabled." >&2 base_ref="" } return 0 } # Linear ticket IDs at the start of the branch or after a slash: ONO-123-x, joe/ono-123-x. branch_linear_links() { local rest id rest=$(git branch --show-current) while [[ "$rest" =~ (^|/)([A-Za-z][A-Za-z0-9]{1,9}-[0-9]+)(-|/|$) ]]; do id=${BASH_REMATCH[2]} printf 'Closes %s\n' "${id^^}" rest=${rest#*"${BASH_REMATCH[0]}"} done } # --- pi generation --------------------------------------------------------- # run_pi