#!/bin/zsh # # Invocation: mkreleaselog [FIRST_REF [LAST_REF]] set -euo pipefail export GO111MODULE=on export GOPATH="$(go env GOPATH)" # List of PCRE regular expressions to match "included" modules. INCLUDE_MODULES=( # orgs "^github.com/ipfs/" "^github.com/ipld/" "^github.com/libp2p/" "^github.com/multiformats/" "^github.com/filecoin-project/" "^github.com/ipfs-shipyard/" # Authors of personal modules used by go-ipfs that should be mentioned in the # release notes. "^github.com/whyrusleeping/" "^github.com/Kubuxu/" "^github.com/jbenet/" "^github.com/Stebalien/" "^github.com/marten-seemann/" "^github.com/hsanjuan/" "^github.com/lucas-clemente/" "^github.com/warpfork/" ) # List of PCRE regular expressions to match "excluded" modules. Applied after includes. EXCLUDE_MODULES=( "^github.com/marten-seemann/qtls" ) # Ignored files as git pathspecs. These patters will match any full path component. IGNORE_FILES=( ".gx" "package.json" ".travis.yml" "go.mod" "go.sum" ".github" "*.pb.go" "cbor_gen.go" "ipldsch_*.go" "*.gen.go" ) ########################################################################################## if [[ ${#INCLUDE_MODULES[@]} -gt 0 ]]; then INCLUDE_REGEX="(${$(printf "|%s" "${INCLUDE_MODULES[@]}"):1})" else INCLUDE_REGEX="" # "match anything" fi if [[ ${#EXCLUDE_MODULES[@]} -gt 0 ]]; then EXCLUDE_REGEX="(${$(printf "|%s" "${EXCLUDE_MODULES[@]}"):1})" else EXCLUDE_REGEX='$^' # "match nothing" fi IGNORE_FILES_PATHSPEC=() for f in "${IGNORE_FILES[@]}"; do IGNORE_FILES_PATHSPEC+=(":^:**/$f" ":^:$f") # Prepend the magic "ignore this" sequence. done NL=$'\n' ROOT_DIR="$(git rev-parse --show-toplevel)" alias jq="jq --unbuffered" msg() { echo "$*" >&2 } statlog() { local module="$1" local rpath="$GOPATH/src/$(strip_version "$module")" local start="${2:-}" local end="${3:-HEAD}" local mailmap_file="$rpath/.mailmap" if ! [[ -e "$mailmap_file" ]]; then mailmap_file="$ROOT_DIR/.mailmap" fi local stack=() 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[@]}" | while read -r line; do if [[ -n "$line" ]]; then stack+=("$line") continue fi read -r changes changed=0 insertions=0 deletions=0 while read count event; do if [[ "$event" =~ ^file ]]; then changed=$count elif [[ "$event" =~ ^insertion ]]; then insertions=$count elif [[ "$event" =~ ^deletion ]]; then deletions=$count else echo "unknown event $event" >&2 exit 1 fi done<<<"${changes//,/$NL}" for author in "${stack[@]}"; do IFS=$'\t' read -r hash name email <<<"$author" jq -n \ --arg "hash" "$hash" \ --arg "name" "$name" \ --arg "email" "$email" \ --argjson "changed" "$changed" \ --argjson "insertions" "$insertions" \ --argjson "deletions" "$deletions" \ '{Commit: $hash, Author: $name, Email: $email, Files: $changed, Insertions: $insertions, Deletions: $deletions}' done stack=() done } # Returns a stream of deps changed between $1 and $2. dep_changes() { { <"$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)' } # resolve_commits resolves a git ref for each version. resolve_commits() { jq '. + {Ref: (.Version|capture("^((?.*)\\+incompatible|v.*-(0\\.)?[0-9]{14}-(?[a-f0-9]{12})|(?v.*))$") | .ref1 // .ref2 // .ref3)}' } pr_link() { local repo="$1" local prnum="$2" local ghname="${repo##github.com/}" printf -- "[%s#%s](https://%s/pull/%s)" "$ghname" "$prnum" "$repo" "$prnum" } ignored_commit() { local repo="$1" local commit="$2" local matches # Check to see if this commit includes any non-ignored files. matches=$(git -C "$repo" diff-tree --no-commit-id --name-only -r "$commit^" "$commit" \ -- "${IGNORE_FILES_PATHSPEC[@]}" | wc -l) [[ "$matches" -eq 0 ]] } # Generate a release log for a range of commits in a single repo. release_log() { setopt local_options BASH_REMATCH local module="$1" local start="$2" local end="${3:-HEAD}" local repo="$(strip_version "$1")" local dir="$GOPATH/src/$repo" local commit pr git -C "$dir" log \ --format='tformat:%H %s' \ --first-parent \ "$start..$end" | while read commit subject; do # Skip commits that only touch ignored files. if ignored_commit "$dir" "$commit"; then continue fi if [[ "$subject" =~ '^Merge pull request #([0-9]+) from' ]]; then local prnum="${BASH_REMATCH[2]}" local desc="$(git -C "$dir" show --summary --format='tformat:%b' "$commit" | head -1)" printf -- "- %s (%s)\n" "$desc" "$(pr_link "$repo" "$prnum")" elif [[ "$subject" =~ '\(#([0-9]+)\)$' ]]; then local prnum="${BASH_REMATCH[2]}" printf -- "- %s (%s)\n" "$subject" "$(pr_link "$repo" "$prnum")" else printf -- "- %s\n" "$subject" fi done } indent() { sed -e 's/^/ /' } mod_deps() { go list -mod=mod -json -m all | jq 'select(.Version != null)' } ensure() { local repo="$(strip_version "$1")" local commit="$2" local rpath="$GOPATH/src/$repo" if [[ ! -d "$rpath" ]]; then msg "Cloning $repo..." git clone "http://$repo" "$rpath" >&2 fi if ! git -C "$rpath" rev-parse --verify "$commit" >/dev/null; then msg "Fetching $repo..." git -C "$rpath" fetch --all >&2 fi git -C "$rpath" rev-parse --verify "$commit" >/dev/null || return 1 } statsummary() { jq -s 'group_by(.Author)[] | {Author: .[0].Author, Commits: (. | length), Insertions: (map(.Insertions) | add), Deletions: (map(.Deletions) | add), Files: (map(.Files) | add)}' | jq '. + {Lines: (.Deletions + .Insertions)}' } strip_version() { local repo="$1" if [[ "$repo" =~ '.*/v[0-9]+$' ]]; then repo="$(dirname "$repo")" fi echo "$repo" } recursive_release_log() { local start="${1:-$(git tag -l | sort -V | grep -v -- '-rc' | grep 'v'| tail -n1)}" local end="${2:-$(git rev-parse HEAD)}" local repo_root="$(git rev-parse --show-toplevel)" local module="$(go list -m)" local dir="$(go list -m -f '{{.Dir}}')" if [[ "${GOPATH}/${module}" -ef "${dir}" ]]; then echo "This script requires the target module and all dependencies to live in a GOPATH." return 1 fi ( local result=0 local workspace="$(mktemp -d)" trap "$(printf 'rm -rf "%q"' "$workspace")" INT TERM EXIT cd "$workspace" echo "Computing old deps..." >&2 git -C "$repo_root" show "$start:go.mod" >go.mod mod_deps | resolve_commits | jq -s > old_deps.json echo "Computing new deps..." >&2 git -C "$repo_root" show "$end:go.mod" >go.mod mod_deps | resolve_commits | jq -s > new_deps.json rm -f go.mod go.sum printf -- "Generating Changelog for %s %s..%s\n" "$module" "$start" "$end" >&2 echo "### ๐Ÿ“ Changelog" echo echo "
Full Changelog" echo printf -- "- %s:\n" "$module" release_log "$module" "$start" "$end" | indent statlog "$module" "$start" "$end" > statlog.json dep_changes old_deps.json new_deps.json | jq --arg inc "$INCLUDE_REGEX" --arg exc "$EXCLUDE_REGEX" \ 'select(.Path | test($inc)) | select(.Path | test($exc) | not)' | # Compute changelogs jq -r '"\(.Path) \(.New.Version) \(.New.Ref) \(.Old.Version) \(.Old.Ref // "")"' | while read module new new_ref old old_ref; do if ! ensure "$module" "$new_ref"; then result=1 local changelog="failed to fetch repo" else statlog "$module" "$old_ref" "$new_ref" >> statlog.json local changelog="$(release_log "$module" "$old_ref" "$new_ref")" fi if [[ -n "$changelog" ]]; then printf -- "- %s (%s -> %s):\n" "$module" "$old" "$new" echo "$changelog" | indent fi done echo echo "
" echo echo "### ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Contributors" echo echo "| Contributor | Commits | Lines ยฑ | Files Changed |" echo "|-------------|---------|---------|---------------|" statsummary