Compare commits

...
Author SHA1 Message Date
cline-test 66040e0d5c Changes as per automated PR feedback 2026-02-18 10:32:14 -08:00
cline-test 3b7b875576 Fix bug in how sed was used 2026-02-18 10:32:14 -08:00
cline-test 9c8259f7f5 Improve code quality of changelog generator 2026-02-18 10:32:14 -08:00
cline-test 325a3885a2 Add support for start/end tag 2026-02-18 10:32:14 -08:00
cline-test 3d557d7440 Code improvements as per automated code review 2026-02-18 10:32:14 -08:00
cline-test 46d0e72162 Robustness improvements to changelog script 2026-02-18 10:32:14 -08:00
cline-test 4a29ea6003 Fixing changelog generator nuances 2026-02-18 10:32:14 -08:00
cline-test 7e2dc62611 Strip preamble from LLM output 2026-02-18 10:32:14 -08:00
cline-test c8bf359332 Add scope to specify vscode vs cli for autogenerating changelog details 2026-02-18 10:32:14 -08:00
cline-test bed46d4224 Move changelog generator scripts into scripts/release/ directory 2026-02-18 10:32:14 -08:00
cline-test 5f2a7bae5f Create changelog generation wrapper script 2026-02-18 10:32:14 -08:00
cline-test bc73cdec42 Rename script 2026-02-18 10:32:14 -08:00
cline-test 783f796ddf Consolidate script for determining first time contributors to the repo 2026-02-18 10:32:14 -08:00
cline-test 00748b60fb Remove unneeded git-based script in favor of gh-based version. 2026-02-18 10:32:14 -08:00
cline-test 1d1e0f4b23 Use gh instead of git to list PRs since last release tag. 2026-02-18 10:32:14 -08:00
cline-test 20d16ebfbd feat(release): Add script for discovering first-time contributors since last release. 2026-02-18 10:32:14 -08:00
cline-test 8e89e67d66 feat(release): Add prompt for use with PR listing file. 2026-02-18 10:32:14 -08:00
cline-test 5d69e8640e feat(release): Add gh-based release cangelog generating script. 2026-02-18 10:32:14 -08:00
cline-test c105f2e890 feat(release-eng): Add script for listing PRs since last release. 2026-02-18 10:32:14 -08:00
3 changed files with 916 additions and 0 deletions
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env bash
# Generates a formatted changelog for the next release by:
# 1. Gathering merged PRs since the last release via gh-list-prs-since-last-release.sh
# 2. Identifying first-time contributor PRs via gh-first-time-contributors.sh
# 3. Passing both datasets to cline for synthesis into a consistently formatted changelog
#
# Use --scope to target either the VSCode extension (CHANGELOG.md) or the CLI (cli/CHANGELOG.md).
#
# Model: defaults to claude-sonnet-4-6; override with --model.
# Provider: whichever provider is currently selected in your cline configuration will be used.
# To change provider, run `cline auth` first.
#
# Output sections (any empty section is omitted):
# Added / Fixed / Changed / New Contributors
#
# Note: This script uses `git tag --list` to autodetect the latest release tag. In shallow
# clones (e.g., GitHub Actions with fetch-depth: 1), tags may be missing. Run
# `git fetch --tags` before invoking this script in CI environments.
#
# Requires:
# - git (with full tag history — run `git fetch --tags` first if in a shallow clone)
# - gh (authenticated)
# - jq
# - cline (authenticated)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
usage() {
cat <<'USAGE'
Usage: scripts/release/cline-generate-changelog.sh --scope <vscode|cli> [--model <model>] [--from-tag <tag>] [--to-tag <tag>] [--timeout <seconds>] [--debug]
Options:
--scope <vscode|cli> Required. Target product surface:
vscode Generate entries for CHANGELOG.md (VS Code extension)
cli Generate entries for cli/CHANGELOG.md (Cline CLI)
--model <model> Model to pass to cline (default: claude-sonnet-4-6).
--from-tag <tag> Start of the range (default: latest vX.Y.Z tag).
--to-tag <tag> End of the range (default: HEAD of main). Use this to
generate a changelog for a past release window.
--timeout <seconds> Timeout in seconds for the cline task (default: 120).
Must be a positive integer.
--debug Print debug stats to stderr.
Requires: git, gh (authenticated), jq, cline (authenticated)
USAGE
}
SCOPE=""
MODEL="claude-sonnet-4-6"
FROM_TAG_ARG=""
TO_TAG_ARG=""
TIMEOUT=120
DEBUG=0
while [ $# -gt 0 ]; do
case "$1" in
--scope)
[ -z "${2-}" ] && { echo "Error: --scope requires a value." >&2; usage >&2; exit 2; }
SCOPE="$2"
shift 2
;;
--model)
[ -z "${2-}" ] && { echo "Error: --model requires a value." >&2; usage >&2; exit 2; }
MODEL="$2"
shift 2
;;
--from-tag)
[ -z "${2-}" ] && { echo "Error: --from-tag requires a value." >&2; usage >&2; exit 2; }
FROM_TAG_ARG="$2"
shift 2
;;
--to-tag)
[ -z "${2-}" ] && { echo "Error: --to-tag requires a value." >&2; usage >&2; exit 2; }
TO_TAG_ARG="$2"
shift 2
;;
--timeout)
[ -z "${2-}" ] && { echo "Error: --timeout requires a value." >&2; usage >&2; exit 2; }
TIMEOUT="$2"
shift 2
;;
--debug)
DEBUG=1
shift 1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
# Validate --scope
if [ -z "${SCOPE}" ]; then
echo "Error: --scope <vscode|cli> is required." >&2
usage >&2
exit 2
fi
if [ "${SCOPE}" != "vscode" ] && [ "${SCOPE}" != "cli" ]; then
echo "Error: --scope must be 'vscode' or 'cli' (got '${SCOPE}')." >&2
usage >&2
exit 2
fi
# Validate --timeout is a positive integer
if ! printf '%s' "${TIMEOUT}" | grep -Eq '^[1-9][0-9]*$'; then
echo "Error: --timeout must be a positive integer (got '${TIMEOUT}')." >&2
usage >&2
exit 2
fi
for cmd in git gh jq cline; do
if ! command -v "${cmd}" >/dev/null 2>&1; then
echo "Error: ${cmd} is required." >&2
exit 1
fi
done
# Build argument arrays for sub-scripts to avoid word-splitting on values
tag_args=()
[ -n "${FROM_TAG_ARG}" ] && tag_args+=("--from-tag" "${FROM_TAG_ARG}")
# --to-tag is passed as --to-tag to the PR lister (upper bound on the git range)
# and as --base to the first-time contributors script (which uses --base as its
# single-window upper bound; --to-tag there triggers multi-window iteration instead).
to_tag_args_pr=()
to_tag_args_ftc=()
if [ -n "${TO_TAG_ARG}" ]; then
to_tag_args_pr+=("--to-tag" "${TO_TAG_ARG}")
to_tag_args_ftc+=("--base" "${TO_TAG_ARG}")
fi
debug_args=()
[ "${DEBUG}" -eq 1 ] && debug_args+=("--debug")
echo "Gathering PR list..." >&2
if ! PR_LIST=$("${SCRIPT_DIR}/gh-list-prs-since-last-release.sh" \
${tag_args[@]+"${tag_args[@]}"} \
${to_tag_args_pr[@]+"${to_tag_args_pr[@]}"} \
${debug_args[@]+"${debug_args[@]}"}); then
echo "Error: Failed to gather PR list (see above for details)." >&2
exit 1
fi
echo "Gathering first-time contributor PRs..." >&2
if ! FIRST_TIME_PRS=$("${SCRIPT_DIR}/gh-first-time-contributors.sh" \
${tag_args[@]+"${tag_args[@]}"} \
${to_tag_args_ftc[@]+"${to_tag_args_ftc[@]}"} \
${debug_args[@]+"${debug_args[@]}"}); then
echo "Warning: Could not gather first-time contributor data; continuing without it." >&2
FIRST_TIME_PRS=""
fi
if [ -z "${PR_LIST}" ]; then
echo "(No PR references found since last release — nothing to generate.)" >&2
exit 0
fi
# ---------------------------------------------------------------------------
# Scope-specific context inserted into the prompt
# ---------------------------------------------------------------------------
if [ "${SCOPE}" = "vscode" ]; then
SCOPE_CONTEXT="This changelog is for the Cline VS Code extension (CHANGELOG.md), not the CLI.
Focus on changes that affect the extension experience:
- New AI provider integrations or model support
- Webview UI improvements (chat interface, settings panel, history view)
- MCP (Model Context Protocol) server integration and tooling
- Plan/Act mode, context window management, and checkpoints
- Task execution capabilities: file editing, browser automation, terminal, tool use
- Extension commands, settings, and keyboard shortcuts
- Core agent behavior and system prompt improvements
Exclude changes that only affect the CLI (cli/ directory) or are purely internal (CI, build tooling, test infrastructure, dependency bumps) with no user-visible impact."
CHANGELOG_FILE="CHANGELOG.md"
else
SCOPE_CONTEXT="This changelog is for the Cline CLI (cli/CHANGELOG.md), not the VS Code extension.
Focus on changes that affect the CLI experience:
- CLI commands and options (cline task, cline auth, cline config, cline history, etc.)
- Terminal UI components (model picker, settings panel, auth flows)
- ACP (Agent Client Protocol) integration
- Provider authentication and configuration within the CLI
- CLI-specific behavior, flags, and output formatting
Exclude changes that only affect the VS Code extension (webview-ui/, VS Code API integrations, extension-only settings) or are purely internal (CI, build tooling, test infrastructure, dependency bumps) with no user-visible impact. Include changes to shared core code (src/) only if they meaningfully affect CLI behavior."
CHANGELOG_FILE="cli/CHANGELOG.md"
fi
# ---------------------------------------------------------------------------
# Build prompt
# ---------------------------------------------------------------------------
PROMPT="You are acting as a technical writer generating a changelog. All the data you need is already in this prompt — do not use any tools, do not read any files, do not fetch any URLs. Simply read the data below and write the changelog.
You have been provided with two datasets to help generate a changelog for the next release of this repository.
## Merged PRs since last release
${PR_LIST}
## First-time contributor PRs in this release
${FIRST_TIME_PRS:-"(none)"}
## Your task
${SCOPE_CONTEXT}
Generate a concise, human-readable changelog suitable for inclusion in ${CHANGELOG_FILE}.
Format requirements:
- Use exactly four sections in this order: Added, Fixed, Changed, New Contributors.
- Each section header must be exactly: ## Added, ## Fixed, ## Changed, ## New Contributors (two hash characters, a space, then the section name — no other formatting). Do NOT use bold (**Added**), underline, or any other formatting for section headers.
- Each of Added, Fixed, and Changed contains plain-language bullet points. Write from the perspective of a user of the product surface described above — what did they gain, what got fixed, what changed for them.
- If two or more entries naturally combine into a single more general statement, merge them into one bullet point.
- The New Contributors section lists each first-time contributor as: - @<login> made their first contribution in #<number> (<url>)
- Omit any section that has no entries.
- Output only the changelog — no preamble, no explanation, no markdown code fences, no closing summary. Your very first line of output must be a section header (e.g. ## Added). Do not write any text before it."
# ---------------------------------------------------------------------------
# Invoke cline
#
# We use `-- "${PROMPT}"` to prevent the prompt being interpreted as a flag
# in the unlikely event it begins with a hyphen.
#
# stderr is captured to a temp file so we can surface it on failure with
# a clear error message, rather than letting it disappear or conflate with
# the structured changelog output.
# ---------------------------------------------------------------------------
CLINE_STDERR_FILE=$(mktemp)
trap 'rm -f "${CLINE_STDERR_FILE}"' EXIT
echo "Generating ${SCOPE} changelog with cline (model: ${MODEL}, timeout: ${TIMEOUT}s)..." >&2
echo "" >&2
CLINE_EXIT=0
RAW_OUTPUT=$(cline -a -y --timeout "${TIMEOUT}" -m "${MODEL}" -- "${PROMPT}" \
2>"${CLINE_STDERR_FILE}") || CLINE_EXIT=$?
if [ "${CLINE_EXIT}" -ne 0 ]; then
echo "Error: cline exited with code ${CLINE_EXIT}." >&2
if [ -s "${CLINE_STDERR_FILE}" ]; then
echo "--- cline stderr ---" >&2
cat "${CLINE_STDERR_FILE}" >&2
echo "--------------------" >&2
fi
echo "Possible causes: task timeout, auth expiry, or model unavailable." >&2
echo "Run 'cline auth' to verify authentication and try again." >&2
exit 1
fi
# Strip any markdown code fences a model may have wrapped the output in,
# normalize common bold-header variants to the canonical "## Section" form,
# then extract from the first *known* changelog section header to end.
#
# We anchor on the explicit section names (Added / Fixed / Changed / New Contributors)
# rather than any "## " line to avoid incorrectly anchoring on preamble headings
# the model may emit before the actual changelog content.
#
# The awk pass handles three things:
# 1. CRLF stripping (tr -d '\r' first — cline may output Windows line endings)
# 2. Code fence removal
# 3. Bold header normalization: **Added:** → ## Added
# Uses substr/index (string ops) instead of regex because \* is not a
# reliable literal-asterisk escape in macOS BSD sed or awk ERE.
#
# Use -E (ERE) so that | alternation works on both macOS BSD sed and GNU sed.
# \| alternation is a GNU-only BRE extension and silently matches nothing on macOS.
NORMALIZED=$(printf '%s\n' "${RAW_OUTPUT}" \
| tr -d '\r' \
| awk '
/^[`][`][`]/ { next }
{
if (substr($0,1,2) == "**") {
rest = substr($0, 3)
n = index(rest, "**")
if (n > 0) {
section = substr(rest, 1, n-1)
gsub(/:$/, "", section)
gsub(/[[:space:]]+$/, "", section)
if (section == "Added" || section == "Fixed" ||
section == "Changed" || section == "New Contributors") {
print "## " section; next
}
}
}
print
}')
if [ "${DEBUG}" -eq 1 ] && [ -n "${RAW_OUTPUT}" ]; then
echo "[debug] Normalized output (after CRLF strip + bold-header normalization):" >&2
printf '%s\n' "${NORMALIZED}" >&2
echo "" >&2
fi
CHANGELOG=$(printf '%s\n' "${NORMALIZED}" \
| sed -En '/^## (Added|Fixed|Changed|New Contributors)/,$p')
if [ -z "${CHANGELOG}" ]; then
if [ -z "${RAW_OUTPUT}" ]; then
echo "(No output from cline — the task may have timed out or the model may be unavailable.)" >&2
else
echo "(No ${SCOPE}-relevant changes found in this release.)" >&2
if [ "${DEBUG}" -eq 1 ]; then
echo "" >&2
echo "[debug] Raw cline output (no known section headers found):" >&2
printf '%s\n' "${RAW_OUTPUT}" >&2
else
echo "[hint] Run with --debug to see the raw cline output." >&2
fi
fi
else
printf '%s\n' "${CHANGELOG}"
fi
+384
View File
@@ -0,0 +1,384 @@
#!/usr/bin/env bash
# Lists first-time contributor PRs, optionally across multiple release windows.
#
# A "first-time contributor" is defined as an author whose earliest merged PR
# in this repo falls within the window being examined.
#
# Single-window mode (--to-tag omitted):
# Lists PRs merged since --from-tag (default: latest vX.Y.Z) through --base.
# Output: - #<number> <title> (@<author>) (<url>)
#
# Multi-window mode (--to-tag specified):
# Iterates all release windows from --from-tag up to --to-tag, printing a
# ## <tag> section header for each window followed by its first-time contributor PRs.
#
# Note: This script uses `git tag --list` to autodetect the latest release tag. In shallow
# clones (e.g., GitHub Actions with fetch-depth: 1), tags may be missing. Run
# `git fetch --tags` before invoking this script in CI environments.
#
# Requires:
# - git (with full tag history — run `git fetch --tags` first if in a shallow clone)
# - gh (authenticated)
# - jq
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: scripts/gh-first-time-contributors.sh [--from-tag <tag>] [--to-tag <tag>] [--base <ref>] [--debug]
Options:
--from-tag <tag> The start tag (default: latest vX.Y.Z tag). In single-window
mode, PRs since this tag through --base are listed. In
multi-window mode, this is the oldest boundary (exclusive).
--to-tag <tag> The end tag (optional). When specified, all release windows
from --from-tag up to and including --to-tag are iterated,
with a ## <tag> section header per window.
--base <ref> Base branch/ref used when --to-tag is omitted (default: main).
--debug Print debug stats to stderr.
Requires: git, gh (authenticated), jq
USAGE
}
BASE_REF="main"
FROM_TAG=""
TO_TAG=""
DEBUG=0
# Maximum number of PRs per GraphQL batch request. GitHub's GraphQL endpoint
# has an undocumented ~40 KB body limit; 100 PR aliases comfortably fits.
PR_CHUNK_SIZE=100
# Maximum number of authors per GraphQL batch request. Author search queries
# are larger per item (~200 chars each), so a smaller chunk is appropriate.
AUTHOR_CHUNK_SIZE=50
while [ $# -gt 0 ]; do
case "$1" in
--from-tag)
[ -z "${2-}" ] && { echo "Error: --from-tag requires a value." >&2; usage >&2; exit 2; }
FROM_TAG="$2"
shift 2
;;
--to-tag)
[ -z "${2-}" ] && { echo "Error: --to-tag requires a value." >&2; usage >&2; exit 2; }
TO_TAG="$2"
shift 2
;;
--base)
[ -z "${2-}" ] && { echo "Error: --base requires a value." >&2; usage >&2; exit 2; }
BASE_REF="$2"
shift 2
;;
--debug)
DEBUG=1
shift 1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if ! command -v git >/dev/null 2>&1; then
echo "Error: git is required." >&2
exit 1
fi
if ! command -v gh >/dev/null 2>&1; then
echo "Error: gh is required." >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq is required." >&2
exit 1
fi
# Auto-detect from-tag if not specified
if [ -z "${FROM_TAG}" ]; then
FROM_TAG=$(git tag --list 'v[0-9]*' --sort=-version:refname | head -1)
fi
if [ -z "${FROM_TAG}" ]; then
echo "Error: No version tags found matching v* pattern." >&2
echo "Hint: If running in a shallow clone, run 'git fetch --tags' first." >&2
exit 1
fi
# Validate FROM_TAG
if ! git rev-parse --verify --quiet "${FROM_TAG}^{}" >/dev/null 2>&1 && \
! git rev-parse --verify --quiet "${FROM_TAG}" >/dev/null 2>&1; then
echo "Error: '${FROM_TAG}' is not a valid tag or revision in this repository." >&2
exit 1
fi
# Validate TO_TAG if specified
if [ -n "${TO_TAG}" ]; then
if ! git rev-parse --verify --quiet "${TO_TAG}^{}" >/dev/null 2>&1 && \
! git rev-parse --verify --quiet "${TO_TAG}" >/dev/null 2>&1; then
echo "Error: '${TO_TAG}' is not a valid tag or revision in this repository." >&2
exit 1
fi
fi
# Get repo owner and name from remote
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
OWNER=${REPO%/*}
NAME=${REPO#*/}
if [ "${DEBUG}" -eq 1 ]; then
echo "[debug] Using repo ${OWNER}/${NAME}" >&2
fi
# ---------------------------------------------------------------------------
# split_into_chunks <newline_separated_items> <chunk_size>
# Prints each chunk as a single space-separated line.
# ---------------------------------------------------------------------------
split_into_chunks() {
local items="$1"
local chunk_size="$2"
printf "%s\n" "${items}" | awk -v n="${chunk_size}" '
NF == 0 { next }
{
buf = (buf == "") ? $0 : buf " " $0
count++
if (count == n) { print buf; buf = ""; count = 0 }
}
END { if (buf != "") print buf }
'
}
# ---------------------------------------------------------------------------
# graphql_pr_batch <space_separated_pr_numbers>
# Fetches PR metadata (number, title, url, mergedAt, author.login) for a
# batch of PR numbers. Prints the raw JSON response. Returns 1 on total failure.
# ---------------------------------------------------------------------------
graphql_pr_batch() {
local pr_numbers="$1"
local query_body
query_body=$(printf "%s\n" ${pr_numbers} |
awk '{printf "pr%s: pullRequest(number: %s) { number title url mergedAt author { login } } ", $1, $1}')
# Capture stdout+stderr together to distinguish total failure (no .data) from the
# expected partial-error case where GitHub returns exit code 1 alongside a valid
# .data payload (some commit subjects reference issue numbers, not PRs).
local response
response=$(gh api graphql \
-f query="query { repository(owner: \"${OWNER}\", name: \"${NAME}\") { ${query_body} } }" 2>&1 || true)
if ! printf '%s' "${response}" | jq -e '.data.repository' >/dev/null 2>&1; then
printf '%s' "${response}" >&2
return 1
fi
printf '%s' "${response}"
}
# ---------------------------------------------------------------------------
# graphql_author_batch <newline_separated_logins>
# Fetches the earliest merged PR for each author login. Prints raw JSON.
# Returns 1 on total failure.
#
# Note on alias sanitization: GraphQL field names cannot contain hyphens or
# other special characters, so logins are sanitized (non-alnum chars → "_")
# and prefixed with "a" plus a unique sequence number. The alias is used only
# to satisfy GraphQL syntax — author logins are extracted directly from each
# node's .author.login field, so alias collisions (e.g. "foo-bar" and "foo_bar"
# both mapping to "afoo_bar") cannot corrupt results.
# ---------------------------------------------------------------------------
graphql_author_batch() {
local logins="$1"
local query_body
# ${logins} is intentionally unquoted here so that word-splitting expands the
# space-separated chunk into individual arguments, causing printf to print each
# login on its own line for awk to process one-per-line.
# shellcheck disable=SC2086
query_body=$(printf "%s\n" ${logins} |
awk -v owner="${OWNER}" -v name="${NAME}" '{
alias=$0
gsub(/[^A-Za-z0-9_]/, "_", alias)
printf "a%s_%d: search(query: \"repo:%s/%s is:pr is:merged author:%s sort:created-asc\", type: ISSUE, first: 1) { nodes { ... on PullRequest { number url title mergedAt author { login } } } } ", alias, NR, owner, name, $0
}')
local response
response=$(gh api graphql -f query="query { ${query_body} }" 2>/dev/null || true)
if [ -z "${response}" ]; then
return 1
fi
printf '%s' "${response}"
}
# ---------------------------------------------------------------------------
# process_window <older_ref> <newer_ref>
# Prints first-time contributor PRs for the window (older..newer].
# ---------------------------------------------------------------------------
process_window() {
local older="$1"
local newer="$2"
# Collect PR numbers from merge commit subjects on the first-parent path.
# NOTE: --first-parent is correct for a merge-based main branch workflow. If the repo
# ever switches to squash-merge or rebase, PR numbers will stop appearing in commit
# subjects and this function will silently produce empty output.
local prs
prs=$(git log --first-parent --pretty=%s "${older}..${newer}" |
grep -Eo '#[0-9]+' |
tr -d '#' |
sort -un || true)
if [ -z "${prs}" ]; then
echo "- (no PR references found in merge commits)"
return 0
fi
local pr_count
pr_count=$(printf "%s\n" "${prs}" | wc -l | tr -d ' ')
if [ "${DEBUG}" -eq 1 ]; then
echo "[debug] ${newer}: PR refs from git: ${pr_count}" >&2
fi
# ---------------------------------------------------------------------------
# Fetch PR metadata in batches of PR_CHUNK_SIZE to stay well under GitHub's
# GraphQL query size limit (~40 KB per request).
# ---------------------------------------------------------------------------
local all_pr_objects="[]"
local chunk
while IFS= read -r chunk; do
[ -z "${chunk}" ] && continue
local batch_response
if ! batch_response=$(graphql_pr_batch "${chunk}"); then
echo "- (error fetching PR metadata from GitHub)" >&2
echo "- (error fetching PR metadata from GitHub)"
return 0
fi
local batch_objects
batch_objects=$(printf '%s' "${batch_response}" | jq -c \
'[.data.repository | to_entries | map(.value) | map(select(. != null))[]]')
all_pr_objects=$(jq -cn --argjson a "${all_pr_objects}" --argjson b "${batch_objects}" '$a + $b')
done < <(split_into_chunks "${prs}" "${PR_CHUNK_SIZE}")
if [ "${DEBUG}" -eq 1 ]; then
echo "[debug] ${newer}: PR objects fetched: $(printf '%s' "${all_pr_objects}" | jq 'length')" >&2
fi
# Filter out PRs with null authors (deleted GitHub accounts) before extracting logins
local authors
authors=$(printf '%s' "${all_pr_objects}" | jq -r \
'[.[].author | select(. != null) | .login] | unique | .[]')
if [ -z "${authors}" ]; then
echo "- (no PR authors found)"
return 0
fi
if [ "${DEBUG}" -eq 1 ]; then
echo "[debug] ${newer}: Unique authors: $(printf "%s\n" "${authors}" | wc -l | tr -d ' ')" >&2
fi
# ---------------------------------------------------------------------------
# For each author, fetch their earliest merged PR in this repo via the Search API.
# Batch in groups of AUTHOR_CHUNK_SIZE to stay within GraphQL size limits.
# ---------------------------------------------------------------------------
local earliest_by_author="{}"
local author_chunk
while IFS= read -r author_chunk; do
[ -z "${author_chunk}" ] && continue
local author_response
if ! author_response=$(graphql_author_batch "${author_chunk}"); then
echo "- (error fetching author history from GitHub)" >&2
echo "- (error fetching author history from GitHub)"
return 0
fi
# Merge batch results into the cumulative earliest_by_author map.
# Extract login directly from each node — no alias-to-login mapping needed.
local batch_earliest
batch_earliest=$(printf '%s' "${author_response}" | jq -c '
(.data // {})
| to_entries
| map(select(.value != null))
| map(.value.nodes[0] // null)
| map(select(. != null and .author != null))
| map({key: .author.login, value: .number})
| from_entries
')
earliest_by_author=$(jq -cn \
--argjson base "${earliest_by_author}" \
--argjson patch "${batch_earliest}" \
'$base + $patch')
done < <(split_into_chunks "${authors}" "${AUTHOR_CHUNK_SIZE}")
if [ "${DEBUG}" -eq 1 ]; then
echo "[debug] ${newer}: Earliest-by-author keys: $(printf '%s' "${earliest_by_author}" | jq 'keys | length')" >&2
fi
# Filter PRs to those that equal the earliest merged PR per author
local filtered
filtered=$(printf '%s' "${all_pr_objects}" | jq -c \
--argjson earliest "${earliest_by_author}" '
.
| map(select(.author != null and .author.login as $a | ($earliest[$a] // -1) == .number))
| sort_by(.number)
')
local count
count=$(printf '%s' "${filtered}" | jq 'length')
if [ "${count}" -eq 0 ]; then
echo "- (no first-time contributor PRs found)"
return 0
fi
printf '%s' "${filtered}" | jq -r '
.[]
| "- #\(.number) \(.title | gsub("[\\r\\n]+"; " ") | gsub("\\s+"; " ") | ltrimstr(" ") | rtrimstr(" ")) (@\(.author.login)) (\(.url))"
'
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
if [ -z "${TO_TAG}" ]; then
# Single-window mode: FROM_TAG..BASE_REF
echo "Finding first-time contributor PRs since ${FROM_TAG} (through ${BASE_REF})..." >&2
process_window "${FROM_TAG}" "${BASE_REF}"
else
# Multi-window mode: iterate all release windows from FROM_TAG up to TO_TAG.
# Collect all vX.Y.Z tags strictly after FROM_TAG and up to and including TO_TAG,
# in ascending semver order.
WINDOW_TAGS=$(git tag --list 'v[0-9]*' --sort=version:refname |
awk -v from="${FROM_TAG}" -v to="${TO_TAG}" '
BEGIN { found_from = 0; done = 0 }
{
if (done) next
if ($0 == from) { found_from = 1; next }
if (found_from) { print; if ($0 == to) done = 1 }
}
')
if [ -z "${WINDOW_TAGS}" ]; then
echo "Error: No tags found after ${FROM_TAG} up to ${TO_TAG}." >&2
exit 1
fi
echo "Finding first-time contributor PRs from ${FROM_TAG} to ${TO_TAG}..." >&2
prev="${FROM_TAG}"
while IFS= read -r tag; do
echo ""
echo "## ${tag}"
process_window "${prev}" "${tag}"
prev="${tag}"
done <<<"${WINDOW_TAGS}"
fi
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env bash
# Lists PRs merged into main since the latest release tag.
# Auto-detects the latest vX.Y.Z tag, or accepts an explicit --from-tag override.
#
# Requires:
# - git (with full tag history — run `git fetch --tags` first if in a shallow clone)
# - gh (authenticated)
# - jq
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: scripts/gh-list-prs-since-last-release.sh [--from-tag <tag>] [--to-tag <tag>] [--base <ref>] [--debug]
Options:
--from-tag <tag> Override the autodetected latest vX.Y.Z tag.
--to-tag <tag> Upper bound of the range (overrides --base when specified).
--base <ref> Base branch to compare against (default: main). Ignored when --to-tag is set.
--debug Print debug stats to stderr.
Note: This script uses `git tag --list` to autodetect the latest release tag. In shallow
clones (e.g., GitHub Actions with fetch-depth: 1), tags may be missing. Run
`git fetch --tags` before invoking this script in CI environments.
Requires: git, gh (authenticated), jq
USAGE
}
BASE_REF="main"
FROM_TAG=""
TO_TAG=""
DEBUG=0
while [ $# -gt 0 ]; do
case "$1" in
--from-tag)
[ -z "${2-}" ] && { echo "Error: --from-tag requires a value." >&2; usage >&2; exit 2; }
FROM_TAG="$2"
shift 2
;;
--to-tag)
[ -z "${2-}" ] && { echo "Error: --to-tag requires a value." >&2; usage >&2; exit 2; }
TO_TAG="$2"
shift 2
;;
--base)
[ -z "${2-}" ] && { echo "Error: --base requires a value." >&2; usage >&2; exit 2; }
BASE_REF="$2"
shift 2
;;
--debug)
DEBUG=1
shift 1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
# Validate required dependencies
for cmd in git gh jq; do
if ! command -v "${cmd}" >/dev/null 2>&1; then
echo "Error: ${cmd} is required." >&2
exit 1
fi
done
# Auto-detect tag if not specified
if [ -z "${FROM_TAG}" ]; then
FROM_TAG=$(git tag --list 'v[0-9]*' --sort=-version:refname | head -1)
fi
if [ -z "${FROM_TAG}" ]; then
echo "Error: No version tags found matching v* pattern." >&2
echo "Hint: If running in a shallow clone, run 'git fetch --tags' first." >&2
exit 1
fi
# Validate the tag/ref exists before using it
if ! git rev-parse --verify --quiet "${FROM_TAG}^{}" >/dev/null 2>&1 && \
! git rev-parse --verify --quiet "${FROM_TAG}" >/dev/null 2>&1; then
echo "Error: '${FROM_TAG}' is not a valid tag or revision in this repository." >&2
exit 1
fi
# If --to-tag was given, validate it and use it as the upper bound; otherwise use BASE_REF.
UPPER_BOUND="${BASE_REF}"
if [ -n "${TO_TAG}" ]; then
if ! git rev-parse --verify --quiet "${TO_TAG}^{}" >/dev/null 2>&1 && \
! git rev-parse --verify --quiet "${TO_TAG}" >/dev/null 2>&1; then
echo "Error: '${TO_TAG}' is not a valid tag or revision in this repository." >&2
exit 1
fi
UPPER_BOUND="${TO_TAG}"
fi
echo "Generating changelog from ${FROM_TAG} to ${UPPER_BOUND}..." >&2
# Get repo owner and name from remote
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
OWNER=${REPO%/*}
NAME=${REPO#*/}
# Collect PR numbers from merge commit subjects on the first-parent path.
# NOTE: --first-parent is correct for a merge-based main branch workflow. If the repo
# ever switches to squash-merge or rebase, PR numbers will stop appearing in commit
# subjects and this script will silently produce empty output.
ALL_PR_NUMBERS=$(git log --first-parent --pretty=%s "${FROM_TAG}..${UPPER_BOUND}" |
grep -Eo '#[0-9]+' |
tr -d '#' |
sort -un || true)
if [ -z "${ALL_PR_NUMBERS}" ]; then
echo "No PR references found in merge commits since ${FROM_TAG}." >&2
exit 0
fi
TOTAL_PRS=$(printf "%s\n" "${ALL_PR_NUMBERS}" | wc -l | tr -d ' ')
if [ "${DEBUG}" -eq 1 ]; then
echo "[debug] Total PR refs from git log: ${TOTAL_PRS}" >&2
fi
# ---------------------------------------------------------------------------
# split_into_chunks <newline_separated_items> <chunk_size>
# Prints each chunk as a single space-separated line, one chunk per output line.
# ---------------------------------------------------------------------------
split_into_chunks() {
local items="$1"
local chunk_size="$2"
printf "%s\n" "${items}" | awk -v n="${chunk_size}" '
NF == 0 { next }
{
buf = (buf == "") ? $0 : buf " " $0
count++
if (count == n) { print buf; buf = ""; count = 0 }
}
END { if (buf != "") print buf }
'
}
# ---------------------------------------------------------------------------
# Batch GraphQL requests in chunks of 100 to stay well under GitHub's query
# size limit (~40 KB). For large releases with hundreds of PRs, a single
# monolithic query would exceed the limit and fail silently.
#
# split_into_chunks outputs one space-separated line per chunk so that
# `while IFS= read -r chunk` iterates once per batch, not once per PR.
# ---------------------------------------------------------------------------
CHUNK_SIZE=100
COMBINED_OUTPUT=""
NULL_COUNT=0
while IFS= read -r chunk; do
[ -z "${chunk}" ] && continue
# Capture stdout+stderr together so we can distinguish a total failure (no .data)
# from the expected partial-error case where GitHub returns exit code 1 alongside
# a valid .data payload because some merge-commit subjects reference issue numbers
# rather than PRs. The jq select(.value != null) below drops those null entries.
QUERY_BODY=$(printf "%s\n" ${chunk} |
awk '{printf "pr%s: pullRequest(number: %s) { number title url } ", $1, $1}')
GH_OUTPUT=$(gh api graphql \
-f query="query { repository(owner: \"${OWNER}\", name: \"${NAME}\") { ${QUERY_BODY} }}" 2>&1 || true)
if ! printf '%s' "${GH_OUTPUT}" | jq -e '.data.repository' >/dev/null 2>&1; then
echo "Error: GitHub GraphQL request failed:" >&2
printf '%s\n' "${GH_OUTPUT}" | head -10 >&2
exit 1
fi
if [ "${DEBUG}" -eq 1 ]; then
CHUNK_NULL_COUNT=$(printf '%s' "${GH_OUTPUT}" | jq '[.data.repository | to_entries[] | select(.value == null)] | length')
NULL_COUNT=$((NULL_COUNT + CHUNK_NULL_COUNT))
fi
# Accumulate results across chunks as newline-separated PR lines
CHUNK_LINES=$(printf '%s' "${GH_OUTPUT}" |
jq -r '.data.repository | to_entries | sort_by(.value.number // 999999) | .[] | select(.value != null) | "- #\(.value.number) \(.value.title | gsub("[\\r\\n]+"; " ") | gsub("\\s+"; " ") | ltrimstr(" ") | rtrimstr(" ")) (\(.value.url))"' |
grep -v '^$' || true)
if [ -n "${CHUNK_LINES}" ]; then
if [ -n "${COMBINED_OUTPUT}" ]; then
COMBINED_OUTPUT="${COMBINED_OUTPUT}"$'\n'"${CHUNK_LINES}"
else
COMBINED_OUTPUT="${CHUNK_LINES}"
fi
fi
done < <(split_into_chunks "${ALL_PR_NUMBERS}" "${CHUNK_SIZE}")
if [ "${DEBUG}" -eq 1 ] && [ "${NULL_COUNT}" -gt 0 ]; then
echo "[debug] Dropped ${NULL_COUNT} non-PR ref(s) (issues or deleted PRs) across all chunks" >&2
fi
printf '%s\n' "${COMBINED_OUTPUT}" | grep -v '^$' || true