Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 47 additions & 7 deletions tests/test_changelog.py
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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
51 changes: 43 additions & 8 deletions visualex_api/tools/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]]:
Expand Down
Loading