mirror of
https://github.com/cline/cline.git
synced 2026-09-14 19:39:22 +08:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66040e0d5c | ||
|
|
3b7b875576 | ||
|
|
9c8259f7f5 | ||
|
|
325a3885a2 | ||
|
|
3d557d7440 | ||
|
|
46d0e72162 | ||
|
|
4a29ea6003 | ||
|
|
7e2dc62611 | ||
|
|
c8bf359332 | ||
|
|
bed46d4224 | ||
|
|
5f2a7bae5f | ||
|
|
bc73cdec42 | ||
|
|
783f796ddf | ||
|
|
00748b60fb | ||
|
|
1d1e0f4b23 | ||
|
|
20d16ebfbd | ||
|
|
8e89e67d66 | ||
|
|
5d69e8640e | ||
|
|
c105f2e890 |
Executable
+328
@@ -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
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user