From 27732b6717bcfc83760dc44ee866a1d438046024 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 11 Jul 2026 12:26:42 -0600 Subject: [PATCH 1/2] build(changelog): harden and improve the AI changelog generator 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 --- .gitignore | 5 + generate_changelog.py | 791 ++++++++++++++++++++++++++++--------- requirements.txt | 3 + test_generate_changelog.py | 276 +++++++++++++ 4 files changed, 896 insertions(+), 179 deletions(-) create mode 100644 requirements.txt create mode 100644 test_generate_changelog.py diff --git a/.gitignore b/.gitignore index 7047c447..5fc92f88 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,11 @@ # Created by https://www.gitignore.io/api/java,gradle,eclipse # Edit at https://www.gitignore.io/?templates=java,gradle,eclipse +### Python (release tooling: generate_changelog.py) ### +__pycache__/ +*.pyc +.venv/ + ### MacOS ### *.DS_Store diff --git a/generate_changelog.py b/generate_changelog.py index 3de5f86a..8dbc52cb 100644 --- a/generate_changelog.py +++ b/generate_changelog.py @@ -1,227 +1,660 @@ +"""Generate a CHANGELOG.md entry for a release from git history. + +This script runs automatically inside `./gradlew release` (the `generateAIChangelog` +task, wired into `beforeReleaseBuild`). It collects the commits since the last tag, +pre-categorizes them, optionally asks an LLM to write a polished changelog section, +and inserts that section into CHANGELOG.md (below the title, Keep a Changelog style). + +DATA-SHARING NOTICE +------------------- +When AI generation is enabled (an OpenAI token is present and CHANGELOG_SKIP_AI is +not set), this script sends commit messages, commit bodies, file lists and code +*diffs* for the release range to the OpenAI API. Diffs can contain sensitive logic +or, in rare cases, secrets. This is an intentional trade-off for output quality on a +security-focused library. To avoid sending diffs, run with CHANGELOG_SKIP_AI=1 (uses +the deterministic offline generator) or set CHANGELOG_NO_DIFFS=1 (AI on, but only +commit messages/bodies and file lists are sent, no diff bodies). + +The changelog step is best-effort: any failure to reach the AI degrades to the +deterministic generator so a release is never aborted by this script. + +Environment variables +--------------------- + OPENAI_API_TOKEN OpenAI API key. If unset, the deterministic generator is used. + OPENAI_MODEL Model name (default: gpt-5). + OPENAI_TEMPERATURE Sampling temperature (default: 0.2). Ignored gracefully + if the model rejects a custom temperature. + CHANGELOG_SKIP_AI If set (to any non-empty value), skip the AI entirely. + CHANGELOG_NO_DIFFS If set, send commit metadata to the AI but no diff bodies. + CHANGELOG_PROMPT_CHAR_BUDGET Max characters of commit context sent to the AI + (default: 200000). Context degrades gracefully when over. + CHANGELOG_FETCH_PR_BODIES If set to 0/false, skip fetching PR descriptions via `gh`. + +Usage +----- + python generate_changelog.py [--dry-run] + python generate_changelog.py # interactive (prompts for version on a TTY) +""" + +import argparse +import json import os -import sys +import re +import shutil import subprocess -from openai import OpenAI +import sys from datetime import date -import re -# tempfile no longer needed -def get_git_commits_with_diffs(): - # Get the last tag +DEFAULT_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5") +DEFAULT_TEMPERATURE = os.environ.get("OPENAI_TEMPERATURE", "0.2") +PROMPT_CHAR_BUDGET = int(os.environ.get("CHANGELOG_PROMPT_CHAR_BUDGET", "200000")) +PER_COMMIT_DIFF_LINE_LIMIT = 500 +MAX_PR_BODY_CHARS = 4000 +EXEMPLAR_CHAR_LIMIT = 6000 +CHANGELOG_FILE = "CHANGELOG.md" + +CATEGORY_ORDER = [ + ("security", "Security"), + ("breaking_changes", "Breaking Changes"), + ("features", "Features"), + ("fixes", "Fixes"), + ("refactorings", "Refactoring"), + ("docs", "Documentation"), + ("tests", "Testing"), + ("other", "Other Changes"), +] + +# Commits that are pure release plumbing and carry no changelog value. +RELEASE_PLUMBING_RE = re.compile( + r"\[Gradle Release Plugin\]|new version commit|pre tag commit|-SNAPSHOT", + re.IGNORECASE, +) +PR_NUMBER_RE = re.compile(r"#(\d+)") +VERSION_HEADING_RE = re.compile(r"^## \[([^\]]+)\]") +MERGE_COMMIT_RE = re.compile(r"^Merge (pull request|branch|remote-tracking)", re.IGNORECASE) + + +# --------------------------------------------------------------------------- # +# Git collection +# --------------------------------------------------------------------------- # +def _git(args): + """Run a git command, decoding as UTF-8 and tolerating undecodable bytes.""" + return subprocess.check_output(args, encoding="utf-8", errors="replace") + + +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() + + +def _parse_stat_files(show_output): + """Extract changed file paths from the diffstat block of `git show --stat`.""" + files = [] + for line in show_output.splitlines(): + # Diffstat rows look like: " path/to/File.java | 12 +++---" + m = re.match(r"^\s(\S.*?)\s+\|\s+(?:\d+|Bin)", line) + if m: + path = m.group(1).strip() + # Handle rename rows: "old => new" or "dir/{a => b}/file". + if "=>" in path: + path = re.sub(r"\{[^}]*=>\s*([^}]*)\}", r"\1", path) + path = path.split("=>")[-1].strip() + files.append(path) + return files + + +def get_commits(last_ref): + """Collect commits since `last_ref`, with subject, body, diff and file list. + + Release-plumbing commits are filtered out. + """ + us, rs = "\x1f", "\x1e" # unit + record separators (safe for multiline bodies) + raw = _git( + ["git", "log", f"{last_ref}..HEAD", f"--pretty=format:%H{us}%s{us}%b{rs}"] + ) + if not raw.strip(): + return [] - # Get commit hashes and messages since the last tag - commit_info = subprocess.check_output( - ["git", "log", f"{last_tag}..HEAD", "--pretty=format:%H|%s"], text=True - ).strip() - - if not commit_info: - return last_tag, [] - - commits_with_diffs = [] - for line in commit_info.split("\n"): - if not line: + commits = [] + for record in raw.split(rs): + record = record.strip("\n") + if not record.strip(): + continue + parts = record.split(us) + commit_hash = parts[0].strip() + subject = parts[1] if len(parts) > 1 else "" + body = parts[2].strip() if len(parts) > 2 else "" + if not commit_hash: continue - commit_hash, commit_message = line.split("|", 1) + if RELEASE_PLUMBING_RE.search(subject) or ( + body and RELEASE_PLUMBING_RE.search(body) and not subject.strip() + ): + continue - # Get the diff for this commit - diff = subprocess.check_output( - ["git", "show", "--stat", "--patch", commit_hash], text=True + # One `git show` yields both the diffstat (file summary) and the patch. + show = _git(["git", "show", "--stat", "--patch", commit_hash]) + pr_numbers = sorted({int(n) for n in PR_NUMBER_RE.findall(f"{subject}\n{body}")}) + + commits.append( + { + "hash": commit_hash, + "subject": subject, + "body": body, + "diff": show, + "files": _parse_stat_files(show), + "pr_numbers": pr_numbers, + "pr_bodies": [], + } ) + return commits - # Extract file changes - files_changed = subprocess.check_output( - ["git", "show", "--name-status", commit_hash], text=True - ).strip() - - commits_with_diffs.append({ - "hash": commit_hash, - "message": commit_message, - "diff": diff, - "files_changed": files_changed - }) - - return last_tag, commits_with_diffs - -def categorize_commits(commits_with_diffs): - """Pre-categorize commits based on conventional commit prefixes""" - categories = { - "features": [], - "fixes": [], - "breaking_changes": [], - "refactorings": [], - "docs": [], - "tests": [], - "other": [] - } - - for commit in commits_with_diffs: - message = commit["message"].lower() - - # Check for breaking changes first - if "breaking change" in message or "!" in message.split(":", 1)[0]: - categories["breaking_changes"].append(commit) - # Then check for common prefixes - elif message.startswith(("feat", "feature")): - categories["features"].append(commit) - elif message.startswith(("fix", "bugfix", "bug")): - categories["fixes"].append(commit) - elif message.startswith("refactor"): - categories["refactorings"].append(commit) - elif message.startswith(("doc", "docs")): - categories["docs"].append(commit) - elif message.startswith(("test", "tests")): - categories["tests"].append(commit) - else: - # Attempt to categorize based on the diff - if any(term in commit["diff"].lower() for term in ["fix", "bug", "issue", "error", "crash"]): - categories["fixes"].append(commit) - elif any(term in commit["diff"].lower() for term in ["feat", "feature", "add", "new", "implement"]): - categories["features"].append(commit) - else: - categories["other"].append(commit) - return categories +def fetch_pr_bodies(commits): + """Best-effort: attach PR titles/descriptions for referenced PRs via `gh`. -def generate_changelog(commits_with_diffs, categorized_commits): - if not commits_with_diffs: - return "No commits to include in the changelog." + Silently no-ops if `gh` is unavailable or a call fails. + """ + if os.environ.get("CHANGELOG_FETCH_PR_BODIES", "").lower() in ("0", "false", "no"): + return + if not shutil.which("gh"): + return - # Prepare the detailed diff information - diff_content = "# Git Commit Information for Changelog Generation\n\n" + 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]) - # Add categorized commit information - for category, commits in categorized_commits.items(): - if commits: - diff_content += f"## {category.replace('_', ' ').title()}\n" - for commit in commits: - diff_content += f"### Commit: {commit['hash'][:8]} - {commit['message']}\n" - # Add file changes summary - diff_content += "#### Files Changed:\n" - diff_content += f"```\n{commit['files_changed']}\n```\n" +def _fetch_single_pr(number): + try: + out = subprocess.run( + ["gh", "pr", "view", str(number), "--json", "title,body"], + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=30, + ) + except (subprocess.SubprocessError, OSError): + return None + if out.returncode != 0: + return None + try: + data = json.loads(out.stdout) + except ValueError: + return None + title = (data.get("title") or "").strip() + body = (data.get("body") or "").strip() + if not title and not body: + return None + if len(body) > MAX_PR_BODY_CHARS: + body = body[:MAX_PR_BODY_CHARS] + "\n... (PR description truncated)" + return f"#{number} {title}\n{body}".strip() + + +# --------------------------------------------------------------------------- # +# Categorization +# --------------------------------------------------------------------------- # +def _looks_like_test(path): + p = path.lower() + return ( + "/test/" in p + or "/tests/" in p + or p.endswith(("test.java", "tests.java", "spec.js", "spec.ts")) + or ".test." in p + or ".spec." in p + ) - # Add a truncated diff only if it's very long (over 500 lines) - diff_lines = commit['diff'].split('\n') - if len(diff_lines) > 500: - truncated_diff = '\n'.join(diff_lines[:500]) - truncated_diff += f"\n... (diff truncated, showing first 500 of {len(diff_lines)} lines)" - else: - truncated_diff = commit['diff'] - diff_content += "#### Diff Preview:\n" - diff_content += f"```diff\n{truncated_diff}\n```\n\n" +def _looks_like_docs(path): + p = path.lower() + return p.endswith((".md", ".adoc", ".rst", ".txt")) or "/docs/" in p - # Build the prompt with categorized information and diff data - prompt = f""" -You are a skilled software developer tasked with creating a detailed changelog. -I have provided you with git commit information including: -1. Commit messages -2. File changes -3. Code diffs -Please generate a clear, comprehensive changelog based on this information. -The commits have been pre-categorized, but please use the actual code changes to: -- Improve category assignments if needed -- Add more specific details about what changed in each commit -- Extract key implementation details from the diffs -- Identify significant changes that aren't clear from just the commit messages +def categorize_commits(commits): + """Bucket commits by conventional-commit prefix, then by changed-file paths. -Here is the detailed commit information: + No whole-diff substring guessing: leftover commits are classified by the kind + of files they touch (docs-only, tests-only) or left as "other". + """ + categories = {key: [] for key, _ in CATEGORY_ORDER} -{diff_content} + for commit in commits: + subject = commit["subject"].lower() + prefix = subject.split(":", 1)[0] + body = commit["body"].lower() + conventional_breaking = bool(re.match(r"^[a-z]+(\([^)]*\))?!:", subject)) -Format the changelog in Markdown as follows: -### Features -- Detailed feature descriptions here, with substantive information from the diffs + if "breaking change" in subject or "breaking change" in body or conventional_breaking: + categories["breaking_changes"].append(commit) + elif prefix.startswith("security"): + categories["security"].append(commit) + elif prefix.startswith(("feat", "feature")): + categories["features"].append(commit) + elif prefix.startswith(("fix", "bugfix", "bug", "hotfix")): + categories["fixes"].append(commit) + elif prefix.startswith("refactor"): + categories["refactorings"].append(commit) + elif prefix.startswith(("doc", "docs")): + categories["docs"].append(commit) + elif prefix.startswith(("test", "tests")): + categories["tests"].append(commit) + elif prefix.startswith(("chore", "ci", "build", "style", "perf")): + categories["other"].append(commit) + else: + files = commit["files"] + if files and all(_looks_like_docs(f) for f in files): + categories["docs"].append(commit) + elif files and all(_looks_like_test(f) for f in files): + categories["tests"].append(commit) + else: + categories["other"].append(commit) + + return categories -### Fixes -- Detailed bug fix descriptions here, with substantive information from the diffs -### Breaking Changes -- Detailed descriptions of breaking changes here (if any), with clear explanations of what changed +# --------------------------------------------------------------------------- # +# Prompt building (with an overall size budget) +# --------------------------------------------------------------------------- # +def _truncate_diff(diff): + lines = diff.split("\n") + if len(lines) > PER_COMMIT_DIFF_LINE_LIMIT: + head = "\n".join(lines[:PER_COMMIT_DIFF_LINE_LIMIT]) + return f"{head}\n... (diff truncated, showing first {PER_COMMIT_DIFF_LINE_LIMIT} of {len(lines)} lines)" + return diff + + +def _render_commit(commit, include_diff): + parts = [f"### Commit {commit['hash'][:8]} — {commit['subject']}"] + if commit["body"]: + parts.append(f"Body:\n{commit['body']}") + for pr in commit["pr_bodies"]: + parts.append(f"Referenced PR:\n{pr}") + if commit["files"]: + parts.append("Files changed:\n" + "\n".join(f"- {f}" for f in commit["files"])) + if include_diff: + parts.append(f"Diff:\n```diff\n{_truncate_diff(commit['diff'])}\n```") + return "\n".join(parts) + "\n" + + +def _render_context(categorized, include_diff): + out = ["# Commit information for changelog generation\n"] + for key, label in CATEGORY_ORDER: + commits = categorized[key] + if not commits: + continue + out.append(f"## Pre-categorized as: {label}\n") + for commit in commits: + out.append(_render_commit(commit, include_diff)) + return "\n".join(out) + + +def build_context(categorized, budget=PROMPT_CHAR_BUDGET, allow_diffs=True): + """Build the commit-context block, degrading to stay within `budget` chars.""" + if allow_diffs: + context = _render_context(categorized, include_diff=True) + if len(context) <= budget: + return context + print( + f"Commit context {len(context)} chars exceeds budget {budget}; " + "dropping diff bodies to fit." + ) + context = _render_context(categorized, include_diff=False) + if len(context) > budget: + print(f"Commit context still over budget; truncating to {budget} chars.") + context = context[:budget] + "\n... (commit context truncated to fit budget)" + return context + + +def read_exemplar(changelog_file=CHANGELOG_FILE): + """Return the most recent existing changelog section body as a style exemplar.""" + if not os.path.exists(changelog_file): + return "" + with open(changelog_file, "r", encoding="utf-8") as f: + content = f.read() + _, sections = split_changelog(content) + for section in sections: + if section["version"].lower() == "unreleased": + continue + body = section["body"].strip() + if len(body) > EXEMPLAR_CHAR_LIMIT: + body = body[:EXEMPLAR_CHAR_LIMIT] + "\n... (exemplar truncated)" + return body + return "" + + +def build_prompt(context, exemplar, human_draft): + exemplar_block = "" + if exemplar: + exemplar_block = ( + "\nSTYLE EXEMPLAR — a previous hand-written entry. Match its voice, " + "structure, altitude, and level of specificity (do NOT copy its content):\n" + f"\n{exemplar}\n\n" + ) + draft_block = "" + if human_draft: + draft_block = ( + "\nHUMAN DRAFT — authoritative notes already written for this release. " + "Complete and refine these; do NOT discard or contradict them:\n" + f"\n{human_draft}\n\n" + ) -### Refactoring -- Important code refactoring changes (if any) + return f"""You are writing the CHANGELOG entry for a new release of Spring User Framework. + +REPOSITORY CONTEXT +- This is a reusable Spring Boot *library*, not an application. Its consumers are other + Spring Boot apps. +- Consumers configure it via properties under the `user.*` prefix. +- Its REST endpoints return a numeric response `code` in the JSON body; describe HTTP + status changes and response `code` changes explicitly. +- Frame every change in terms of its impact on a *consuming application*. + +INPUT +Below is the commit information for this release (messages, bodies, referenced PR +descriptions, file lists, and where available code diffs), pre-categorized by a +heuristic. Trust the actual changes over the heuristic categories. +{exemplar_block}{draft_block} +{context} + +OUTPUT REQUIREMENTS +- Output GitHub-flavored Markdown for the body of ONE release section. Do NOT include + the `## [version]` heading or the date — those are added automatically. Start with a + one- to two-sentence summary paragraph of the release. +- After the summary, add a **SemVer classification** line: state whether this is a + major, minor, or patch release and give a one-sentence rationale (new public API → + minor; incompatible API change → major; otherwise patch). +- Then use these `###` subsections, in this order, OMITTING any that are empty: + `### Security`, `### Breaking Changes`, `### Features`, `### Fixes`, + `### Refactoring`, `### Documentation`, `### Testing`, `### Other Changes`. +- Lead with `### Security` whenever there are security-relevant changes. Reference any + CWE identifiers and ticket IDs (e.g. `SUF-01`) found in the commit text. +- If any change alters HTTP status codes, response `code` values, defaults, or requires + action from consumers, add a `### Behavior changes (client impact)` subsection (place + it right after the security/breaking sections) spelling out the required consumer action. +- Use the EXACT class, property, and endpoint names from the diffs and commit text. Do + not invent names. Prefer specifics (status codes, property keys) over generalities. +- Ignore release-plumbing noise; every bullet must describe a real code or doc change. +""" -### Documentation -- Documentation updates (if any) -### Testing -- Test-related changes (if any) +# --------------------------------------------------------------------------- # +# AI + deterministic generation +# --------------------------------------------------------------------------- # +def _create_completion(client, model, messages, temperature): + """Call chat.completions, retrying without temperature if the model rejects it.""" + try: + kwargs = {"model": model, "messages": messages} + if temperature is not None: + kwargs["temperature"] = temperature + return client.chat.completions.create(**kwargs) + except Exception as exc: # noqa: BLE001 - narrow retry on temperature rejection + if temperature is not None and "temperature" in str(exc).lower(): + print(f"Model rejected temperature={temperature}; retrying with model default.") + return client.chat.completions.create(model=model, messages=messages) + raise -### Other Changes -- Any other significant changes -Important: Focus on providing value to humans reading the changelog. Explain changes in user-centric terms where possible. -""" +def generate_ai_changelog(prompt): + """Call OpenAI to write the changelog body. Raises on any failure.""" + from openai import OpenAI # lazy: keeps the offline path import-free - client = OpenAI( - api_key=os.environ.get("OPENAI_API_TOKEN"), - ) + token = os.environ.get("OPENAI_API_TOKEN") + if not token: + raise RuntimeError("OPENAI_API_TOKEN is not set") - # Use GPT-4o (without fallback) - response = client.chat.completions.create( - model="gpt-5", - messages=[ - {"role": "system", "content": "You are a helpful assistant for software development with expertise in analyzing code changes and creating detailed changelogs."}, + try: + temperature = float(DEFAULT_TEMPERATURE) + except ValueError: + temperature = None + + client = OpenAI(api_key=token) + response = _create_completion( + client, + DEFAULT_MODEL, + [ + { + "role": "system", + "content": "You are an expert release engineer who writes precise, " + "consumer-focused changelogs for a Spring Boot library.", + }, {"role": "user", "content": prompt}, ], + temperature, ) - changelog = response.choices[0].message.content.strip() - - # No temporary file to clean up anymore - - return changelog - -def update_changelog(version, changelog_content): - changelog_file = "CHANGELOG.md" + text = (response.choices[0].message.content or "").strip() + if not text: + raise RuntimeError("Model returned an empty changelog") + return _strip_code_fences(text) + + +def _strip_code_fences(text): + """Remove a wrapping ```markdown ... ``` fence if the model added one.""" + stripped = text.strip() + if stripped.startswith("```"): + lines = stripped.split("\n") + if lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + return "\n".join(lines).strip() + return text + + +def generate_fallback_changelog(categorized, human_draft): + """Deterministic, offline changelog body from categorized commits.""" + lines = [ + "_This section was generated offline from commit metadata " + "(AI generation was unavailable); review and refine before publishing._", + ] + if human_draft: + lines.append("") + lines.append(human_draft.strip()) + + for key, label in CATEGORY_ORDER: + commits = [c for c in categorized[key] if not MERGE_COMMIT_RE.match(c["subject"])] + if not commits: + continue + lines.append("") + lines.append(f"### {label}") + for commit in commits: + subject = commit["subject"].strip() + pr = f" (#{commit['pr_numbers'][0]})" if commit["pr_numbers"] else "" + lines.append(f"- {subject}{pr} ({commit['hash'][:8]})") + return "\n".join(lines).strip() + + +# --------------------------------------------------------------------------- # +# CHANGELOG.md read/split/write +# --------------------------------------------------------------------------- # +def split_changelog(content): + """Split CHANGELOG content into (head, sections). + + `head` is the title/preamble before the first `## [..]` heading. Each section is + a dict with `version`, `heading`, `body`, and `raw`. + """ + lines = content.splitlines(keepends=True) + first_idx = next( + (i for i, ln in enumerate(lines) if VERSION_HEADING_RE.match(ln)), None + ) + if first_idx is None: + return content, [] + + head = "".join(lines[:first_idx]) + sections = [] + current = None + for ln in lines[first_idx:]: + m = VERSION_HEADING_RE.match(ln) + if m: + if current: + sections.append(current) + current = {"version": m.group(1), "heading": ln, "body": "", "raw": ln} + else: + current["body"] += ln + current["raw"] += ln + if current: + sections.append(current) + return head, sections + + +EMPTY_UNRELEASED = "## [Unreleased]\n\n" + + +def compose_changelog(head, sections, version, today, new_body): + """Assemble a full CHANGELOG, inserting the new version section below the title. + + - Consumes and resets any `## [Unreleased]` section (its notes were folded in). + - Replaces any pre-existing section for the same `version` (dedup / refine). + - Keeps `[Unreleased]` at the top when present. + """ + had_unreleased = any(s["version"].lower() == "unreleased" for s in sections) + kept = [ + s + for s in sections + if s["version"].lower() != "unreleased" and s["version"] != version + ] + + new_section = f"## [{version}] - {today}\n\n{new_body.strip()}\n\n" + + if head and not head.endswith("\n\n"): + head = head.rstrip("\n") + "\n\n" + parts = [head] + if had_unreleased: + parts.append(EMPTY_UNRELEASED) + parts.append(new_section) + for s in kept: + parts.append(s["raw"]) + return "".join(parts) + + +def extract_human_draft(sections, version): + """Gather human-written notes to feed the model: Unreleased + same-version body.""" + drafts = [] + for s in sections: + if s["version"].lower() == "unreleased" and s["body"].strip(): + drafts.append(s["body"].strip()) + elif s["version"] == version and s["body"].strip(): + drafts.append(s["body"].strip()) + return "\n\n".join(drafts).strip() + + +DEFAULT_HEADER = ( + "# Changelog\n\n" + "All notable changes to this project are documented here. " + "This project follows [Semantic Versioning](https://semver.org/).\n\n" +) + + +def update_changelog(version, new_body, changelog_file=CHANGELOG_FILE): today = date.today().strftime("%Y-%m-%d") - new_entry = f"## [{version}] - {today}\n{changelog_content}\n\n" - if os.path.exists(changelog_file): - with open(changelog_file, "r+") as f: - old_content = f.read() - f.seek(0, 0) - f.write(new_entry + old_content) + with open(changelog_file, "r", encoding="utf-8") as f: + content = f.read() + head, sections = split_changelog(content) + if not head.strip(): + head = DEFAULT_HEADER else: - with open(changelog_file, "w") as f: - f.write(new_entry) + head, sections = DEFAULT_HEADER, [] + + result = compose_changelog(head, sections, version, today, new_body) + with open(changelog_file, "w", encoding="utf-8") as f: + f.write(result) + + +# --------------------------------------------------------------------------- # +# Entry point +# --------------------------------------------------------------------------- # +def resolve_version(cli_version): + if cli_version: + return cli_version + if sys.stdin.isatty(): + return input("Enter the new version (e.g., 1.0.0): ").strip() + print( + "ERROR: no version argument provided and no interactive terminal available. " + "Pass the version as the first argument (e.g. `generate_changelog.py 1.2.3`).", + file=sys.stderr, + ) + sys.exit(2) + + +def build_changelog_body(commits, version): + """Produce the changelog body, using AI when possible and falling back cleanly.""" + categorized = categorize_commits(commits) + + human_draft = "" + if os.path.exists(CHANGELOG_FILE): + with open(CHANGELOG_FILE, "r", encoding="utf-8") as f: + _, sections = split_changelog(f.read()) + human_draft = extract_human_draft(sections, version) + + if os.environ.get("CHANGELOG_SKIP_AI") or not os.environ.get("OPENAI_API_TOKEN"): + reason = "CHANGELOG_SKIP_AI set" if os.environ.get("CHANGELOG_SKIP_AI") else "no OPENAI_API_TOKEN" + print(f"AI changelog disabled ({reason}); using deterministic generator.") + return generate_fallback_changelog(categorized, human_draft) + + # PR descriptions only feed the AI context, so fetch them only on the AI path. + fetch_pr_bodies(commits) + allow_diffs = not os.environ.get("CHANGELOG_NO_DIFFS") + context = build_context(categorized, allow_diffs=allow_diffs) + exemplar = read_exemplar() + prompt = build_prompt(context, exemplar, human_draft) + try: + print("Generating changelog via OpenAI...") + return generate_ai_changelog(prompt) + except Exception as exc: # noqa: BLE001 - never let this abort a release + print( + f"WARNING: AI changelog generation failed ({exc}); " + "falling back to the deterministic generator.", + file=sys.stderr, + ) + return generate_fallback_changelog(categorized, human_draft) -if __name__ == "__main__": - last_tag, commits_with_diffs = get_git_commits_with_diffs() - if not commits_with_diffs: - print("No new commits found.") - sys.exit(0) +def main(argv=None): + parser = argparse.ArgumentParser(description="Generate a CHANGELOG.md release entry.") + parser.add_argument("version", nargs="?", help="Release version, e.g. 1.2.3") + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the generated section without writing CHANGELOG.md.", + ) + args = parser.parse_args(argv) - print(f"Found {len(commits_with_diffs)} commits since {last_tag}") + try: + last_ref = get_last_reference() + commits = get_commits(last_ref) + except Exception as exc: # noqa: BLE001 - collection failure must not abort a release + print( + f"WARNING: could not collect git history for the changelog ({exc}); " + "skipping changelog update.", + file=sys.stderr, + ) + return 0 - # Pre-categorize commits for better LLM results - categorized_commits = categorize_commits(commits_with_diffs) + if not commits: + print("No new commits found since", last_ref) + return 0 - print("Generating detailed changelog...") - changelog_content = generate_changelog(commits_with_diffs, categorized_commits) + print(f"Found {len(commits)} commits since {last_ref}") + version = resolve_version(args.version) + body = build_changelog_body(commits, version) - print("\nGenerated Changelog:") - print(changelog_content) + if args.dry_run: + today = date.today().strftime("%Y-%m-%d") + print("\n--- DRY RUN (CHANGELOG.md not modified) ---\n") + print(f"## [{version}] - {today}\n\n{body}\n") + return 0 + + update_changelog(version, body) + print(f"Changelog updated for version {version}!") + return 0 - # Check if a version was passed as a command-line argument - if len(sys.argv) > 1: - new_version = sys.argv[1] - else: - # Prompt for a version if none was provided - new_version = input("Enter the new version (e.g., 1.0.0): ").strip() - update_changelog(new_version, changelog_content) - print(f"Changelog updated for version {new_version}!") +if __name__ == "__main__": + sys.exit(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..ccdf0f87 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# Dependencies for generate_changelog.py (release changelog generator). +# Pinned for reproducible releases; bump deliberately and re-test. +openai==1.105.0 diff --git a/test_generate_changelog.py b/test_generate_changelog.py new file mode 100644 index 00000000..c6a2b7b2 --- /dev/null +++ b/test_generate_changelog.py @@ -0,0 +1,276 @@ +"""Tests for generate_changelog.py pure functions. + +Run with: python -m unittest test_generate_changelog +No network or OpenAI package required (the OpenAI import is lazy). +""" + +import os +import tempfile +import unittest +from datetime import date +from unittest import mock + +import generate_changelog as gc + + +def _commit(subject, body="", files=None, diff="", pr_numbers=None): + return { + "hash": "abcdef1234567890", + "subject": subject, + "body": body, + "diff": diff, + "files": files or [], + "pr_numbers": pr_numbers or [], + "pr_bodies": [], + } + + +class ReleasePlumbingTests(unittest.TestCase): + def test_matches_gradle_release_and_snapshot(self): + for msg in ( + "[Gradle Release Plugin] - new version commit: '5.1.1-SNAPSHOT'.", + "[Gradle Release Plugin] - pre tag commit: '5.1.0'.", + "bump to 6.0.0-SNAPSHOT", + ): + self.assertIsNotNone(gc.RELEASE_PLUMBING_RE.search(msg), msg) + + def test_ignores_real_commits(self): + self.assertIsNone(gc.RELEASE_PLUMBING_RE.search("feat: add StepUpService SPI")) + + +class ParseStatFilesTests(unittest.TestCase): + def test_parses_paths_and_renames(self): + show = ( + "commit abc\n\n subject\n\n" + " src/main/java/Foo.java | 12 ++++----\n" + " docs/{old => new}/Guide.md | 4 +--\n" + " bin/logo.png | Bin 0 -> 10 bytes\n" + " 3 files changed\n\n" + "diff --git a/src/main/java/Foo.java b/src/main/java/Foo.java\n" + ) + files = gc._parse_stat_files(show) + self.assertIn("src/main/java/Foo.java", files) + self.assertIn("bin/logo.png", files) + self.assertTrue(any(f.endswith("Guide.md") for f in files)) + self.assertNotIn("3 files changed", " ".join(files)) + + +class CategorizeTests(unittest.TestCase): + def test_conventional_prefixes(self): + commits = [ + _commit("feat: new thing"), + _commit("fix: a bug"), + _commit("docs: update readme"), + _commit("refactor: tidy"), + _commit("test: add coverage"), + _commit("security: patch CWE-640"), + _commit("feat!: breaking api"), + _commit("chore: bump dep"), + ] + cats = gc.categorize_commits(commits) + self.assertEqual(len(cats["features"]), 1) + self.assertEqual(len(cats["fixes"]), 1) + self.assertEqual(len(cats["docs"]), 1) + self.assertEqual(len(cats["refactorings"]), 1) + self.assertEqual(len(cats["tests"]), 1) + self.assertEqual(len(cats["security"]), 1) + self.assertEqual(len(cats["breaking_changes"]), 1) + self.assertEqual(len(cats["other"]), 1) + + def test_breaking_change_in_body(self): + cats = gc.categorize_commits([_commit("update api", body="BREAKING CHANGE: removed X")]) + self.assertEqual(len(cats["breaking_changes"]), 1) + + def test_trailing_bang_is_not_breaking(self): + # A non-conventional subject that merely ends in "!" must not be breaking. + cats = gc.categorize_commits([_commit("fix the thing already!")]) + self.assertEqual(len(cats["breaking_changes"]), 0) + self.assertEqual(len(cats["fixes"]), 1) + + def test_path_based_fallback_docs_and_tests(self): + cats = gc.categorize_commits( + [ + _commit("update stuff", files=["README.md", "docs/Guide.md"]), + _commit("more stuff", files=["src/test/java/FooTest.java"]), + _commit("mixed", files=["src/main/java/Foo.java", "README.md"]), + ] + ) + self.assertEqual(len(cats["docs"]), 1) + self.assertEqual(len(cats["tests"]), 1) + self.assertEqual(len(cats["other"]), 1) + + def test_no_diff_substring_guessing(self): + # A non-conventional commit whose diff merely contains the word "fix" + # must NOT be bucketed as a fix (the old heuristic did this). + cats = gc.categorize_commits( + [_commit("adjust config", files=["src/main/java/Foo.java"], diff="+ // fix typo")] + ) + self.assertEqual(len(cats["fixes"]), 0) + self.assertEqual(len(cats["other"]), 1) + + +class SplitAndComposeTests(unittest.TestCase): + SAMPLE = ( + "# Changelog\n\n" + "Preamble text.\n\n" + "## [5.0.1] - 2026-06-15\n" + "### Fixes\n- old fix\n\n" + "## [5.0.0] - 2026-05-01\n" + "### Features\n- old feature\n\n" + ) + + def test_split_head_and_sections(self): + head, sections = gc.split_changelog(self.SAMPLE) + self.assertIn("# Changelog", head) + self.assertIn("Preamble", head) + self.assertEqual([s["version"] for s in sections], ["5.0.1", "5.0.0"]) + + def test_compose_inserts_below_title(self): + head, sections = gc.split_changelog(self.SAMPLE) + result = gc.compose_changelog(head, sections, "5.1.0", "2026-07-11", "### Security\n- new") + # Title comes first, new version sits below it and above the previous latest. + self.assertLess(result.index("# Changelog"), result.index("## [5.1.0]")) + self.assertLess(result.index("## [5.1.0]"), result.index("## [5.0.1]")) + self.assertIn("### Security", result) + + 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. + head, sections = gc.split_changelog( + "# Changelog\n\n## [5.1.0] - 2026-07-01\n### Features\n- hand draft\n\n" + "## [5.0.1] - 2026-06-15\n### Fixes\n- old\n\n" + ) + result = gc.compose_changelog(head, sections, "5.1.0", "2026-07-11", "### Security\n- refined") + self.assertEqual(result.count("## [5.1.0]"), 1) + self.assertIn("### Security", result) + self.assertNotIn("hand draft", result) + + def test_compose_resets_unreleased(self): + content = ( + "# Changelog\n\n" + "## [Unreleased]\n### Features\n- jotted note\n\n" + "## [5.0.1] - 2026-06-15\n### Fixes\n- old\n\n" + ) + head, sections = gc.split_changelog(content) + result = gc.compose_changelog(head, sections, "5.1.0", "2026-07-11", "### Features\n- done") + self.assertEqual(result.count("## [Unreleased]"), 1) + self.assertNotIn("jotted note", result) + # Unreleased stays on top, new version below it. + self.assertLess(result.index("## [Unreleased]"), result.index("## [5.1.0]")) + + +class HumanDraftTests(unittest.TestCase): + def test_extracts_unreleased_and_same_version(self): + content = ( + "# Changelog\n\n" + "## [Unreleased]\n- unreleased note\n\n" + "## [5.1.0] - 2026-07-01\n- version draft\n\n" + ) + _, sections = gc.split_changelog(content) + draft = gc.extract_human_draft(sections, "5.1.0") + self.assertIn("unreleased note", draft) + self.assertIn("version draft", draft) + + def test_none_version_still_gets_unreleased(self): + content = "# Changelog\n\n## [Unreleased]\n- note\n\n" + _, sections = gc.split_changelog(content) + draft = gc.extract_human_draft(sections, None) + self.assertIn("note", draft) + + +class UpdateChangelogTests(unittest.TestCase): + def test_creates_file_when_missing(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "CHANGELOG.md") + gc.update_changelog("1.0.0", "### Features\n- first", changelog_file=path) + with open(path, encoding="utf-8") as f: + content = f.read() + self.assertIn("# Changelog", content) + self.assertIn("## [1.0.0]", content) + today = date.today().strftime("%Y-%m-%d") + self.assertIn(today, content) + + def test_inserts_below_existing_title(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "CHANGELOG.md") + with open(path, "w", encoding="utf-8") as f: + f.write("# Changelog\n\nPreamble.\n\n## [1.0.0] - 2026-01-01\n- old\n\n") + gc.update_changelog("1.1.0", "### Features\n- new", changelog_file=path) + with open(path, encoding="utf-8") as f: + content = f.read() + self.assertLess(content.index("# Changelog"), content.index("## [1.1.0]")) + self.assertLess(content.index("## [1.1.0]"), content.index("## [1.0.0]")) + + +class BudgetTests(unittest.TestCase): + def test_drops_diffs_when_over_budget(self): + big_diff = "diff --git a/x b/x\n" + ("+line\n" * 2000) + cats = gc.categorize_commits([_commit("feat: big", files=["x"], diff=big_diff)]) + context = gc.build_context(cats, budget=500, allow_diffs=True) + self.assertLessEqual(len(context), 500 + len("\n... (commit context truncated to fit budget)")) + self.assertNotIn("+line", context) + + +class MiscTests(unittest.TestCase): + def test_strip_code_fences(self): + self.assertEqual(gc._strip_code_fences("```markdown\n### X\n- y\n```"), "### X\n- y") + self.assertEqual(gc._strip_code_fences("### X\n- y"), "### X\n- y") + + def test_fallback_changelog_lists_commits(self): + cats = gc.categorize_commits( + [_commit("feat: add thing", pr_numbers=[42]), _commit("fix: a bug")] + ) + body = gc.generate_fallback_changelog(cats, human_draft="") + self.assertIn("### Features", body) + self.assertIn("add thing", body) + self.assertIn("#42", body) + self.assertIn("### Fixes", body) + + def test_fallback_includes_human_draft(self): + cats = gc.categorize_commits([_commit("feat: x")]) + body = gc.generate_fallback_changelog(cats, human_draft="- authoritative note") + self.assertIn("authoritative note", body) + + def test_fallback_excludes_merge_commits(self): + cats = gc.categorize_commits( + [ + _commit("Merge pull request #339 from devondragon/docs/cleanup", files=["CHANGELOG.md"]), + _commit("docs: real change", files=["CHANGELOG.md"]), + ] + ) + body = gc.generate_fallback_changelog(cats, human_draft="") + self.assertNotIn("Merge pull request", body) + self.assertIn("real change", body) + + def test_read_exemplar_skips_unreleased(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "CHANGELOG.md") + with open(path, "w", encoding="utf-8") as f: + f.write( + "# Changelog\n\n## [Unreleased]\n- skip me\n\n" + "## [1.0.0] - 2026-01-01\n### Features\n- real exemplar\n\n" + ) + exemplar = gc.read_exemplar(changelog_file=path) + self.assertIn("real exemplar", exemplar) + self.assertNotIn("skip me", exemplar) + + +class ReliabilityTests(unittest.TestCase): + def test_main_returns_zero_on_collection_failure(self): + # A git-collection failure must be non-fatal (return 0), not abort the release. + with mock.patch.object(gc, "get_last_reference", return_value="v1"), mock.patch.object( + gc, "get_commits", side_effect=RuntimeError("boom") + ): + self.assertEqual(gc.main(["9.9.9"]), 0) + + def test_skip_ai_does_not_fetch_pr_bodies(self): + commits = [_commit("feat: x", pr_numbers=[7])] + with mock.patch.dict(os.environ, {"CHANGELOG_SKIP_AI": "1"}, clear=False), mock.patch.object( + gc, "fetch_pr_bodies" + ) as fetch: + gc.build_changelog_body(commits, "1.0.0") + fetch.assert_not_called() + + +if __name__ == "__main__": + unittest.main() From 3b37b2048baafe938fba8fbb1bab03fb0c86b5fd Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 11 Jul 2026 12:46:29 -0600 Subject: [PATCH 2/2] build(changelog): address PR review feedback - 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. --- generate_changelog.py | 56 ++++++++++++++++++++++++++------------ test_generate_changelog.py | 48 +++++++++++++++++++++++++++++--- 2 files changed, 82 insertions(+), 22 deletions(-) diff --git a/generate_changelog.py b/generate_changelog.py index 8dbc52cb..b1b68e4d 100644 --- a/generate_changelog.py +++ b/generate_changelog.py @@ -48,6 +48,7 @@ DEFAULT_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5") DEFAULT_TEMPERATURE = os.environ.get("OPENAI_TEMPERATURE", "0.2") PROMPT_CHAR_BUDGET = int(os.environ.get("CHANGELOG_PROMPT_CHAR_BUDGET", "200000")) +MAX_PR_FETCHES = int(os.environ.get("CHANGELOG_MAX_PR_FETCHES", "50")) PER_COMMIT_DIFF_LINE_LIMIT = 500 MAX_PR_BODY_CHARS = 4000 EXEMPLAR_CHAR_LIMIT = 6000 @@ -64,9 +65,11 @@ ("other", "Other Changes"), ] -# Commits that are pure release plumbing and carry no changelog value. +# Commits that are pure release plumbing and carry no changelog value. Matches the +# Gradle Release Plugin's own commit messages, NOT any commit that mentions a version +# string (a bare "-SNAPSHOT" match would drop legitimate commits referencing one). RELEASE_PLUMBING_RE = re.compile( - r"\[Gradle Release Plugin\]|new version commit|pre tag commit|-SNAPSHOT", + r"\[Gradle Release Plugin\]|new version commit|pre tag commit", re.IGNORECASE, ) PR_NUMBER_RE = re.compile(r"#(\d+)") @@ -83,12 +86,12 @@ def _git(args): def get_last_reference(): - """Return the last tag, or the first commit if there are no tags.""" + """Return the last tag, or None when there are no tags (i.e. include all history).""" try: return _git(["git", "describe", "--tags", "--abbrev=0"]).strip() except subprocess.CalledProcessError: - print("No tags found. Using the first commit as reference.") - return _git(["git", "rev-list", "--max-parents=0", "HEAD"]).strip() + print("No tags found; including full history.") + return None def _parse_stat_files(show_output): @@ -110,12 +113,12 @@ def _parse_stat_files(show_output): def get_commits(last_ref): """Collect commits since `last_ref`, with subject, body, diff and file list. - Release-plumbing commits are filtered out. + When `last_ref` is falsy (no tags), the full history is used so the root commit + is included. Release-plumbing commits are filtered out. """ us, rs = "\x1f", "\x1e" # unit + record separators (safe for multiline bodies) - raw = _git( - ["git", "log", f"{last_ref}..HEAD", f"--pretty=format:%H{us}%s{us}%b{rs}"] - ) + rev_range = f"{last_ref}..HEAD" if last_ref else "HEAD" + raw = _git(["git", "log", rev_range, f"--pretty=format:%H{us}%s{us}%b{rs}"]) if not raw.strip(): return [] @@ -164,11 +167,20 @@ def fetch_pr_bodies(commits): if not shutil.which("gh"): return + unique = {n for commit in commits for n in commit["pr_numbers"]} + if len(unique) > MAX_PR_FETCHES: + print( + f"Note: {len(unique)} PRs referenced; fetching descriptions for at most " + f"{MAX_PR_FETCHES} (raise CHANGELOG_MAX_PR_FETCHES to change)." + ) + cache = {} for commit in commits: for number in commit["pr_numbers"]: if number not in cache: - cache[number] = _fetch_single_pr(number) + # `cache` grows with every seen number (including cap misses), so the + # number of actual `gh` calls is bounded by MAX_PR_FETCHES. + cache[number] = None if len(cache) >= MAX_PR_FETCHES else _fetch_single_pr(number) if cache[number]: commit["pr_bodies"].append(cache[number]) @@ -190,6 +202,8 @@ def _fetch_single_pr(number): data = json.loads(out.stdout) except ValueError: return None + if not isinstance(data, dict): + return None title = (data.get("title") or "").strip() body = (data.get("body") or "").strip() if not title and not body: @@ -458,6 +472,9 @@ def generate_fallback_changelog(categorized, human_draft): lines.append(human_draft.strip()) for key, label in CATEGORY_ORDER: + # Merge commits are dropped from this offline list (their PR's real commits are + # already listed), but intentionally kept in the AI context (_render_context) + # because they carry the PR numbers whose descriptions feed the model. commits = [c for c in categorized[key] if not MERGE_COMMIT_RE.match(c["subject"])] if not commits: continue @@ -598,13 +615,15 @@ def build_changelog_body(commits, version): print(f"AI changelog disabled ({reason}); using deterministic generator.") return generate_fallback_changelog(categorized, human_draft) - # PR descriptions only feed the AI context, so fetch them only on the AI path. - fetch_pr_bodies(commits) - allow_diffs = not os.environ.get("CHANGELOG_NO_DIFFS") - context = build_context(categorized, allow_diffs=allow_diffs) - exemplar = read_exemplar() - prompt = build_prompt(context, exemplar, human_draft) + # The entire AI path is guarded: any failure (PR fetch, context build, API call) + # degrades to the deterministic generator so a release is never aborted. try: + # PR descriptions only feed the AI context, so fetch them only on the AI path. + fetch_pr_bodies(commits) + allow_diffs = not os.environ.get("CHANGELOG_NO_DIFFS") + context = build_context(categorized, allow_diffs=allow_diffs) + exemplar = read_exemplar() + prompt = build_prompt(context, exemplar, human_draft) print("Generating changelog via OpenAI...") return generate_ai_changelog(prompt) except Exception as exc: # noqa: BLE001 - never let this abort a release @@ -637,11 +656,12 @@ def main(argv=None): ) return 0 + since = last_ref or "the beginning of history" if not commits: - print("No new commits found since", last_ref) + print("No new commits found since", since) return 0 - print(f"Found {len(commits)} commits since {last_ref}") + print(f"Found {len(commits)} commits since {since}") version = resolve_version(args.version) body = build_changelog_body(commits, version) diff --git a/test_generate_changelog.py b/test_generate_changelog.py index c6a2b7b2..81329528 100644 --- a/test_generate_changelog.py +++ b/test_generate_changelog.py @@ -26,17 +26,22 @@ def _commit(subject, body="", files=None, diff="", pr_numbers=None): class ReleasePlumbingTests(unittest.TestCase): - def test_matches_gradle_release_and_snapshot(self): + def test_matches_gradle_release_plugin_commits(self): for msg in ( "[Gradle Release Plugin] - new version commit: '5.1.1-SNAPSHOT'.", "[Gradle Release Plugin] - pre tag commit: '5.1.0'.", - "bump to 6.0.0-SNAPSHOT", ): self.assertIsNotNone(gc.RELEASE_PLUMBING_RE.search(msg), msg) def test_ignores_real_commits(self): self.assertIsNone(gc.RELEASE_PLUMBING_RE.search("feat: add StepUpService SPI")) + def test_does_not_drop_legit_commit_mentioning_snapshot(self): + # A bare "-SNAPSHOT" mention must not be treated as release plumbing. + self.assertIsNone( + gc.RELEASE_PLUMBING_RE.search("chore: verify build against 6.0.0-SNAPSHOT") + ) + class ParseStatFilesTests(unittest.TestCase): def test_parses_paths_and_renames(self): @@ -134,8 +139,7 @@ def test_compose_inserts_below_title(self): self.assertIn("### Security", result) 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. + # A hand-written 5.1.0 section already exists; composing a fresh 5.1.0 replaces it. head, sections = gc.split_changelog( "# Changelog\n\n## [5.1.0] - 2026-07-01\n### Features\n- hand draft\n\n" "## [5.0.1] - 2026-06-15\n### Fixes\n- old\n\n" @@ -271,6 +275,42 @@ def test_skip_ai_does_not_fetch_pr_bodies(self): gc.build_changelog_body(commits, "1.0.0") fetch.assert_not_called() + def test_ai_failure_falls_back_to_deterministic(self): + # The headline contract: an AI-path failure degrades to the offline generator. + commits = [_commit("feat: something")] + env = {"CHANGELOG_SKIP_AI": "", "OPENAI_API_TOKEN": "test-token"} + with mock.patch.dict(os.environ, env, clear=False), mock.patch.object( + gc, "fetch_pr_bodies" + ), mock.patch.object(gc, "generate_ai_changelog", side_effect=RuntimeError("api down")): + body = gc.build_changelog_body(commits, "1.0.0") + self.assertIn("generated offline", body) + self.assertIn("### Features", body) + self.assertIn("something", body) + + def test_no_tags_uses_full_history_range(self): + # With no tags (last_ref is None), the git log range must be HEAD (includes root). + calls = [] + + def fake_git(args): + calls.append(args) + return "" # empty log -> no commits, short-circuits + + with mock.patch.object(gc, "_git", side_effect=fake_git): + gc.get_commits(None) + log_args = calls[0] + self.assertIn("HEAD", log_args) + self.assertNotIn("..HEAD", " ".join(log_args)) + + def test_pr_fetch_capped(self): + commits = [_commit(f"feat: x{i}", pr_numbers=[i]) for i in range(5)] + with mock.patch.dict(os.environ, {}, clear=False), mock.patch.object( + gc, "MAX_PR_FETCHES", 2 + ), mock.patch("shutil.which", return_value="/usr/bin/gh"), mock.patch.object( + gc, "_fetch_single_pr", return_value=None + ) as fetch: + gc.fetch_pr_bodies(commits) + self.assertEqual(fetch.call_count, 2) + if __name__ == "__main__": unittest.main()