build(changelog): harden and improve the AI changelog generator (#338)#340
Conversation
Rework generate_changelog.py (run during ./gradlew release) so it can never abort a release and produces output close to the hand-written CHANGELOG voice. Reliability - Wrap the OpenAI call and the git-collection phase so any failure degrades to a deterministic offline generator (or skips) instead of aborting beforeReleaseBuild. - Fail fast (exit 2) when no version is passed on a non-interactive shell instead of hanging on input(). - Make model/temperature configurable (OPENAI_MODEL, OPENAI_TEMPERATURE) and retry gracefully when a model rejects a custom temperature; fix the stale model comment. CHANGELOG handling - Insert the new section below the "# Changelog" title (Keep a Changelog style), dedupe an existing same-version section, and consume/reset an [Unreleased] section. This resolves the release-time duplication cleaned up manually today. Prompt quality - Lead with a Security category (CWE/SUF-xx), add Behavior-changes and SemVer sections, give repo context (library, user.* config, numeric response code), constrain to exact names, and use the latest CHANGELOG section as a few-shot exemplar. Feed commit bodies and referenced PR descriptions, not just subjects. Cost / security - Enforce an overall prompt-size budget with graceful degradation. - Document the diff-to-OpenAI data-sharing; add CHANGELOG_NO_DIFFS and CHANGELOG_SKIP_AI opt-outs. Categorization / cleanup - Drop the near-useless whole-diff substring heuristic; categorize leftover commits by file paths; filter release-plumbing commits; consolidate two git show calls. - Add requirements.txt (pinned openai) and a stdlib unittest suite; add --dry-run. Closes #338
There was a problem hiding this comment.
Pull request overview
This PR substantially reworks the release-time generate_changelog.py script to be best-effort (never aborting ./gradlew release) while producing changelog output closer to the project’s desired voice and structure.
Changes:
- Hardened changelog generation with offline fallback, prompt budgeting, improved categorization, and safer git/AI/PR-body collection behavior.
- Improved
CHANGELOG.mdupdating: insert below header area, consume/reset## [Unreleased], and de-duplicate same-version entries. - Added pinned Python dependency (
openai) plus a stdlibunittestsuite covering the new pure-function behavior.
Reviewed changes
Copilot reviewed 2 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| generate_changelog.py | Major reliability, prompt/categorization, budget controls, and Keep-a-Changelog write-path improvements for release-time changelog generation. |
| test_generate_changelog.py | New unittest suite validating categorization, changelog splitting/composition, budget degradation, and non-fatal behavior. |
| requirements.txt | Pins openai for reproducible release tooling installs. |
| .gitignore | Ignores common Python artifacts for the new release tooling/tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def get_last_reference(): | ||
| """Return the last tag, or the first commit if there are no tags.""" | ||
| try: | ||
| last_tag = subprocess.check_output( | ||
| ["git", "describe", "--tags", "--abbrev=0"], text=True | ||
| ).strip() | ||
| return _git(["git", "describe", "--tags", "--abbrev=0"]).strip() | ||
| except subprocess.CalledProcessError: | ||
| # If no tags exist, use the first commit | ||
| last_tag = subprocess.check_output( | ||
| ["git", "rev-list", "--max-parents=0", "HEAD"], text=True | ||
| ).strip() | ||
| print("No tags found. Using the first commit as reference.") | ||
| return _git(["git", "rev-list", "--max-parents=0", "HEAD"]).strip() |
| cache = {} | ||
| for commit in commits: | ||
| for number in commit["pr_numbers"]: | ||
| if number not in cache: | ||
| cache[number] = _fetch_single_pr(number) | ||
| if cache[number]: | ||
| commit["pr_bodies"].append(cache[number]) |
| def test_compose_dedupes_same_version(self): | ||
| content = self.SAMPLE + "## [5.1.0] - 2026-07-01\n### Features\n- hand draft\n\n" | ||
| # Reorder so 5.1.0 exists; split then compose a fresh 5.1.0. |
Review: build(changelog): harden and improve the AI changelog generator (#338)This is a well-structured rewrite of Correctness — reliability gap vs. the stated "never abort" goalThe PR description says: "AI generation and the git-collection phase are wrapped so any failure … degrades to a deterministic offline generator or skips — it never aborts Looking at ```python
```python That would crash the script with a non-zero exit and traceback — exactly the release-aborting failure mode this PR is meant to eliminate. Suggestion: widen the Test coverageThe 24 unit tests thoroughly cover the pure functions (categorization, split/compose, budget degradation, dedup, etc.), but there's no test exercising the "AI path throws → falls back to deterministic generator" behavior (e.g. mocking Security consideration (minor, already partly addressed)The module docstring is upfront about diffs being sent to OpenAI, and Minor / nit
SummaryNice improvement overall — much more robust categorization, sensible prompt-budget degradation, and good test coverage of the deterministic logic. The main thing I'd fix before merging is closing the gap between the "AI failures never abort the release" claim and what's actually wrapped in |
- Include the root commit on the no-tags path: get_last_reference() returns None and get_commits() uses a HEAD range so an initial release is not omitted. - Widen the AI-path try/except to cover fetch_pr_bodies/build_context/read_exemplar, and guard against non-dict gh JSON, so no AI-path failure can abort the release. - Cap PR-description fetches (CHANGELOG_MAX_PR_FETCHES, default 50) so a large release range cannot stall on repeated gh calls. - Narrow RELEASE_PLUMBING_RE so a bare "-SNAPSHOT" mention no longer drops a legitimate commit; the Gradle Release Plugin's own messages still match. - Document the intentional merge-commit asymmetry (dropped from the offline list, kept in the AI context for PR-body context). - Tests: add AI-failure-falls-back, no-tags full-history range, PR-fetch cap, and legit-snapshot-not-dropped; remove an unused local.
|
Thanks @copilot and Claude for the review — addressed in 3b37b20. Fixed
Intentionally not changed (with rationale)
Test suite is now 28 passing ( |
|
Review This is a well-scoped rewrite of Strengths
Potential issues
Test coverage Minor / non-blocking
Nothing here is blocking; the reliability guarantees and dedup logic (the core goals of this PR) are correctly implemented and well tested. The two categorization/env-parsing points are low-severity edge cases worth a follow-up rather than a required fix before merge. |
Summary
Reworks
generate_changelog.py(invoked during./gradlew releasevia thegenerateAIChangelogtask wired intobeforeReleaseBuild) so it can never abort a release and produces output much closer to our hand-written CHANGELOG voice.Closes #338.
What changed
Reliability
OPENAI_API_TOKEN, network/API error, git error, decode error) degrades to a deterministic offline generator or skips — it never abortsbeforeReleaseBuild.input().OPENAI_MODEL,OPENAI_TEMPERATURE); retries gracefully if a model rejects a custom temperature. Stale "GPT-4o" comment removed.CHANGELOG.md handling
# Changelogtitle (Keep a Changelog style), an existing same-version section is de-duplicated, and an## [Unreleased]section is consumed as an authoritative draft and reset. This resolves the release-time duplication we clean up by hand today.Prompt quality
### Securitycategory (CWE / SUF-xx IDs), adds### Behavior changes (client impact)and a SemVer classification + rationale, gives repo context (library,user.*config, numeric responsecode), constrains to exact class/property/endpoint names, and uses the most recent existing CHANGELOG section as a few-shot style exemplar.gh), not just subjects.Cost / security
CHANGELOG_PROMPT_CHAR_BUDGET) with graceful degradation (drop diffs, then truncate).CHANGELOG_NO_DIFFS(metadata only) andCHANGELOG_SKIP_AI(fully offline) opt-outs.Categorization / cleanup
[Gradle Release Plugin],*-SNAPSHOT) are filtered.git showcalls into one.import reis now used.requirements.txt(pinnedopenai) and a stdlibunittestsuite (24 tests). Added--dry-run.Config / env vars
OPENAI_API_TOKEN,OPENAI_MODEL(defaultgpt-5),OPENAI_TEMPERATURE(default0.2),CHANGELOG_SKIP_AI,CHANGELOG_NO_DIFFS,CHANGELOG_PROMPT_CHAR_BUDGET(default 200000),CHANGELOG_FETCH_PR_BODIES.Not done (by design)
The ticket floated a hard human-approval gate for the generated changelog. I did not add a blocking gate inside the automated release (it would break
./gradlew release). Instead:--dry-run, a "review and refine" marker on offline output, and preserve-and-refine of hand drafts. Happy to add an opt-in gate if you'd prefer.Testing
python -m unittest test_generate_changelog→ 24 passing (categorization, plumbing filter, split/compose/update, Unreleased reset, same-version dedup, budget degradation, fallback, code-fence stripping, non-fatal collection failure, skip-AI does not callgh).CHANGELOG_SKIP_AI=1 python generate_changelog.py 5.1.1 --dry-runagainst real history.CHANGELOG.md: single title, inserted below title, no5.1.0duplication, order preserved.