Files
VoiceStudio/backend/core/changelog.py
T
16294fed44 feat(updates): data-safe updates — pre-migration DB backups, guarded venv heal, release notes + changelog reader (#909)
Backend:
- core/db_backup.py: WAL-safe SQLite snapshot to omnivoice.db.backup-<version>-<n>
  before pending alembic migrations run; keep newest 3, prune older; skip >500MB
  with a log line. Restore is never automatic.
- core/db.py: _run_alembic_upgrade now plans the run (up_to_date / pending /
  unknown_revision), snapshots first when migrations will execute, and raises
  MigrationError on a mid-flight failure — startup stops with the backup path
  named instead of continuing on a half-migrated DB. The #552/#547
  unknown-revision class stays non-fatal (warn + additive reconcile).
- core/changelog.py + GET /api/settings/changelog: parse the shipped
  CHANGELOG.md (single-line and wrapped bullet styles) into structured releases.
- GET /api/settings/db-backup: newest pre-migration backup for the panel.

Rust (bootstrap.rs):
- #314 heal guard: an exit-signature match alone can no longer delete the venv —
  venv_rebuild_justified requires a structural problem or a failed direct
  interpreter probe; a venv that probes healthy is kept and the real error
  surfaced. Drift/repair remains in-place `uv sync` (non-destructive).
- CHANGELOG.md now ships as a bundle resource and is copied/refreshed into the
  project dir so the changelog endpoint works in packaged installs.

Frontend (Settings → Updates):
- Available update shows its actual release notes (updater metadata body)
  through a safe markdown-lite renderer (text nodes only, refs stay plain).
- "Your data is backed up before every update" line with the latest backup
  timestamp from the new endpoint.
- "What's new" changelog reader (accordion, newest expanded) over the shipped
  CHANGELOG.md; GitHub releases list reuses the same renderer.
- One-time, non-blocking "What's new" footer pill after an update
  (persisted last-seen version; fresh installs baseline silently).
- All strings via t() with en keys (other locales fall back to English).

Tests: db backup/rotation/failure-path units, migration-safety units, changelog
parser (both bullet styles + real CHANGELOG.md), endpoint tests, route
inventory regenerated, Rust decision-logic + probe tests, vitest suites for
renderer/viewer/panel/pill logic.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:52:17 +05:30

139 lines
5.1 KiB
Python

"""Parse the shipped CHANGELOG.md into structured release notes.
Feeds ``GET /api/settings/changelog`` — the Settings → Updates "What's new"
viewer. Local-first by design: the changelog ships with the app (repo root in
dev; copied into the packaged project dir by the Tauri bootstrap alongside
README.md), so the viewer works fully offline.
The house format (see CHANGELOG.md / the release-notes hard rule):
## [X.Y.Z] — DATE
one-paragraph headline (the "intro")
### Added / Fixed / Changed / ...
- **Bold one-line lead.** 1-3 lines of plain-English why. (#NNN)
Bullets may be a single long line (recent sections) *or* hard-wrapped across
indented continuation lines (older sections) — the parser normalizes both to
one logical line per bullet. Bullets stay raw markdown-lite; the frontend's
safe renderer handles **bold** / `code` / (#NNN) refs.
"""
from __future__ import annotations
import os
import re
#: ``## [0.3.9] — 2026-07-02`` (em/en dash or hyphen; date optional).
_RELEASE_RE = re.compile(r"^##\s+\[(?P<version>[^\]]+)\]\s*(?:[—–-]\s*(?P<date>.+?))?\s*$")
_SECTION_RE = re.compile(r"^###\s+(?P<title>.+?)\s*$")
_BULLET_RE = re.compile(r"^\s*[-*]\s+(?P<text>.*\S)\s*$")
def changelog_path() -> str | None:
"""The shipped CHANGELOG.md, or None when this install doesn't have one.
``backend/core/changelog.py`` → two levels up is the project root: the
repo root in dev, and ``<env>/project`` in packaged installs (where the
bootstrap copies CHANGELOG.md next to README.md). ``OMNIVOICE_CHANGELOG``
overrides for tests/containers.
"""
override = os.environ.get("OMNIVOICE_CHANGELOG")
if override:
return override if os.path.isfile(override) else None
here = os.path.dirname(os.path.abspath(__file__))
candidate = os.path.join(os.path.dirname(os.path.dirname(here)), "CHANGELOG.md")
return candidate if os.path.isfile(candidate) else None
def _looks_like_release_version(version: str) -> bool:
"""Only released ``X.Y.Z...`` sections (skip ``[Unreleased]`` etc.)."""
return bool(re.match(r"^v?\d", version.strip()))
def parse_changelog(text: str, limit_versions: int = 5) -> list[dict]:
"""CHANGELOG.md text → newest-first list of releases::
{"version": "0.3.9", "date": "2026-07-02", "intro": "…",
"sections": [{"title": "Fixed", "bullets": ["…", …]}, …]}
Tolerates both single-line bullets and older hard-wrapped bullets
(continuation lines are joined with a space). Content between the version
heading and the first ``###`` becomes ``intro`` (paragraphs joined by
blank lines).
"""
releases: list[dict] = []
release: dict | None = None
section: dict | None = None
intro_parts: list[str] = []
bullet_open = False # last bullet may still absorb continuation lines
intro_new_para = True
def close_release():
nonlocal release, section, intro_parts, bullet_open, intro_new_para
if release is not None:
release["intro"] = "\n\n".join(p for p in intro_parts if p)
release["sections"] = [s for s in release["sections"] if s["bullets"]]
releases.append(release)
release = None
section = None
intro_parts = []
bullet_open = False
intro_new_para = True
for raw in text.splitlines():
m = _RELEASE_RE.match(raw)
if m:
close_release()
if len(releases) >= limit_versions:
break
version = m.group("version").strip().lstrip("v")
if not _looks_like_release_version(version):
continue # e.g. [Unreleased] — skip until the next heading
release = {
"version": version,
"date": (m.group("date") or "").strip(),
"intro": "",
"sections": [],
}
continue
if release is None:
continue
line = raw.strip()
if not line:
bullet_open = False
intro_new_para = True
continue
sm = _SECTION_RE.match(raw)
if sm:
section = {"title": sm.group("title"), "bullets": []}
release["sections"].append(section)
bullet_open = False
continue
bm = _BULLET_RE.match(raw)
if bm:
if section is None:
# Rare: a bullet before any ### heading — group it untitled.
section = {"title": "", "bullets": []}
release["sections"].append(section)
section["bullets"].append(bm.group("text"))
bullet_open = True
continue
if section is not None:
if bullet_open and section["bullets"]:
# Hard-wrapped bullet continuation (older sections) → join.
section["bullets"][-1] += " " + line
continue
# Headline paragraph(s) before the first ### section.
if intro_new_para or not intro_parts:
intro_parts.append(line)
else:
intro_parts[-1] += " " + line
intro_new_para = False
close_release()
return releases[:limit_versions]