diff --git a/app.py b/app.py index 13c406e9..b6b50db1 100644 --- a/app.py +++ b/app.py @@ -35,6 +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.exceptions import ( ValidationError, ResourceNotFoundError, @@ -1319,23 +1320,19 @@ def run_git_command(args: list[str]) -> str: commit_author = await asyncio.to_thread(run_git_command, ['log', '-1', '--format=%an']) branch = await asyncio.to_thread(run_git_command, ['rev-parse', '--abbrev-ref', 'HEAD']) - # Get changelog (last 10 commits) - changelog_raw = await asyncio.to_thread( + # Changelog: what landed in this version, one merge at a time. The + # first-parent log gives a line per branch that landed rather than per + # development step; visualex_api/tools/changelog.py drops the rest. + version_file_log = await asyncio.to_thread( run_git_command, - ['log', '-10', '--format=%h|%s|%ci|%an'] + ['log', '-n', '2', '--format=%H', '--', 'version.txt'] ) - changelog = [] - if changelog_raw: - for line in changelog_raw.split('\n'): - if line and '|' in line: - parts = line.split('|', 3) - if len(parts) >= 4: - changelog.append({ - 'hash': parts[0], - 'message': parts[1], - 'date': parts[2], - 'author': parts[3] - }) + 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/docs/backend/python_api_reference.md b/docs/backend/python_api_reference.md index 275a3ef4..58f8c761 100644 --- a/docs/backend/python_api_reference.md +++ b/docs/backend/python_api_reference.md @@ -549,7 +549,10 @@ Get application version and git information. "changelog": [ { "hash": "abc1234", - "message": "feat: Add new feature", + "message": "feat(reading): Add new feature", + "summary": "Add new feature", + "type": "feat", + "scope": "reading", "date": "2024-01-15T10:30:45Z", "author": "Developer Name" } @@ -557,7 +560,14 @@ Get application version and git information. } ``` -**Notes:** Changelog contains last 10 commits. +**Notes:** The changelog is what landed in the current version, read from the +first-parent log so each entry is a branch that landed rather than a development +step. `visualex_api/tools/changelog.py` drops reverted work (a revert cancels +the merge it undid, and with it everything that merge brought in), housekeeping +types (`build/chore/ci/docs/refactor/style/test`) and toolchain scopes, then +caps the list at 20 entries. `message` is the raw commit subject; `summary`, +`type` and `scope` are its parsed parts, `type` and `scope` null when the +subject carries no conventional prefix. --- diff --git a/docs/deployment.md b/docs/deployment.md index dbaf043f..749ba914 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -95,7 +95,7 @@ cd backend && npm test cd frontend && npm run build && npx vitest run ``` -Expected today: 355 Python (1 deselected — the `live` marker), 28 backend, 84 frontend. +Expected today: 400 Python (1 deselected — the `live` marker), 35 backend, 192 frontend. The backend suite needs `backend/.env.test` pointing at a **separate** database (`visualex_test`, not `visualex_platform`) — it runs `prisma migrate reset` on diff --git a/frontend/src/components/ui/ChangelogNotification.test.tsx b/frontend/src/components/ui/ChangelogNotification.test.tsx new file mode 100644 index 00000000..2c344a9f --- /dev/null +++ b/frontend/src/components/ui/ChangelogNotification.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ChangelogContent } from './ChangelogNotification'; +import type { ChangelogEntry } from '../../config/versionConfig'; + +const entry = (over: Partial = {}): ChangelogEntry => ({ + hash: 'd3bb80b1', + message: 'fix(sidebar): lift hover tooltips above page content', + summary: 'lift hover tooltips above page content', + type: 'fix', + scope: 'sidebar', + date: '2026-08-20 10:00:00 +0200', + author: 'capazme', + ...over, +}); + +describe('ChangelogContent', () => { + it('shows a change without its conventional-commit prefix', () => { + render(); + + expect(screen.getByText('lift hover tooltips above page content')).toBeInTheDocument(); + expect(screen.queryByText(/fix\(sidebar\):/)).not.toBeInTheDocument(); + }); + + it('labels a change with the area it touched', () => { + render(); + + expect(screen.getByText('sidebar')).toBeInTheDocument(); + }); + + it('leaves out the commit hash and the author', () => { + render(); + + expect(screen.queryByText(/d3bb80b/)).not.toBeInTheDocument(); + expect(screen.queryByText(/capazme/)).not.toBeInTheDocument(); + }); + + it('falls back to the raw message when an older server sends no summary', () => { + render( + + ); + + expect( + screen.getByText('fix(sidebar): lift hover tooltips above page content') + ).toBeInTheDocument(); + }); + + it('reports an empty changelog instead of rendering nothing', () => { + render(); + + expect(screen.getByText('Nessun changelog disponibile')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/ChangelogNotification.tsx b/frontend/src/components/ui/ChangelogNotification.tsx index a540bf19..78543bd9 100644 --- a/frontend/src/components/ui/ChangelogNotification.tsx +++ b/frontend/src/components/ui/ChangelogNotification.tsx @@ -1,5 +1,5 @@ import { motion, AnimatePresence } from 'framer-motion'; -import { Sparkles, X, GitCommit, Calendar, User } from 'lucide-react'; +import { Sparkles, X, Calendar } from 'lucide-react'; import { useVersionCheck } from '../../hooks/useVersionCheck'; import { Modal } from './Modal'; import { cn } from '../../lib/utils'; @@ -52,14 +52,14 @@ export function ChangelogNotification() { className={cn( 'fixed z-[60] flex items-center gap-3 px-4 py-3 rounded-xl border max-w-[90vw] md:max-w-md', 'bottom-4 left-1/2 -translate-x-1/2 md:left-auto md:translate-x-0 md:bottom-8 md:right-8', - 'bg-white dark:bg-slate-900 border-primary-200 dark:border-primary-900/30', - 'text-slate-800 dark:text-slate-100 shadow-lg shadow-primary-500/10' + 'bg-white dark:bg-slate-900 border-blue-200 dark:border-blue-900/30', + 'text-slate-800 dark:text-slate-100 shadow-lg shadow-blue-500/10' )} role="alert" > {/* Icon */} -
- +
+
{/* Content */} @@ -75,7 +75,7 @@ export function ChangelogNotification() { onClick={handleViewChangelog} className={cn( 'shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium', - 'bg-primary-500 hover:bg-primary-600 text-white', + 'bg-blue-500 hover:bg-blue-600 text-white', 'transition-colors' )} > @@ -111,9 +111,36 @@ export function ChangelogNotification() { } /** - * Changelog content for the modal + * Italian label for the kinds of change worth naming. Anything else - a merge + * that landed a branch, a subject with no prefix - is shown without a label: + * the summary already says what happened. */ -function ChangelogContent({ changelog }: { changelog: ChangelogEntry[] }) { +const TYPE_BADGES: Record = { + feat: { + label: 'Novità', + className: 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300', + }, + fix: { + label: 'Correzione', + className: 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300', + }, + perf: { + label: 'Prestazioni', + className: 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300', + }, +}; + +/** + * Changelog content for the modal. + * + * The server hands over what landed in this version, already stripped of + * housekeeping and reverted work (visualex_api/tools/changelog.py). Here we + * only render it: no hashes, no author - this is the reader's changelog, not + * the technical panel in Impostazioni. + * + * Exported for its own test. + */ +export function ChangelogContent({ changelog }: { changelog: ChangelogEntry[] }) { if (!changelog || changelog.length === 0) { return (

@@ -136,68 +163,54 @@ function ChangelogContent({ changelog }: { changelog: ChangelogEntry[] }) { } }; - const formatMessage = (message: string) => { - // Highlight conventional commit prefixes - const prefixMatch = message.match(/^(feat|fix|docs|style|refactor|perf|test|chore|build|ci)(\(.+?\))?:/i); - if (prefixMatch) { - const prefix = prefixMatch[0]; - const rest = message.slice(prefix.length).trim(); - return ( - <> - - {prefix} - {' '} - {rest} - - ); - } - return message; - }; - return (

- Ultimi {changelog.length} aggiornamenti: + Le modifiche entrate in questa versione:

- {changelog.map((entry, index) => ( -
- {/* Message */} -

- {formatMessage(entry.message)} -

- - {/* Meta */} -
- {entry.hash && ( - - - {entry.hash.slice(0, 7)} - - )} - {entry.date && ( - - - {formatDate(entry.date)} - - )} - {entry.author && entry.author !== 'unknown' && ( - - - {entry.author} - + {changelog.map((entry, index) => { + const badge = entry.type ? TYPE_BADGES[entry.type] : undefined; + + return ( +
+ {/* What changed */} +

+ {badge && ( + + {badge.label} + + )} + {entry.summary || entry.message} +

+ + {/* Where, and when */} +
+ {entry.scope && {entry.scope}} + {entry.date && ( + + + {formatDate(entry.date)} + + )} +
-
- ))} + ); + })}
); diff --git a/frontend/src/config/versionConfig.ts b/frontend/src/config/versionConfig.ts index 828fbdee..cc9df47e 100644 --- a/frontend/src/config/versionConfig.ts +++ b/frontend/src/config/versionConfig.ts @@ -22,9 +22,16 @@ export const VERSION_FETCH_TIMEOUT = 5000; */ export interface ChangelogEntry { hash: string; + /** Raw commit subject, prefix included. Shown only in the technical panel. */ message: string; date: string; author: string; + /** Conventional-commit type, when the subject carries one. */ + type?: string | null; + /** Conventional-commit scope: the area of the app the change touched. */ + scope?: string | null; + /** The subject without its prefix. Absent from servers older than 1.4. */ + summary?: string; } /** diff --git a/tests/test_changelog.py b/tests/test_changelog.py new file mode 100644 index 00000000..62001ca4 --- /dev/null +++ b/tests/test_changelog.py @@ -0,0 +1,140 @@ +"""Tests for the user-facing changelog built from the git first-parent log.""" + +from visualex_api.tools.changelog import build_changelog, changelog_range + +DATE = "2026-08-20 10:00:00 +0200" + + +def line(commit_hash: str, subject: str) -> str: + return f"{commit_hash}|{subject}|{DATE}|capazme" + + +def subjects(entries): + return [e["summary"] for e in entries] + + +def test_parses_a_log_line_into_its_fields(): + entries = build_changelog(line("abc1234", "feat(reading): inline case law")) + + assert len(entries) == 1 + entry = entries[0] + assert entry["hash"] == "abc1234" + assert entry["message"] == "feat(reading): inline case law" + assert entry["summary"] == "inline case law" + assert entry["type"] == "feat" + assert entry["scope"] == "reading" + assert entry["date"] == DATE + assert entry["author"] == "capazme" + + +def test_a_summary_without_a_prefix_keeps_the_whole_subject(): + entries = build_changelog(line("abc1234", "primo push API")) + + assert entries[0]["summary"] == "primo push API" + assert entries[0]["type"] is None + assert entries[0]["scope"] is None + + +def test_drops_a_revert_together_with_the_work_it_undid(): + raw = "\n".join([ + line("0845090", 'Revert "merge: live case law from four courts"'), + line("b849acc", "merge: live case law from four courts"), + line("d3bb80b", "merge: accessible names for the sidebar controls"), + ]) + + assert subjects(build_changelog(raw)) == ["accessible names for the sidebar controls"] + + +def test_a_revert_of_a_revert_restores_the_work(): + raw = "\n".join([ + line("aaaaaaa", 'Revert "Revert "merge: live case law""'), + line("0845090", 'Revert "merge: live case law"'), + line("b849acc", "merge: live case law"), + ]) + + assert subjects(build_changelog(raw)) == ["live case law"] + + +def test_drops_housekeeping_commit_types(): + raw = "\n".join([ + line("1111111", "chore: bump version to 1.3.0"), + line("2222222", "docs: implementation plan for the case-law backend"), + line("3333333", "ci: run the Node jobs on 24"), + line("4444444", "test(palette): give the second wait the same budget"), + line("5555555", "build: drop the unused rollup plugin"), + line("6666666", "style: reformat the store"), + line("7777777", "refactor(store): split the tab actions"), + line("8888888", "fix(sidebar): lift hover tooltips above page content"), + ]) + + assert subjects(build_changelog(raw)) == ["lift hover tooltips above page content"] + + +def test_drops_fixes_scoped_to_the_toolchain(): + raw = "\n".join([ + line("1111111", "fix(tests): skip live tests on source unreachability"), + line("2222222", "fix(deploy): stop each deploy from breaking the next one"), + line("3333333", "fix(palette): reach the act resolver instead of a stale copy"), + ]) + + assert subjects(build_changelog(raw)) == [ + "reach the act resolver instead of a stale copy" + ] + + +def test_drops_a_bare_branch_merge_that_names_no_change(): + raw = "\n".join([ + line("4e8e739", "Merge branch 'claude/reading-navigation-round2a' into main"), + line("1234567", "Merge pull request #12 from capazme/fix-dates"), + line("f72f945", "merge: case law as an inline section"), + ]) + + assert subjects(build_changelog(raw)) == ["case law as an inline section"] + + +def test_a_descriptive_merge_keeps_its_subject_and_carries_no_scope(): + entries = build_changelog(line("d3bb80b", "merge: accessible names for the sidebar")) + + assert entries[0]["summary"] == "accessible names for the sidebar" + assert entries[0]["type"] == "merge" + assert entries[0]["scope"] is None + + +def test_a_breaking_change_marker_does_not_hide_the_summary(): + entries = build_changelog(line("abc1234", "feat(api)!: drop the legacy route")) + + assert entries[0]["summary"] == "drop the legacy route" + assert entries[0]["type"] == "feat" + assert entries[0]["scope"] == "api" + + +def test_keeps_only_the_most_recent_entries_up_to_the_limit(): + raw = "\n".join(line(f"{i:07d}", f"fix: change number {i}") for i in range(5)) + + assert subjects(build_changelog(raw, limit=2)) == ["change number 0", "change number 1"] + + +def test_ignores_malformed_and_empty_lines(): + raw = "\n".join([ + "", + "not-a-log-line", + "abc1234|missing the trailing fields", + line("d3bb80b", "fix: a real one"), + ]) + + assert subjects(build_changelog(raw)) == ["a real one"] + + +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" + + assert changelog_range(version_log) == "79b4d1dbbb..HEAD" + + +def test_a_first_ever_release_has_no_earlier_bump_to_start_from(): + assert changelog_range("2be0468aaa") is None + assert changelog_range("") is None diff --git a/visualex_api/tools/changelog.py b/visualex_api/tools/changelog.py new file mode 100644 index 00000000..88694adc --- /dev/null +++ b/visualex_api/tools/changelog.py @@ -0,0 +1,113 @@ +"""The user-facing changelog, distilled from git's first-parent log. + +`GET /version` used to hand the UI a raw `git log`: one line per development +step, bump commits and reverts included. Work lands on `main` through a merge +whose subject already summarises the whole branch, so the first-parent log is +the honest unit of "what changed" — and everything this module drops (reverted +work, housekeeping, toolchain fixes) is noise the reader cannot act on. + +The functions here are pure: they take git's output as text so the rules can be +tested without a repository. +""" + +from __future__ import annotations + +import re +from typing import Any, Optional + +# Commit types that describe work on the codebase, not a change to the product. +NOISE_TYPES = frozenset({'build', 'chore', 'ci', 'docs', 'refactor', 'style', 'test'}) + +# Scopes that mark a change to the toolchain, whatever the commit type says. +NOISE_SCOPES = frozenset({'ci', 'claude-md', 'deploy', 'deps', 'plan', 'test', 'tests'}) + +# How many entries the UI is willing to show. +DEFAULT_LIMIT = 20 + +# How far back to read before filtering, so a revert still finds its target. +SCAN_LIMIT = 200 + +_CONVENTIONAL = re.compile( + r'^(?P[a-z]+)(?:\((?P[^)]+)\))?!?:\s*(?P.+)$' +) + +# `git revert` writes the reverted subject back into its own, quoted. +_REVERT = re.compile(r'^Revert "(?P.+)"$') + +# A merge git named for us carries the branch, not the change it brought in. +_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]]: + """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. + """ + parsed = [entry for entry in (_parse_line(line) for line in raw_log.splitlines()) if entry] + + entries: list[dict[str, Any]] = [] + pending_reverts: list[str] = [] + for entry in parsed: + subject = entry['message'] + + if subject in pending_reverts: + pending_reverts.remove(subject) + continue + + revert = _REVERT.match(subject) + if revert: + pending_reverts.append(revert.group('subject')) + continue + + if _BARE_MERGE.match(subject): + continue + + if _is_noise(entry): + continue + + entries.append(entry) + if len(entries) == limit: + break + + return entries + + +def changelog_range(version_file_log: str) -> Optional[str]: + """The commit range holding the current version's changes. + + 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. + """ + bumps = [line.strip() for line in version_file_log.splitlines() if line.strip()] + if len(bumps) < 2: + return None + return f'{bumps[1]}..HEAD' + + +def _parse_line(line: str) -> Optional[dict[str, Any]]: + parts = line.split('|', 3) + if len(parts) < 4: + return None + + commit_hash, subject, date, author = (part.strip() for part in parts) + if not commit_hash or not subject: + return None + + conventional = _CONVENTIONAL.match(subject) + return { + 'hash': commit_hash, + 'message': subject, + 'date': date, + 'author': author, + 'type': conventional.group('type') if conventional else None, + 'scope': conventional.group('scope') if conventional else None, + 'summary': conventional.group('summary') if conventional else subject, + } + + +def _is_noise(entry: dict[str, Any]) -> bool: + return entry['type'] in NOISE_TYPES or entry['scope'] in NOISE_SCOPES