master
text 715 lines 21.3 KB
Raw
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 "$@"