From 68cb97142caf0a4e546da1f429cd9953a66ac533 Mon Sep 17 00:00:00 2001 From: capazme Date: Sat, 29 Aug 2026 16:57:22 +0200 Subject: [PATCH] fix(changelog): fall back when the version window holds only bumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window was verified against a repository whose history does not have production's shape. deploy.sh commits each version bump and never pushes it, so every `git pull -r` rebases those commits forward: the server now carries twenty consecutive `chore: bump version to X` commits stacked above the real work. The two most recent bumps are therefore adjacent, the window between them holds nothing but a bump, and the filter drops it as housekeeping. Measured against the server's own log: 0 entries. The feature would have shipped and shown "Nessun changelog disponibile". When the window yields nothing, use the whole scan instead — the most recent work is a better answer than silence. Windowing now happens over the parsed log rather than through a git revision range, so the fallback costs no extra call, and `changelog_range` becomes `changelog_boundary`. Same log, with the fallback: 20 entries. Co-Authored-By: Claude Opus 5 --- app.py | 18 ++++++----- tests/test_changelog.py | 54 ++++++++++++++++++++++++++++----- visualex_api/tools/changelog.py | 51 ++++++++++++++++++++++++++----- 3 files changed, 100 insertions(+), 23 deletions(-) diff --git a/app.py b/app.py index b6b50db..27f7ffa 100644 --- a/app.py +++ b/app.py @@ -35,7 +35,7 @@ from visualex_api.tools.nl_parser import parse_nl_query from visualex_api.tools.alias_resolver import resolve_alias from visualex_api.tools.citation_linker import extract_citations as extract_citations_from_text -from visualex_api.tools.changelog import build_changelog, changelog_range, SCAN_LIMIT +from visualex_api.tools.changelog import build_changelog, changelog_boundary, SCAN_LIMIT from visualex_api.tools.exceptions import ( ValidationError, ResourceNotFoundError, @@ -1325,14 +1325,16 @@ def run_git_command(args: list[str]) -> str: # development step; visualex_api/tools/changelog.py drops the rest. version_file_log = await asyncio.to_thread( run_git_command, - ['log', '-n', '2', '--format=%H', '--', 'version.txt'] + ['log', '-n', '2', '--format=%h', '--', 'version.txt'] + ) + changelog_raw = await asyncio.to_thread( + run_git_command, + ['log', '--first-parent', '--format=%h|%s|%ci|%an', '-n', str(SCAN_LIMIT)] + ) + changelog = build_changelog( + changelog_raw, + boundary=changelog_boundary(version_file_log), ) - log_args = ['log', '--first-parent', '--format=%h|%s|%ci|%an', '-n', str(SCAN_LIMIT)] - commit_range = changelog_range(version_file_log) - if commit_range: - log_args.append(commit_range) - changelog_raw = await asyncio.to_thread(run_git_command, log_args) - changelog = build_changelog(changelog_raw) return jsonify({ 'version': version, diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 62001ca..d395be1 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1,6 +1,6 @@ """Tests for the user-facing changelog built from the git first-parent log.""" -from visualex_api.tools.changelog import build_changelog, changelog_range +from visualex_api.tools.changelog import build_changelog, changelog_boundary DATE = "2026-08-20 10:00:00 +0200" @@ -129,12 +129,52 @@ def test_no_output_from_git_yields_no_entries(): assert build_changelog("") == [] -def test_the_window_starts_at_the_bump_before_the_current_one(): - version_log = "2be0468aaa\n79b4d1dbbb\ncf93c8eccc" +def test_the_window_keeps_only_what_landed_after_the_previous_bump(): + raw = "\n".join([ + line("aaaaaaa", "merge: the newest thing"), + line("bbbbbbb", "chore: bump version to 1.4.0"), + line("ccccccc", "merge: shipped in 1.4.0"), + line("ddddddd", "chore: bump version to 1.3.0"), + line("eeeeeee", "merge: ancient history"), + ]) + + assert subjects(build_changelog(raw, boundary="ddddddd")) == [ + "the newest thing", + "shipped in 1.4.0", + ] + + +def test_a_window_holding_nothing_but_bumps_falls_back_to_the_whole_log(): + # The production server never pushes its bump commits, so every deploy + # rebases them forward and they end up stacked above the real work. The + # two most recent bumps are then adjacent, and the window between them is + # empty. Showing "no changelog" there would be a worse answer than showing + # the most recent work. + raw = "\n".join([ + line("aaaaaaa", "chore: bump version to 1.6.3"), + line("bbbbbbb", "chore: bump version to 1.6.2"), + line("ccccccc", "chore: bump version to 1.6.1"), + line("ddddddd", "merge: the last real thing that happened"), + ]) + + assert subjects(build_changelog(raw, boundary="bbbbbbb")) == [ + "the last real thing that happened" + ] + + +def test_a_boundary_older_than_the_scan_does_not_truncate_anything(): + raw = "\n".join([ + line("aaaaaaa", "merge: one"), + line("bbbbbbb", "merge: two"), + ]) + + assert subjects(build_changelog(raw, boundary="not-in-the-log")) == ["one", "two"] + - assert changelog_range(version_log) == "79b4d1dbbb..HEAD" +def test_the_boundary_is_the_bump_before_the_current_one(): + assert changelog_boundary("2be0468\n79b4d1d\ncf93c8e") == "79b4d1d" -def test_a_first_ever_release_has_no_earlier_bump_to_start_from(): - assert changelog_range("2be0468aaa") is None - assert changelog_range("") is None +def test_a_first_ever_release_has_no_boundary(): + assert changelog_boundary("2be0468") is None + assert changelog_boundary("") is None diff --git a/visualex_api/tools/changelog.py b/visualex_api/tools/changelog.py index 88694ad..f07aeaf 100644 --- a/visualex_api/tools/changelog.py +++ b/visualex_api/tools/changelog.py @@ -38,15 +38,50 @@ _BARE_MERGE = re.compile(r"^Merge (branch|pull request|remote-tracking) ") -def build_changelog(raw_log: str, limit: int = DEFAULT_LIMIT) -> list[dict[str, Any]]: +def build_changelog( + raw_log: str, + boundary: Optional[str] = None, + limit: int = DEFAULT_LIMIT, +) -> list[dict[str, Any]]: """Turn `git log --first-parent --format=%h|%s|%ci|%an` into shown entries. Expects git's own order, newest first, which is what the revert pairing below reads: a revert cancels the nearest older commit with that subject, so a revert of a revert leaves the original work standing. + + `boundary` is the commit the current version started from, from + `changelog_boundary()`. Everything at or below it is another version's + news. When that window turns out to hold nothing worth showing, the whole + log is used instead — see the fallback below for why that happens in + production. """ parsed = [entry for entry in (_parse_line(line) for line in raw_log.splitlines()) if entry] + entries = _select(_window(parsed, boundary), limit) + if not entries and boundary is not None: + # The window can be empty through no fault of the log. The production + # server commits each version bump but never pushes it, so every + # `git pull -r` rebases those commits forward and they end up stacked + # above the real work — twenty of them, at the time of writing. The two + # most recent bumps are then adjacent and nothing sits between them. + # Showing the most recent work beats answering "nothing changed". + entries = _select(parsed, limit) + return entries + + +def _window( + entries: list[dict[str, Any]], boundary: Optional[str] +) -> list[dict[str, Any]]: + """Everything newer than `boundary`. An unknown boundary truncates nothing.""" + if not boundary: + return entries + for index, entry in enumerate(entries): + if entry['hash'] == boundary: + return entries[:index] + return entries + + +def _select(parsed: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]: entries: list[dict[str, Any]] = [] pending_reverts: list[str] = [] for entry in parsed: @@ -74,18 +109,18 @@ def build_changelog(raw_log: str, limit: int = DEFAULT_LIMIT) -> list[dict[str, return entries -def changelog_range(version_file_log: str) -> Optional[str]: - """The commit range holding the current version's changes. +def changelog_boundary(version_file_log: str) -> Optional[str]: + """Where the current version's news starts. - Takes `git log -n 2 --format=%H -- version.txt`. The deploy script stamps - `version.txt` after building, so everything between the previous stamp and - HEAD is what this version brought. Returns None before a second release - exists, leaving the caller to fall back to a plain window of recent commits. + Takes `git log -n 2 --format=%h -- version.txt`. The deploy script stamps + `version.txt` after building, so everything above the previous stamp is what + this version brought. Returns None before a second release exists, which + leaves the changelog unwindowed. """ bumps = [line.strip() for line in version_file_log.splitlines() if line.strip()] if len(bumps) < 2: return None - return f'{bumps[1]}..HEAD' + return bumps[1] def _parse_line(line: str) -> Optional[dict[str, Any]]: