| 1 | #!/bin/bash |
| 2 | # |
| 3 | # Invocation: mkreleaselog [FIRST_REF [LAST_REF]] |
| 4 | # |
| 5 | # Generates release notes with contributor statistics, deduplicating by GitHub handle. |
| 6 | # GitHub handles are resolved from: |
| 7 | # 1. GitHub noreply emails (user@users.noreply.github.com) |
| 8 | # 2. Merge commit messages (Merge pull request #N from user/branch) |
| 9 | # 3. GitHub API via gh CLI (for squash merges) |
| 10 | # |
| 11 | # Results are cached in ~/.cache/mkreleaselog/github-handles.json |
| 12 | |
| 13 | set -euo pipefail |
| 14 | export GO111MODULE=on |
| 15 | GOPATH="$(go env GOPATH)" |
| 16 | export GOPATH |
| 17 | |
| 18 | # List of PCRE regular expressions to match "included" modules. |
| 19 | INCLUDE_MODULES=( |
| 20 | # orgs |
| 21 | "^github.com/ipfs/" |
| 22 | "^github.com/ipld/" |
| 23 | "^github.com/libp2p/" |
| 24 | "^github.com/multiformats/" |
| 25 | "^github.com/filecoin-project/" |
| 26 | "^github.com/ipfs-shipyard/" |
| 27 | "^github.com/ipshipyard/" |
| 28 | "^github.com/probe-lab/" |
| 29 | |
| 30 | # Authors of personal modules used by go-ipfs that should be mentioned in the |
| 31 | # release notes. |
| 32 | "^github.com/whyrusleeping/" |
| 33 | "^github.com/gammazero/" |
| 34 | "^github.com/Jorropo/" |
| 35 | "^github.com/guillaumemichel/" |
| 36 | "^github.com/Kubuxu/" |
| 37 | "^github.com/jbenet/" |
| 38 | "^github.com/Stebalien/" |
| 39 | "^github.com/marten-seemann/" |
| 40 | "^github.com/hsanjuan/" |
| 41 | "^github.com/lucas-clemente/" |
| 42 | "^github.com/warpfork/" |
| 43 | ) |
| 44 | |
| 45 | # List of PCRE regular expressions to match "excluded" modules. Applied after includes. |
| 46 | EXCLUDE_MODULES=( |
| 47 | "^github.com/marten-seemann/qtls" |
| 48 | ) |
| 49 | |
| 50 | # Ignored files as git pathspecs. These patters will match any full path component. |
| 51 | IGNORE_FILES=( |
| 52 | ".gx" |
| 53 | "package.json" |
| 54 | ".travis.yml" |
| 55 | "go.mod" |
| 56 | "go.sum" |
| 57 | ".github" |
| 58 | "*.pb.go" |
| 59 | "cbor_gen.go" |
| 60 | "ipldsch_*.go" |
| 61 | "*.gen.go" |
| 62 | ) |
| 63 | |
| 64 | ########################################################################################## |
| 65 | # GitHub Handle Resolution Infrastructure |
| 66 | ########################################################################################## |
| 67 | |
| 68 | # Cache location following XDG spec |
| 69 | GITHUB_CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/mkreleaselog" |
| 70 | GITHUB_CACHE_FILE="$GITHUB_CACHE_DIR/github-handles.json" |
| 71 | |
| 72 | # Timeout for gh CLI commands (seconds) |
| 73 | GH_TIMEOUT=10 |
| 74 | |
| 75 | # Associative array for email -> github handle mapping (runtime cache) |
| 76 | declare -A EMAIL_TO_GITHUB |
| 77 | |
| 78 | # Check if gh CLI is available and authenticated |
| 79 | gh_available() { |
| 80 | command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 |
| 81 | } |
| 82 | |
| 83 | # Load cached email -> github handle mappings from disk |
| 84 | load_github_cache() { |
| 85 | EMAIL_TO_GITHUB=() |
| 86 | |
| 87 | if [[ ! -f "$GITHUB_CACHE_FILE" ]]; then |
| 88 | return 0 |
| 89 | fi |
| 90 | |
| 91 | # Validate JSON before loading |
| 92 | if ! jq -e '.' "$GITHUB_CACHE_FILE" >/dev/null 2>&1; then |
| 93 | msg "Warning: corrupted cache file, ignoring" |
| 94 | return 0 |
| 95 | fi |
| 96 | |
| 97 | local email handle |
| 98 | while IFS=$'\t' read -r email handle; do |
| 99 | # Validate handle format (alphanumeric, hyphens, max 39 chars) |
| 100 | if [[ -n "$email" && -n "$handle" && "$handle" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$ ]]; then |
| 101 | EMAIL_TO_GITHUB["$email"]="$handle" |
| 102 | fi |
| 103 | done < <(jq -r 'to_entries[] | "\(.key)\t\(.value)"' "$GITHUB_CACHE_FILE" 2>/dev/null) |
| 104 | |
| 105 | msg "Loaded ${#EMAIL_TO_GITHUB[@]} cached GitHub handle mappings" |
| 106 | } |
| 107 | |
| 108 | # Save email -> github handle mappings to disk (atomic write) |
| 109 | save_github_cache() { |
| 110 | if [[ ${#EMAIL_TO_GITHUB[@]} -eq 0 ]]; then |
| 111 | return 0 |
| 112 | fi |
| 113 | |
| 114 | mkdir -p "$GITHUB_CACHE_DIR" |
| 115 | |
| 116 | local tmp_file |
| 117 | tmp_file="$(mktemp "$GITHUB_CACHE_DIR/cache.XXXXXX")" || return 1 |
| 118 | |
| 119 | # Build JSON from associative array |
| 120 | { |
| 121 | echo "{" |
| 122 | local first=true |
| 123 | local key |
| 124 | for key in "${!EMAIL_TO_GITHUB[@]}"; do |
| 125 | if [[ "$first" == "true" ]]; then |
| 126 | first=false |
| 127 | else |
| 128 | echo "," |
| 129 | fi |
| 130 | # Escape special characters in email for JSON |
| 131 | printf ' %s: %s' "$(jq -n --arg e "$key" '$e')" "$(jq -n --arg h "${EMAIL_TO_GITHUB[$key]}" '$h')" |
| 132 | done |
| 133 | echo |
| 134 | echo "}" |
| 135 | } > "$tmp_file" |
| 136 | |
| 137 | # Validate before replacing |
| 138 | if jq -e '.' "$tmp_file" >/dev/null 2>&1; then |
| 139 | mv "$tmp_file" "$GITHUB_CACHE_FILE" |
| 140 | msg "Saved ${#EMAIL_TO_GITHUB[@]} GitHub handle mappings to cache" |
| 141 | else |
| 142 | rm -f "$tmp_file" |
| 143 | msg "Warning: failed to save cache (invalid JSON)" |
| 144 | fi |
| 145 | } |
| 146 | |
| 147 | # Extract GitHub handle from email if it's a GitHub noreply address |
| 148 | # Handles: user@users.noreply.github.com and 12345678+user@users.noreply.github.com |
| 149 | extract_handle_from_noreply() { |
| 150 | local email="$1" |
| 151 | |
| 152 | if [[ "$email" =~ ^([0-9]+\+)?([a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?)@users\.noreply\.github\.com$ ]]; then |
| 153 | echo "${BASH_REMATCH[2]}" |
| 154 | return 0 |
| 155 | fi |
| 156 | return 1 |
| 157 | } |
| 158 | |
| 159 | # Extract GitHub handle from merge commit subject |
| 160 | # Handles: "Merge pull request #123 from username/branch" |
| 161 | extract_handle_from_merge_commit() { |
| 162 | local subject="$1" |
| 163 | |
| 164 | if [[ "$subject" =~ ^Merge\ pull\ request\ \#[0-9]+\ from\ ([a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?)/.*$ ]]; then |
| 165 | echo "${BASH_REMATCH[1]}" |
| 166 | return 0 |
| 167 | fi |
| 168 | return 1 |
| 169 | } |
| 170 | |
| 171 | # Extract PR number from commit subject |
| 172 | # Handles: "Subject (#123)" and "Merge pull request #123 from" |
| 173 | extract_pr_number() { |
| 174 | local subject="$1" |
| 175 | |
| 176 | if [[ "$subject" =~ \(#([0-9]+)\)$ ]]; then |
| 177 | echo "${BASH_REMATCH[1]}" |
| 178 | return 0 |
| 179 | elif [[ "$subject" =~ ^Merge\ pull\ request\ \#([0-9]+)\ from ]]; then |
| 180 | echo "${BASH_REMATCH[1]}" |
| 181 | return 0 |
| 182 | fi |
| 183 | return 1 |
| 184 | } |
| 185 | |
| 186 | # Query GitHub API for PR author (with timeout and error handling) |
| 187 | query_pr_author() { |
| 188 | local gh_repo="$1" # e.g., "ipfs/kubo" |
| 189 | local pr_num="$2" |
| 190 | |
| 191 | if ! gh_available; then |
| 192 | return 1 |
| 193 | fi |
| 194 | |
| 195 | local handle |
| 196 | handle="$(timeout "$GH_TIMEOUT" gh pr view "$pr_num" --repo "$gh_repo" --json author -q '.author.login' 2>/dev/null)" || return 1 |
| 197 | |
| 198 | # Validate handle format |
| 199 | if [[ -n "$handle" && "$handle" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$ ]]; then |
| 200 | echo "$handle" |
| 201 | return 0 |
| 202 | fi |
| 203 | return 1 |
| 204 | } |
| 205 | |
| 206 | # Query GitHub API for commit author (fallback when no PR available) |
| 207 | query_commit_author() { |
| 208 | local gh_repo="$1" # e.g., "ipfs/kubo" |
| 209 | local commit_sha="$2" |
| 210 | |
| 211 | if ! gh_available; then |
| 212 | return 1 |
| 213 | fi |
| 214 | |
| 215 | local handle |
| 216 | handle="$(timeout "$GH_TIMEOUT" gh api "/repos/$gh_repo/commits/$commit_sha" --jq '.author.login // empty' 2>/dev/null)" || return 1 |
| 217 | |
| 218 | # Validate handle format |
| 219 | if [[ -n "$handle" && "$handle" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$ ]]; then |
| 220 | echo "$handle" |
| 221 | return 0 |
| 222 | fi |
| 223 | return 1 |
| 224 | } |
| 225 | |
| 226 | # Resolve email to GitHub handle using all available methods |
| 227 | # Args: email, commit_hash (optional), repo_dir (optional), gh_repo (optional) |
| 228 | resolve_github_handle() { |
| 229 | local email="$1" |
| 230 | local commit="${2:-}" |
| 231 | local repo_dir="${3:-}" |
| 232 | local gh_repo="${4:-}" |
| 233 | |
| 234 | # Skip empty emails |
| 235 | [[ -z "$email" ]] && return 1 |
| 236 | |
| 237 | # Check runtime cache first |
| 238 | if [[ -n "${EMAIL_TO_GITHUB[$email]:-}" ]]; then |
| 239 | echo "${EMAIL_TO_GITHUB[$email]}" |
| 240 | return 0 |
| 241 | fi |
| 242 | |
| 243 | local handle="" |
| 244 | |
| 245 | # Method 1: Extract from noreply email |
| 246 | if handle="$(extract_handle_from_noreply "$email")"; then |
| 247 | EMAIL_TO_GITHUB["$email"]="$handle" |
| 248 | echo "$handle" |
| 249 | return 0 |
| 250 | fi |
| 251 | |
| 252 | # Method 2: Look at commit message for merge commit pattern |
| 253 | if [[ -n "$commit" && -n "$repo_dir" ]]; then |
| 254 | local subject |
| 255 | subject="$(git -C "$repo_dir" log -1 --format='%s' "$commit" 2>/dev/null)" || true |
| 256 | |
| 257 | if [[ -n "$subject" ]]; then |
| 258 | if handle="$(extract_handle_from_merge_commit "$subject")"; then |
| 259 | EMAIL_TO_GITHUB["$email"]="$handle" |
| 260 | echo "$handle" |
| 261 | return 0 |
| 262 | fi |
| 263 | |
| 264 | # Method 3: Query GitHub API for PR author |
| 265 | if [[ -n "$gh_repo" ]]; then |
| 266 | local pr_num |
| 267 | if pr_num="$(extract_pr_number "$subject")"; then |
| 268 | if handle="$(query_pr_author "$gh_repo" "$pr_num")"; then |
| 269 | EMAIL_TO_GITHUB["$email"]="$handle" |
| 270 | echo "$handle" |
| 271 | return 0 |
| 272 | fi |
| 273 | fi |
| 274 | fi |
| 275 | fi |
| 276 | fi |
| 277 | |
| 278 | return 1 |
| 279 | } |
| 280 | |
| 281 | # Build GitHub handle mappings for all commits in a range |
| 282 | # This does a single pass to collect PR numbers, then batch queries them |
| 283 | build_github_mappings() { |
| 284 | local module="$1" |
| 285 | local start="$2" |
| 286 | local end="${3:-HEAD}" |
| 287 | local repo |
| 288 | repo="$(strip_version "$module")" |
| 289 | local dir |
| 290 | local gh_repo="" |
| 291 | |
| 292 | if [[ "$module" == "github.com/ipfs/kubo" ]]; then |
| 293 | dir="$ROOT_DIR" |
| 294 | else |
| 295 | dir="$GOPATH/src/$repo" |
| 296 | fi |
| 297 | |
| 298 | # Extract gh_repo for API calls (e.g., "ipfs/kubo" from "github.com/ipfs/kubo") |
| 299 | if [[ "$repo" =~ ^github\.com/(.+)$ ]]; then |
| 300 | gh_repo="${BASH_REMATCH[1]}" |
| 301 | fi |
| 302 | |
| 303 | msg "Building GitHub handle mappings for $module..." |
| 304 | |
| 305 | # Collect all unique emails and their commit context |
| 306 | declare -A email_commits=() |
| 307 | local hash email subject |
| 308 | |
| 309 | while IFS=$'\t' read -r hash email subject; do |
| 310 | [[ -z "$email" ]] && continue |
| 311 | |
| 312 | # Skip if already resolved |
| 313 | [[ -n "${EMAIL_TO_GITHUB[$email]:-}" ]] && continue |
| 314 | |
| 315 | # Try to resolve without API first |
| 316 | local handle="" |
| 317 | |
| 318 | # Method 1: noreply email |
| 319 | if handle="$(extract_handle_from_noreply "$email")"; then |
| 320 | EMAIL_TO_GITHUB["$email"]="$handle" |
| 321 | continue |
| 322 | fi |
| 323 | |
| 324 | # Method 2: merge commit message |
| 325 | if handle="$(extract_handle_from_merge_commit "$subject")"; then |
| 326 | EMAIL_TO_GITHUB["$email"]="$handle" |
| 327 | continue |
| 328 | fi |
| 329 | |
| 330 | # Store for potential API lookup |
| 331 | if [[ -z "${email_commits[$email]:-}" ]]; then |
| 332 | email_commits["$email"]="$hash" |
| 333 | fi |
| 334 | done < <(git -C "$dir" log --format='tformat:%H%x09%aE%x09%s' --no-merges "$start..$end" 2>/dev/null) |
| 335 | |
| 336 | # API batch lookup for remaining emails (if gh is available) |
| 337 | if gh_available && [[ -n "$gh_repo" && ${#email_commits[@]} -gt 0 ]]; then |
| 338 | msg "Querying GitHub API for ${#email_commits[@]} unknown contributors..." |
| 339 | local key |
| 340 | for key in "${!email_commits[@]}"; do |
| 341 | # Skip if already resolved |
| 342 | [[ -n "${EMAIL_TO_GITHUB[$key]:-}" ]] && continue |
| 343 | |
| 344 | local commit_hash="${email_commits[$key]}" |
| 345 | local subj handle |
| 346 | subj="$(git -C "$dir" log -1 --format='%s' "$commit_hash" 2>/dev/null)" || true |
| 347 | |
| 348 | # Try PR author lookup first (cheaper API call) |
| 349 | local pr_num |
| 350 | if pr_num="$(extract_pr_number "$subj")"; then |
| 351 | if handle="$(query_pr_author "$gh_repo" "$pr_num")"; then |
| 352 | EMAIL_TO_GITHUB["$key"]="$handle" |
| 353 | continue |
| 354 | fi |
| 355 | fi |
| 356 | |
| 357 | # Fallback: commit author API (works for any commit) |
| 358 | if handle="$(query_commit_author "$gh_repo" "$commit_hash")"; then |
| 359 | EMAIL_TO_GITHUB["$key"]="$handle" |
| 360 | fi |
| 361 | done |
| 362 | fi |
| 363 | } |
| 364 | |
| 365 | ########################################################################################## |
| 366 | # Original infrastructure with modifications |
| 367 | ########################################################################################## |
| 368 | |
| 369 | build_include_regex() { |
| 370 | local result="" |
| 371 | local mod |
| 372 | for mod in "${INCLUDE_MODULES[@]}"; do |
| 373 | if [[ -n "$result" ]]; then |
| 374 | result="$result|$mod" |
| 375 | else |
| 376 | result="$mod" |
| 377 | fi |
| 378 | done |
| 379 | echo "($result)" |
| 380 | } |
| 381 | |
| 382 | build_exclude_regex() { |
| 383 | local result="" |
| 384 | local mod |
| 385 | for mod in "${EXCLUDE_MODULES[@]}"; do |
| 386 | if [[ -n "$result" ]]; then |
| 387 | result="$result|$mod" |
| 388 | else |
| 389 | result="$mod" |
| 390 | fi |
| 391 | done |
| 392 | if [[ -n "$result" ]]; then |
| 393 | echo "($result)" |
| 394 | else |
| 395 | echo '$^' # match nothing |
| 396 | fi |
| 397 | } |
| 398 | |
| 399 | if [[ ${#INCLUDE_MODULES[@]} -gt 0 ]]; then |
| 400 | INCLUDE_REGEX="$(build_include_regex)" |
| 401 | else |
| 402 | INCLUDE_REGEX="" # "match anything" |
| 403 | fi |
| 404 | |
| 405 | if [[ ${#EXCLUDE_MODULES[@]} -gt 0 ]]; then |
| 406 | EXCLUDE_REGEX="$(build_exclude_regex)" |
| 407 | else |
| 408 | EXCLUDE_REGEX='$^' # "match nothing" |
| 409 | fi |
| 410 | |
| 411 | IGNORE_FILES_PATHSPEC=() |
| 412 | for f in "${IGNORE_FILES[@]}"; do |
| 413 | IGNORE_FILES_PATHSPEC+=(":^:**/$f" ":^:$f") # Prepend the magic "ignore this" sequence. |
| 414 | done |
| 415 | |
| 416 | |
| 417 | NL=$'\n' |
| 418 | |
| 419 | ROOT_DIR="$(git rev-parse --show-toplevel)" |
| 420 | |
| 421 | msg() { |
| 422 | echo "$*" >&2 |
| 423 | } |
| 424 | |
| 425 | statlog() { |
| 426 | local module="$1" |
| 427 | local rpath |
| 428 | local gh_repo="" |
| 429 | |
| 430 | if [[ "$module" == "github.com/ipfs/kubo" ]]; then |
| 431 | rpath="$ROOT_DIR" |
| 432 | else |
| 433 | rpath="$GOPATH/src/$(strip_version "$module")" |
| 434 | fi |
| 435 | |
| 436 | # Extract gh_repo for API calls |
| 437 | local repo |
| 438 | repo="$(strip_version "$module")" |
| 439 | if [[ "$repo" =~ ^github\.com/(.+)$ ]]; then |
| 440 | gh_repo="${BASH_REMATCH[1]}" |
| 441 | fi |
| 442 | |
| 443 | local start="${2:-}" |
| 444 | local end="${3:-HEAD}" |
| 445 | local mailmap_file="$rpath/.mailmap" |
| 446 | if ! [[ -e "$mailmap_file" ]]; then |
| 447 | mailmap_file="$ROOT_DIR/.mailmap" |
| 448 | fi |
| 449 | |
| 450 | local stack=() |
| 451 | local line |
| 452 | while read -r line; do |
| 453 | if [[ -n "$line" ]]; then |
| 454 | stack+=("$line") |
| 455 | continue |
| 456 | fi |
| 457 | |
| 458 | local changes |
| 459 | read -r changes |
| 460 | |
| 461 | local changed=0 |
| 462 | local insertions=0 |
| 463 | local deletions=0 |
| 464 | local count event |
| 465 | while read -r count event; do |
| 466 | if [[ "$event" =~ ^file ]]; then |
| 467 | changed=$count |
| 468 | elif [[ "$event" =~ ^insertion ]]; then |
| 469 | insertions=$count |
| 470 | elif [[ "$event" =~ ^deletion ]]; then |
| 471 | deletions=$count |
| 472 | else |
| 473 | echo "unknown event $event" >&2 |
| 474 | exit 1 |
| 475 | fi |
| 476 | done<<<"${changes//,/$NL}" |
| 477 | |
| 478 | local author |
| 479 | for author in "${stack[@]}"; do |
| 480 | local hash name email |
| 481 | IFS=$'\t' read -r hash name email <<<"$author" |
| 482 | |
| 483 | # Resolve GitHub handle |
| 484 | local github_handle="" |
| 485 | github_handle="$(resolve_github_handle "$email" "$hash" "$rpath" "$gh_repo")" || true |
| 486 | |
| 487 | jq -n \ |
| 488 | --arg "hash" "$hash" \ |
| 489 | --arg "name" "$name" \ |
| 490 | --arg "email" "$email" \ |
| 491 | --arg "github" "$github_handle" \ |
| 492 | --argjson "changed" "$changed" \ |
| 493 | --argjson "insertions" "$insertions" \ |
| 494 | --argjson "deletions" "$deletions" \ |
| 495 | '{Commit: $hash, Author: $name, Email: $email, GitHub: $github, Files: $changed, Insertions: $insertions, Deletions: $deletions}' |
| 496 | done |
| 497 | stack=() |
| 498 | done < <(git -C "$rpath" -c mailmap.file="$mailmap_file" log --use-mailmap --shortstat --no-merges --pretty="tformat:%H%x09%aN%x09%aE" "$start..$end" -- . "${IGNORE_FILES_PATHSPEC[@]}") |
| 499 | } |
| 500 | |
| 501 | # Returns a stream of deps changed between $1 and $2. |
| 502 | dep_changes() { |
| 503 | cat "$1" "$2" | jq -s 'JOIN(INDEX(.[0][]; .Path); .[1][]; .Path; {Path: .[0].Path, Old: (.[1] | del(.Path)), New: (.[0] | del(.Path))}) | select(.New.Version != .Old.Version)' |
| 504 | } |
| 505 | |
| 506 | # resolve_commits resolves a git ref for each version. |
| 507 | resolve_commits() { |
| 508 | jq '. + {Ref: (.Version|capture("^((?<ref1>.*)\\+incompatible|v.*-(0\\.)?[0-9]{14}-(?<ref2>[a-f0-9]{12})|(?<ref3>v.*))$") | .ref1 // .ref2 // .ref3)}' |
| 509 | } |
| 510 | |
| 511 | pr_link() { |
| 512 | local repo="$1" |
| 513 | local prnum="$2" |
| 514 | local ghname="${repo##github.com/}" |
| 515 | printf -- "[%s#%s](https://%s/pull/%s)" "$ghname" "$prnum" "$repo" "$prnum" |
| 516 | } |
| 517 | |
| 518 | ignored_commit() { |
| 519 | local repo="$1" |
| 520 | local commit="$2" |
| 521 | local matches |
| 522 | |
| 523 | # Check to see if this commit includes any non-ignored files. |
| 524 | matches=$(git -C "$repo" diff-tree --no-commit-id --name-only -r "$commit^" "$commit" \ |
| 525 | -- "${IGNORE_FILES_PATHSPEC[@]}" | wc -l) |
| 526 | [[ "$matches" -eq 0 ]] |
| 527 | } |
| 528 | |
| 529 | # Generate a release log for a range of commits in a single repo. |
| 530 | release_log() { |
| 531 | local module="$1" |
| 532 | local start="$2" |
| 533 | local end="${3:-HEAD}" |
| 534 | local repo |
| 535 | repo="$(strip_version "$1")" |
| 536 | local dir |
| 537 | if [[ "$module" == "github.com/ipfs/kubo" ]]; then |
| 538 | dir="$ROOT_DIR" |
| 539 | else |
| 540 | dir="$GOPATH/src/$repo" |
| 541 | fi |
| 542 | |
| 543 | local commit subject |
| 544 | while read -r commit subject; do |
| 545 | # Skip commits that only touch ignored files. |
| 546 | if ignored_commit "$dir" "$commit"; then |
| 547 | continue |
| 548 | fi |
| 549 | |
| 550 | if [[ "$subject" =~ ^Merge\ pull\ request\ \#([0-9]+)\ from ]]; then |
| 551 | local prnum="${BASH_REMATCH[1]}" |
| 552 | local desc |
| 553 | desc="$(git -C "$dir" show --summary --format='tformat:%b' "$commit" | head -1)" |
| 554 | printf -- "- %s (%s)\n" "$desc" "$(pr_link "$repo" "$prnum")" |
| 555 | elif [[ "$subject" =~ \(#([0-9]+)\)$ ]]; then |
| 556 | local prnum="${BASH_REMATCH[1]}" |
| 557 | printf -- "- %s (%s)\n" "$subject" "$(pr_link "$repo" "$prnum")" |
| 558 | else |
| 559 | printf -- "- %s\n" "$subject" |
| 560 | fi |
| 561 | done < <(git -C "$dir" log --format='tformat:%H %s' --first-parent "$start..$end") |
| 562 | } |
| 563 | |
| 564 | indent() { |
| 565 | sed -e 's/^/ /' |
| 566 | } |
| 567 | |
| 568 | mod_deps() { |
| 569 | go list -mod=mod -json -m all | jq 'select(.Version != null)' |
| 570 | } |
| 571 | |
| 572 | ensure() { |
| 573 | local repo |
| 574 | repo="$(strip_version "$1")" |
| 575 | local commit="$2" |
| 576 | local rpath |
| 577 | if [[ "$1" == "github.com/ipfs/kubo" ]]; then |
| 578 | rpath="$ROOT_DIR" |
| 579 | else |
| 580 | rpath="$GOPATH/src/$repo" |
| 581 | fi |
| 582 | if [[ "$1" != "github.com/ipfs/kubo" ]] && [[ ! -d "$rpath" ]]; then |
| 583 | msg "Cloning $repo..." |
| 584 | git clone "http://$repo" "$rpath" >&2 |
| 585 | fi |
| 586 | |
| 587 | if ! git -C "$rpath" rev-parse --verify "$commit" >/dev/null; then |
| 588 | msg "Fetching $repo..." |
| 589 | git -C "$rpath" fetch --all >&2 |
| 590 | fi |
| 591 | |
| 592 | git -C "$rpath" rev-parse --verify "$commit" >/dev/null || return 1 |
| 593 | } |
| 594 | |
| 595 | # Summarize stats, grouping by GitHub handle (with fallback to email for dedup) |
| 596 | statsummary() { |
| 597 | jq -s ' |
| 598 | # Group by GitHub handle if available, otherwise by email |
| 599 | group_by(if .GitHub != "" then .GitHub else .Email end)[] | |
| 600 | { |
| 601 | # Use first non-empty GitHub handle, or fall back to Author name |
| 602 | Author: .[0].Author, |
| 603 | GitHub: (map(select(.GitHub != "")) | .[0].GitHub // ""), |
| 604 | Email: .[0].Email, |
| 605 | Commits: (. | length), |
| 606 | Insertions: (map(.Insertions) | add), |
| 607 | Deletions: (map(.Deletions) | add), |
| 608 | Files: (map(.Files) | add) |
| 609 | } |
| 610 | ' | jq '. + {Lines: (.Deletions + .Insertions)}' |
| 611 | } |
| 612 | |
| 613 | strip_version() { |
| 614 | local repo="$1" |
| 615 | if [[ "$repo" =~ .*/v[0-9]+$ ]]; then |
| 616 | repo="$(dirname "$repo")" |
| 617 | fi |
| 618 | echo "$repo" |
| 619 | } |
| 620 | |
| 621 | recursive_release_log() { |
| 622 | local start="${1:-$(git tag -l | sort -V | grep -v -- '-rc' | grep 'v'| tail -n1)}" |
| 623 | local end="${2:-$(git rev-parse HEAD)}" |
| 624 | local repo_root |
| 625 | repo_root="$(git rev-parse --show-toplevel)" |
| 626 | local module |
| 627 | module="$(go list -m)" |
| 628 | local dir |
| 629 | dir="$(go list -m -f '{{.Dir}}')" |
| 630 | |
| 631 | # Load cached GitHub handle mappings |
| 632 | load_github_cache |
| 633 | |
| 634 | # Kubo can be run from any directory, dependencies still use GOPATH |
| 635 | |
| 636 | ( |
| 637 | local result=0 |
| 638 | local workspace |
| 639 | workspace="$(mktemp -d)" |
| 640 | # shellcheck disable=SC2064 |
| 641 | trap "rm -rf '$workspace'" INT TERM EXIT |
| 642 | cd "$workspace" |
| 643 | |
| 644 | echo "Computing old deps..." >&2 |
| 645 | git -C "$repo_root" show "$start:go.mod" >go.mod |
| 646 | mod_deps | resolve_commits | jq -s > old_deps.json |
| 647 | |
| 648 | echo "Computing new deps..." >&2 |
| 649 | git -C "$repo_root" show "$end:go.mod" >go.mod |
| 650 | mod_deps | resolve_commits | jq -s > new_deps.json |
| 651 | |
| 652 | rm -f go.mod go.sum |
| 653 | |
| 654 | printf -- "Generating Changelog for %s %s..%s\n" "$module" "$start" "$end" >&2 |
| 655 | |
| 656 | # Pre-build GitHub mappings for main module |
| 657 | build_github_mappings "$module" "$start" "$end" |
| 658 | |
| 659 | echo "### 📝 Changelog" |
| 660 | echo |
| 661 | echo "<details><summary>Full Changelog</summary>" |
| 662 | echo |
| 663 | |
| 664 | printf -- "- %s:\n" "$module" |
| 665 | release_log "$module" "$start" "$end" | indent |
| 666 | |
| 667 | statlog "$module" "$start" "$end" > statlog.json |
| 668 | |
| 669 | local dep_module new new_ref old old_ref |
| 670 | while read -r dep_module new new_ref old old_ref; do |
| 671 | if ! ensure "$dep_module" "$new_ref"; then |
| 672 | result=1 |
| 673 | local changelog="failed to fetch repo" |
| 674 | else |
| 675 | # Pre-build GitHub mappings for dependency |
| 676 | build_github_mappings "$dep_module" "$old_ref" "$new_ref" |
| 677 | statlog "$dep_module" "$old_ref" "$new_ref" >> statlog.json |
| 678 | local changelog |
| 679 | changelog="$(release_log "$dep_module" "$old_ref" "$new_ref")" |
| 680 | fi |
| 681 | if [[ -n "$changelog" ]]; then |
| 682 | printf -- "- %s (%s -> %s):\n" "$dep_module" "$old" "$new" |
| 683 | echo "$changelog" | indent |
| 684 | fi |
| 685 | done < <(dep_changes old_deps.json new_deps.json | |
| 686 | jq --arg inc "$INCLUDE_REGEX" --arg exc "$EXCLUDE_REGEX" \ |
| 687 | 'select(.Path | test($inc)) | select(.Path | test($exc) | not)' | |
| 688 | jq -r '"\(.Path) \(.New.Version) \(.New.Ref) \(.Old.Version) \(.Old.Ref // "")"') |
| 689 | |
| 690 | echo |
| 691 | echo "</details>" |
| 692 | echo |
| 693 | echo "### 👨👩👧👦 Contributors" |
| 694 | echo |
| 695 | |
| 696 | echo "| Contributor | Commits | Lines ± | Files Changed |" |
| 697 | echo "|-------------|---------|---------|---------------|" |
| 698 | statsummary <statlog.json | |
| 699 | jq -s 'sort_by(.Lines) | reverse | .[]' | |
| 700 | jq -r ' |
| 701 | if .GitHub != "" then |
| 702 | "| [@\(.GitHub)](https://github.com/\(.GitHub)) | \(.Commits) | +\(.Insertions)/-\(.Deletions) | \(.Files) |" |
| 703 | else |
| 704 | "| \(.Author) | \(.Commits) | +\(.Insertions)/-\(.Deletions) | \(.Files) |" |
| 705 | end |
| 706 | ' |
| 707 | |
| 708 | # Save cache before exiting |
| 709 | save_github_cache |
| 710 | |
| 711 | return "$result" |
| 712 | ) |
| 713 | } |
| 714 | |
| 715 | recursive_release_log "$@" |