|
| 1 | +#!/usr/bin/env bash |
| 2 | +# |
| 3 | +# Classify release commits into categories for CHANGELOG entries and PR bodies. |
| 4 | +# |
| 5 | +# Reads annotated commit lines on stdin, one per line, in the format produced by |
| 6 | +# the "Annotate commits with author + external flag" workflow step: |
| 7 | +# |
| 8 | +# <short-sha> <subject> -- author=<github-login> external=<true|false> |
| 9 | +# |
| 10 | +# Writes TSV to stdout, one line per included commit: |
| 11 | +# |
| 12 | +# <category>\t<description>\t<pr-ref>\t<login>\t<external> |
| 13 | +# |
| 14 | +# where <category> is one of: breaking, feature, fix, improvement, enhancement |
| 15 | +# and <pr-ref> is either "#NNN" or empty. |
| 16 | +# |
| 17 | +# Chore-type commits (chore/ci/docs/test/build/style, version-bump and |
| 18 | +# release-automation commits) are dropped entirely. |
| 19 | +# |
| 20 | +# Classification order: |
| 21 | +# 1. Conventional-commit prefix (feat:, fix:, perf:, ...). A "!" before the |
| 22 | +# colon, or a "BREAKING CHANGE" marker, promotes the commit to breaking. |
| 23 | +# 2. Leading imperative verb, for the many historical commits in these repos |
| 24 | +# that predate conventional commits. |
| 25 | +# 3. Anything left over becomes "enhancement". |
| 26 | + |
| 27 | +set -euo pipefail |
| 28 | + |
| 29 | +# Subjects matching these patterns are release-automation noise, never user-facing. |
| 30 | +readonly SKIP_SUBJECT_RE='^(Prepare for |Bump versions|Update changelogs|Applying documentation updates|Merge (branch|pull request|remote-tracking)|Release/|Run rush change|Initial (commit|release)|Resync )' |
| 31 | + |
| 32 | +# Conventional-commit types that never appear in release notes. |
| 33 | +readonly SKIP_TYPE_RE='^(chore|ci|docs|test|tests|build|style|revert)$' |
| 34 | + |
| 35 | +# Bare-subject chore detection, for the many commits predating conventional |
| 36 | +# commits. Deliberately narrow: it requires a chore *object* (CI, README, API |
| 37 | +# docs, the test suite, a linter) so that user-facing changes which merely |
| 38 | +# mention a version or a dependency are still included. Verified against the |
| 39 | +# existing hand-written CHANGELOGs, which omit exactly these. |
| 40 | +readonly SKIP_BARE_RE='(^|[[:space:]])(ci|CI)([[:space:]]|$)|[Ll]inting|[Ll]int issues|(unit|integration|flaky)[[:space:]]+tests?|tests?[[:space:]]+(in|on)[[:space:]]+CI|README|API docs|api docs|[Dd]ocumentation (build|updates|page)|docs\.snowplow\.io|API ref|[Cc]hangelog|GitHub [Aa]ction|publish action|prepare-release|\[skip ci\]|[Cc]laude|CLAUDE\.md|[Aa]gent pipeline|instrumentation|jest tests|[Ss]igning config for demo|[Bb]undlemon|[Cc]overalls|[Dd]ependabot|[Aa]ddress (PR )?review|[Aa]ddress review comments|[Ff]ix(up)? review|[Aa]pply review|[Ss]elf-review|[Rr]ebase|[Mm]erge conflict|[Tt]ypo in (test|CI)' |
| 41 | + |
| 42 | +while IFS= read -r line; do |
| 43 | + [[ -z "$line" ]] && continue |
| 44 | + |
| 45 | + # Split the trailing "-- author=... external=..." metadata off the subject. |
| 46 | + # The separator is optional: dry runs and manual testing pipe bare |
| 47 | + # "<sha> <subject>" lines, and treating those as metadata would leak the |
| 48 | + # parsed fields into the description. |
| 49 | + if [[ "$line" == *" -- author="* ]]; then |
| 50 | + meta="${line##*" -- "}" |
| 51 | + head="${line%" -- "*}" |
| 52 | + else |
| 53 | + meta="" |
| 54 | + head="$line" |
| 55 | + fi |
| 56 | + |
| 57 | + # The leading short sha is dropped; only the subject drives classification. |
| 58 | + subject="${head#* }" |
| 59 | + [[ "$subject" == "$head" ]] && subject="" |
| 60 | + [[ -z "$subject" ]] && continue |
| 61 | + |
| 62 | + login="" |
| 63 | + external="false" |
| 64 | + if [[ -n "$meta" ]]; then |
| 65 | + if [[ "$meta" =~ author=([^[:space:]]*) ]]; then |
| 66 | + login="${BASH_REMATCH[1]}" |
| 67 | + fi |
| 68 | + if [[ "$meta" =~ external=([^[:space:]]*) ]]; then |
| 69 | + external="${BASH_REMATCH[1]}" |
| 70 | + fi |
| 71 | + fi |
| 72 | + |
| 73 | + # Drop release-automation commits. |
| 74 | + if [[ "$subject" =~ $SKIP_SUBJECT_RE ]]; then |
| 75 | + continue |
| 76 | + fi |
| 77 | + |
| 78 | + breaking="false" |
| 79 | + # "BREAKING CHANGE" / "BREAKING-CHANGE" anywhere in the subject is a strong signal. |
| 80 | + if [[ "$subject" == *"BREAKING CHANGE"* || "$subject" == *"BREAKING-CHANGE"* ]]; then |
| 81 | + breaking="true" |
| 82 | + fi |
| 83 | + |
| 84 | + category="" |
| 85 | + description="$subject" |
| 86 | + |
| 87 | + # --- Rule 1: conventional-commit prefix --------------------------------- |
| 88 | + # Matches "type: ", "type(scope): ", and the breaking "type!: " / "type(scope)!: ". |
| 89 | + if [[ "$subject" =~ ^([a-zA-Z]+)(\(([^\)]*)\))?(!)?:[[:space:]]+(.*)$ ]]; then |
| 90 | + type="$(printf '%s' "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]')" |
| 91 | + bang="${BASH_REMATCH[4]}" |
| 92 | + rest="${BASH_REMATCH[5]}" |
| 93 | + |
| 94 | + if [[ "$type" =~ $SKIP_TYPE_RE ]]; then |
| 95 | + continue |
| 96 | + fi |
| 97 | + |
| 98 | + [[ -n "$bang" ]] && breaking="true" |
| 99 | + |
| 100 | + case "$type" in |
| 101 | + feat|feature) category="feature" ;; |
| 102 | + fix|bugfix) category="fix" ;; |
| 103 | + perf|refactor) category="improvement" ;; |
| 104 | + *) category="" ;; # unknown type: fall through to the verb rule |
| 105 | + esac |
| 106 | + |
| 107 | + if [[ -n "$category" ]]; then |
| 108 | + # Drop the scope. It duplicates information already obvious from the |
| 109 | + # description in these repos (e.g. "emitter: wake emitter on signal"), |
| 110 | + # and keeping it forces an awkward capitalisation of the scope token. |
| 111 | + description="$rest" |
| 112 | + fi |
| 113 | + fi |
| 114 | + |
| 115 | + # --- Rule 2: leading imperative verb ----------------------------------- |
| 116 | + # Covers the bare-subject style used throughout these repos' history. |
| 117 | + if [[ -z "$category" ]]; then |
| 118 | + # Bare chore commits (CI, docs, lint, test-suite upkeep) are not |
| 119 | + # user-facing. Only applied here: an explicit "feat:"/"fix:" prefix in |
| 120 | + # rule 1 always wins, so a genuine fix mentioning CI is never dropped. |
| 121 | + if [[ "$subject" =~ $SKIP_BARE_RE ]]; then |
| 122 | + continue |
| 123 | + fi |
| 124 | + |
| 125 | + verb="$(printf '%s' "$subject" | awk '{print tolower($1)}')" |
| 126 | + case "$verb" in |
| 127 | + fix|fixes|fixed|resolve|resolves|correct|corrects|prevent|prevents|address|addresses|avoid|avoids|handle|handles|guard) |
| 128 | + category="fix" ;; |
| 129 | + add|adds|added|introduce|introduces|support|supports|expose|exposes|implement|implements|allow|allows|enable|enables|create|creates) |
| 130 | + category="feature" ;; |
| 131 | + improve|improves|update|updates|upgrade|upgrades|refactor|refactors|change|changes|make|makes|migrate|migrates|remove|removes|strip|strips|filter|filters|rename|renames|switch|switches|reduce|reduces|optimise|optimize|simplify|declare|deprecate|deprecates|move|moves|replace|replaces|drop|drops|adjust|adjusts|annotate|clean|unify|tidy|undeprecate|reintroduce) |
| 132 | + category="improvement" ;; |
| 133 | + *) |
| 134 | + category="enhancement" ;; |
| 135 | + esac |
| 136 | + fi |
| 137 | + |
| 138 | + [[ "$breaking" == "true" ]] && category="breaking" |
| 139 | + |
| 140 | + # Extract a PR/issue reference to preserve at the end of the line. |
| 141 | + # |
| 142 | + # Subjects may carry two references: a trailing "(#NNN)" squash-merge marker |
| 143 | + # added by GitHub, and an inline "(close #NNN)" issue link written by the |
| 144 | + # author. Prefer the inline issue reference (it names the user-visible issue) |
| 145 | + # and strip both markers so the formatters re-append exactly one. |
| 146 | + pr_ref="" |
| 147 | + if [[ "$description" =~ \((close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]]+\#([0-9]+)\) ]]; then |
| 148 | + # Preserve the "close" keyword: the existing CHANGELOGs write "(close #720)", |
| 149 | + # and it keeps GitHub's issue-closing semantics visible in the notes. |
| 150 | + pr_ref="${BASH_REMATCH[1]} #${BASH_REMATCH[3]}" |
| 151 | + description="$(printf '%s' "$description" \ |
| 152 | + | sed -E 's/[[:space:]]*\((close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]]+#[0-9]+\)//I')" |
| 153 | + # Drop a redundant trailing squash marker, e.g. "... (close #720) (#720)". |
| 154 | + description="$(printf '%s' "$description" | sed -E 's/[[:space:]]*\(#[0-9]+\)[[:space:]]*$//')" |
| 155 | + elif [[ "$description" =~ \(\#([0-9]+)\)[[:space:]]*$ ]]; then |
| 156 | + pr_ref="#${BASH_REMATCH[1]}" |
| 157 | + description="$(printf '%s' "$description" | sed -E 's/[[:space:]]*\(#[0-9]+\)[[:space:]]*$//')" |
| 158 | + elif [[ "$description" =~ \#([0-9]+) ]]; then |
| 159 | + pr_ref="#${BASH_REMATCH[1]}" |
| 160 | + fi |
| 161 | + |
| 162 | + # Strip an inline BREAKING CHANGE marker; the category already conveys it. |
| 163 | + description="$(printf '%s' "$description" \ |
| 164 | + | sed -E 's/^BREAKING[ -]CHANGE:?[[:space:]]*//; s/[[:space:]]*BREAKING[ -]CHANGE:?[[:space:]]*/ /')" |
| 165 | + |
| 166 | + # Tidy whitespace and drop a trailing period for consistent list formatting. |
| 167 | + description="$(printf '%s' "$description" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//; s/\.$//')" |
| 168 | + [[ -z "$description" ]] && continue |
| 169 | + |
| 170 | + # Capitalise the first letter so bare conventional-commit bodies read as list items. |
| 171 | + first="$(printf '%s' "${description:0:1}" | tr '[:lower:]' '[:upper:]')" |
| 172 | + description="${first}${description:1}" |
| 173 | + |
| 174 | + # Empty fields are written as "-": bash's word splitting collapses runs of |
| 175 | + # tabs, so a genuinely empty column would shift every later field left. |
| 176 | + # The formatters translate "-" back to an empty string. |
| 177 | + printf '%s\t%s\t%s\t%s\t%s\n' \ |
| 178 | + "$category" "$description" "${pr_ref:--}" "${login:--}" "$external" |
| 179 | +done |
0 commit comments