|
| 1 | +#!/usr/bin/env bash |
| 2 | + |
| 3 | +set -euo pipefail |
| 4 | + |
| 5 | +TYPES='feat|fix|perf|refactor|docs|revert|deps|chore|test|ci|build|style' |
| 6 | +MODEL='claude-opus-5' |
| 7 | +DIFF_BYTES=40000 |
| 8 | + |
| 9 | +check() { |
| 10 | + local title=${1-} subject |
| 11 | + printf '%s' "$title" | grep -Eq "^($TYPES)(\([^)]+\))?!?: .+" || return 1 |
| 12 | + subject=${title#*: } |
| 13 | + printf '%s' "$subject" | grep -Eq '^[A-Z][a-z]' && return 1 |
| 14 | + case "$subject" in *.) return 1 ;; esac |
| 15 | + return 0 |
| 16 | +} |
| 17 | + |
| 18 | +scope_for() { |
| 19 | + local files=$1 scopes |
| 20 | + scopes=$(printf '%s\n' "$files" | sed -n \ |
| 21 | + -e 's#^apps/\([^/]*\)/.*#\1#p' \ |
| 22 | + -e 's#^packages/\([^/]*\)/.*#\1#p' \ |
| 23 | + -e 's#^docs/.*#docs#p' \ |
| 24 | + -e 's#^\.github/.*#ci#p' | |
| 25 | + sort -u) |
| 26 | + if [ "$(printf '%s\n' "$scopes" | grep -c .)" = "1" ]; then |
| 27 | + printf '%s' "$scopes" |
| 28 | + fi |
| 29 | +} |
| 30 | + |
| 31 | +type_for() { |
| 32 | + local files=$1 |
| 33 | + if ! grep -qvE '(^|/)(docs/|README|CONTRIBUTING|AGENTS|.*\.md$)' <<<"$files"; then |
| 34 | + printf 'docs' |
| 35 | + elif ! grep -qv '^\.github/' <<<"$files"; then |
| 36 | + printf 'ci' |
| 37 | + elif ! grep -qvE '(^|/)(test|tests|__tests__)/|\.spec\.|\.test\.' <<<"$files"; then |
| 38 | + printf 'test' |
| 39 | + elif ! grep -qvE '(^|/)(package\.json|bun\.lock|package-lock\.json)$' <<<"$files"; then |
| 40 | + printf 'chore' |
| 41 | + else |
| 42 | + printf 'feat' |
| 43 | + fi |
| 44 | +} |
| 45 | + |
| 46 | +subject_from_branch() { |
| 47 | + local subject |
| 48 | + subject=$(printf '%s' "${1-}" | sed -e 's#^[^/]*/##' -e 's/[-_]/ /g' -e 's/\.$//' -e 's/[[:space:]]*$//') |
| 49 | + printf '%s' "$subject" | grep -Eq '^[A-Z][a-z]' && |
| 50 | + subject=$(printf '%s' "$subject" | sed -e 's/^\(.\)/\l\1/') |
| 51 | + printf '%s' "$subject" |
| 52 | +} |
| 53 | + |
| 54 | +model_title() { |
| 55 | + local base=$1 head=$2 scopes=$3 evidence body response title |
| 56 | + command -v jq >/dev/null 2>&1 || return 1 |
| 57 | + |
| 58 | + evidence=$( |
| 59 | + printf 'Commit subjects:\n%s\n\n' "$(git log "$base..$head" --no-merges --reverse --format='- %s')" |
| 60 | + printf 'Files changed:\n%s\n\n' "$(git diff --stat "$base..$head")" |
| 61 | + printf 'Diff:\n%s\n' "$(git diff "$base..$head" | head -c "$DIFF_BYTES")" |
| 62 | + ) |
| 63 | + |
| 64 | + body=$(jq -n \ |
| 65 | + --arg model "$MODEL" \ |
| 66 | + --arg system "You write the Conventional Commit title for a pull request that will be squashed onto main, so the title is the commit subject and the line a person reads in the changelog months from now. Write it for that reader: what the change does for them, not which files moved. |
| 67 | +
|
| 68 | +Format: type(scope): subject |
| 69 | +Types: feat (new capability, minor bump), fix, perf, refactor, docs, revert (patch), chore, test, ci, build, style (no release). Add ! before the colon only for a breaking change. |
| 70 | +Scopes, one only, omitted when the change spans several: $scopes. |
| 71 | +Subject: lowercase imperative, no trailing full stop, under 60 characters, no ticket numbers and no file paths. |
| 72 | +
|
| 73 | +Reply with the title on one line and nothing else." \ |
| 74 | + --arg user "$evidence" \ |
| 75 | + '{model: $model, |
| 76 | + max_tokens: 4000, |
| 77 | + system: $system, |
| 78 | + output_config: {effort: "low"}, |
| 79 | + messages: [{role: "user", content: $user}]}') |
| 80 | + |
| 81 | + response=$(curl -fsS --max-time 120 https://api.anthropic.com/v1/messages \ |
| 82 | + -H "x-api-key: $ANTHROPIC_API_KEY" \ |
| 83 | + -H 'anthropic-version: 2023-06-01' \ |
| 84 | + -H 'content-type: application/json' \ |
| 85 | + -d "$body") || return 1 |
| 86 | + |
| 87 | + [ "$(printf '%s' "$response" | jq -r '.stop_reason // ""')" = "refusal" ] && return 1 |
| 88 | + |
| 89 | + title=$(printf '%s' "$response" | |
| 90 | + jq -r '[.content[] | select(.type == "text") | .text] | join("")' | |
| 91 | + grep -v '^[[:space:]]*$' | head -1 | |
| 92 | + sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^`//' -e 's/`$//') |
| 93 | + |
| 94 | + check "$title" || return 1 |
| 95 | + printf '%s' "$title" |
| 96 | +} |
| 97 | + |
| 98 | +generate() { |
| 99 | + local base=$1 head=$2 branch=${3-} files scope subject conventional title |
| 100 | + |
| 101 | + files=$(git diff --name-only "$base..$head") |
| 102 | + scope=$(scope_for "$files") |
| 103 | + |
| 104 | + if [ -n "${ANTHROPIC_API_KEY:-}" ]; then |
| 105 | + local allowed |
| 106 | + allowed=$( (printf '%s\n' "$files" | sed -n -e 's#^apps/\([^/]*\)/.*#\1#p' -e 's#^packages/\([^/]*\)/.*#\1#p' |
| 107 | + printf 'docs\nci\ndeps\n') | sort -u | paste -sd ',' - | sed 's/,/, /g') |
| 108 | + if title=$(model_title "$base" "$head" "$allowed"); then |
| 109 | + printf '%s\n' "$title" |
| 110 | + return 0 |
| 111 | + fi |
| 112 | + echo "The model did not return a usable title — falling back to the branch name." >&2 |
| 113 | + fi |
| 114 | + |
| 115 | + conventional=$( |
| 116 | + while IFS= read -r subject; do |
| 117 | + if check "$subject"; then |
| 118 | + printf '%s' "$subject" |
| 119 | + break |
| 120 | + fi |
| 121 | + done < <(git log "$base..$head" --no-merges --reverse --format=%s) |
| 122 | + ) |
| 123 | + if [ -n "$conventional" ]; then |
| 124 | + printf '%s\n' "$conventional" |
| 125 | + return 0 |
| 126 | + fi |
| 127 | + |
| 128 | + subject=$(subject_from_branch "$branch") |
| 129 | + [ -n "$subject" ] || subject="update ${files%%$'\n'*}" |
| 130 | + if [ -n "$scope" ]; then |
| 131 | + printf '%s(%s): %s\n' "$(type_for "$files")" "$scope" "$subject" |
| 132 | + else |
| 133 | + printf '%s: %s\n' "$(type_for "$files")" "$subject" |
| 134 | + fi |
| 135 | +} |
| 136 | + |
| 137 | +case "${1-}" in |
| 138 | +check) check "${2-}" ;; |
| 139 | +generate) generate "${2:?base ref}" "${3:?head ref}" "${4-}" ;; |
| 140 | +*) |
| 141 | + echo "usage: pr-title.sh check <title> | pr-title.sh generate <base> <head> [branch]" >&2 |
| 142 | + exit 2 |
| 143 | + ;; |
| 144 | +esac |
0 commit comments