Files
bruno c9e3240ae2
CI / lint (push) Successful in 1m28s
CI / security (push) Successful in 58s
CI / test (push) Successful in 2m9s
CI / build (push) Successful in 1m3s
CI / e2e (push) Successful in 11m12s
feat(assistant): #94-#97 historique permanent, sidebar IA, panneau + et bouton rond
2026-09-16 14:56:45 -04:00

578 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""ObsiGate — outil de versionnage SemVer (source unique de vérité : `VERSION`).
Le fichier `VERSION` (racine du dépôt) contient la version **livrée** au format
`MAJEUR.MINEUR.CORRECTIF`. C'est la seule source de vérité : tout le reste en
découle (backend `backend/version.py`, image Docker, desktop Tauri, `package.json`,
`CHANGELOG.md`, `docs/ROADMAP.md`).
Incrément (Conventional Commits) :
`!:` ou `BREAKING CHANGE:` -> MAJEUR x.0.0
`feat:` -> MINEUR x.y.0
tout le reste (fix, perf…) -> CORRECTIF x.y.z
Usage :
scripts/bump_version.py # bump auto (commits depuis le dernier tag)
scripts/bump_version.py --from-message-file .git/COMMIT_EDITMSG --stage
scripts/bump_version.py --minor --dry-run
scripts/bump_version.py --set 3.0.0
scripts/bump_version.py --print-version
scripts/bump_version.py --major --commit --tag --push
Options :
--bump {auto,major,minor,patch} incrément explicite
--major | --minor | --patch raccourcis de --bump
--set X.Y.Z fixe la version (sans incrément)
--from-message-file PATH détecte l'incrément depuis un message de commit
--message TEXT détecte l'incrément depuis un message donné
--dry-run affiche sans rien écrire
--sync / --no-files synchroniser (ou non) les fichiers dérivés
--changelog / --no-changelog rotation de CHANGELOG.md (ou non)
--stage `git add` des fichiers modifiés (utilisé par le hook)
--commit crée un commit `chore(release): vX.Y.Z`
--tag crée le tag annoté `vX.Y.Z` s'il n'existe pas
--push pousse la branche et les tags (implique --tag)
--print-version affiche la version courante et sort
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from datetime import date
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
VERSION_FILE = "VERSION" # source unique de vérité
LEGACY_VERSION_FILES = ("backend/VERSION",) # anciens emplacements (repli)
PACKAGE_JSON = "package.json"
ROADMAP = "docs/ROADMAP.md"
CHANGELOG = "CHANGELOG.md"
DESKTOP_CONF = "desktop/tauri.conf.json"
DESKTOP_CARGO = "desktop/Cargo.toml"
DESKTOP_LOCK = "desktop/Cargo.lock"
BUMP_KINDS = ("major", "minor", "patch")
# ── Version ────────────────────────────────────────────────────────────────
def parse_version(raw: str) -> tuple[int, int, int] | None:
"""Extrait (majeur, mineur, correctif) d'une chaîne quelconque.
Accepte `2.3.0`, `v2.3.0`, `2.3.0-dev`, `2.2.1-35-g314e37c`.
Retourne ``None`` si aucun triplet numérique n'est présent.
"""
match = re.search(r"(\d+)\.(\d+)\.(\d+)", str(raw or ""))
if not match:
return None
return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
def format_version(version: tuple[int, int, int]) -> str:
"""Rend `MAJEUR.MINEUR.CORRECTIF`."""
return f"{version[0]}.{version[1]}.{version[2]}"
def bump_version(version: tuple[int, int, int], kind: str) -> tuple[int, int, int]:
"""Applique un incrément SemVer à ``version``."""
major, minor, patch = version
if kind == "major":
return (major + 1, 0, 0)
if kind == "minor":
return (major, minor + 1, 0)
if kind == "patch":
return (major, minor, patch + 1)
raise ValueError(f"incrément inconnu : {kind!r}")
# ── Git ────────────────────────────────────────────────────────────────────
def git(*args: str, root: Path = REPO_ROOT) -> str | None:
"""Exécute git dans ``root`` ; retourne stdout (strippé) ou ``None``."""
try:
result = subprocess.run(
["git", *args],
cwd=str(root),
capture_output=True,
text=True,
timeout=10,
check=False,
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return None
if result.returncode != 0:
return None
return result.stdout.strip()
def last_tag_version(root: Path = REPO_ROOT) -> str | None:
"""Version (sans `v`) du dernier tag de release, ou ``None``."""
tag = git("describe", "--tags", "--abbrev=0", root=root)
if not tag:
return None
parsed = parse_version(tag)
return format_version(parsed) if parsed else None
def read_version(root: Path = REPO_ROOT) -> str:
"""Version courante : `VERSION` → `backend/VERSION` → dernier tag → `0.0.0`."""
for rel in (VERSION_FILE, *LEGACY_VERSION_FILES):
path = root / rel
if path.exists():
parsed = parse_version(path.read_text(encoding="utf-8"))
if parsed:
return format_version(parsed)
tag = last_tag_version(root)
return tag or "0.0.0"
def head_version(root: Path = REPO_ROOT) -> str | None:
"""Version contenue dans `VERSION` au commit HEAD, ou ``None``."""
raw = git("show", f"HEAD:{VERSION_FILE}", root=root)
if not raw:
return None
parsed = parse_version(raw)
return format_version(parsed) if parsed else None
# ── Détection de l'incrément ───────────────────────────────────────────────
def detect_bump_from_message(message: str, default: str | None = None) -> str | None:
"""Type d'incrément déduit d'un message de commit.
Retourne ``None`` quand le commit ne doit **pas** déclencher de version
(merge, revert, commit de release, message vide) — sauf si ``default`` est
fourni : il s'applique alors à un message vide (commit rédigé dans un
éditeur, où le hook s'exécute avant la saisie du message).
"""
text = (message or "").strip()
if not text:
return default
lines = [line for line in text.splitlines() if not line.lstrip().startswith("#")]
if not lines or not "\n".join(lines).strip():
return default
subject = lines[0].strip()
if re.match(r"^(Merge|Revert)\b", subject):
return None
if re.match(r"^chore\(release\)", subject):
return None
if "BREAKING CHANGE" in text or re.match(r"^[a-zA-Z][a-zA-Z0-9-]*(\([^)]*\))?!:", subject):
return "major"
if re.match(r"^(feat|feature)(\([^)]*\))?:", subject):
return "minor"
return "patch"
def detect_bump_from_commits(root: Path = REPO_ROOT) -> str | None:
"""Incrément déduit des commits depuis le dernier tag (ou des 20 derniers)."""
tag = git("describe", "--tags", "--abbrev=0", root=root)
rev_range = f"{tag}..HEAD" if tag else "HEAD~20..HEAD"
log = git("log", "--format=%s%n%b", rev_range, root=root)
if log is None:
log = git("log", "-1", "--format=%s%n%b", root=root) or ""
if not log.strip():
log = git("log", "-1", "--format=%s%n%b", root=root) or ""
subjects = [line for line in log.splitlines() if line.strip()]
if not subjects:
return None
if "BREAKING CHANGE" in log or any(
re.match(r"^[a-zA-Z][a-zA-Z0-9-]*(\([^)]*\))?!:", line) for line in subjects
):
return "major"
if any(re.match(r"^(feat|feature)(\([^)]*\))?:", line) for line in subjects):
return "minor"
return "patch"
# ── Écriture de fichiers (préserve les fins de ligne) ──────────────────────
def write_preserving_newlines(path: Path, text: str) -> None:
"""Écrit ``text`` en conservant le style de fin de ligne du fichier."""
original = path.read_bytes() if path.exists() else b""
crlf = b"\r\n" in original
data = text.replace("\r\n", "\n")
if crlf:
data = data.replace("\n", "\r\n")
path.write_bytes(data.encode("utf-8"))
def sync_package_json(root: Path, version: str) -> bool:
"""Aligne `package.json` (`version`)."""
path = root / PACKAGE_JSON
if not path.exists():
return False
original = path.read_text(encoding="utf-8")
data = json.loads(original)
if data.get("version") == version:
return False
data["version"] = version
text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
write_preserving_newlines(path, text)
return True
def sync_desktop(root: Path, version: str) -> list[str]:
"""Aligne les fichiers desktop Tauri ; retourne les fichiers réellement modifiés."""
changed: list[str] = []
conf = root / DESKTOP_CONF
if conf.exists():
text = conf.read_text(encoding="utf-8")
new_text, count = re.subn(
r'("version"\s*:\s*")[^"]*(")',
lambda m: f"{m.group(1)}{version}{m.group(2)}",
text,
count=1,
)
if count and new_text != text:
write_preserving_newlines(conf, new_text)
changed.append(DESKTOP_CONF)
cargo = root / DESKTOP_CARGO
if cargo.exists():
text = cargo.read_text(encoding="utf-8")
new_text, count = re.subn(
r'(?m)^version\s*=\s*"[^"]*"',
f'version = "{version}"',
text,
count=1,
)
if count and new_text != text:
write_preserving_newlines(cargo, new_text)
changed.append(DESKTOP_CARGO)
lock = root / DESKTOP_LOCK
if lock.exists():
text = lock.read_text(encoding="utf-8")
new_text, count = re.subn(
r'(?m)^(name = "obsigate-desktop"\r?\nversion = ")[^"]*(")',
lambda m: f"{m.group(1)}{version}{m.group(2)}",
text,
count=1,
)
if count and new_text != text:
write_preserving_newlines(lock, new_text)
changed.append(DESKTOP_LOCK)
return changed
def sync_readmes(root: Path, version: str) -> list[str]:
"""Aligne les badges et pieds de page des README (FR/EN)."""
changed: list[str] = []
patterns = (
(r"(badge/Version-)[^)]*(-blue)", rf"\g<1>{version}\g<2>"),
(r"(\| Version\s?:\s?)[\d.]+(?:-dev)?", rf"\g<1>{version}"),
(r"\(v1\.0\.0 → v[\d.]+(?:-dev)?\)", f"(v1.0.0 → v{version})"),
)
for rel in ("README.md", "README.fr.md"):
path = root / rel
if not path.exists():
continue
text = path.read_text(encoding="utf-8")
new_text = text
for pattern, replacement in patterns:
new_text = re.sub(pattern, replacement, new_text)
if new_text != text:
write_preserving_newlines(path, new_text)
changed.append(rel)
return changed
def sync_roadmap(root: Path, version: str, when: date | None = None) -> bool:
"""Aligne l'en-tête de `docs/ROADMAP.md` (version + date de mise à jour)."""
path = root / ROADMAP
if not path.exists():
return False
text = path.read_text(encoding="utf-8")
today = (when or date.today()).isoformat()
new_text, count = re.subn(
r"(> \*\*Version :\*\* )\S+( \| \*\*Dernière mise à jour :\*\* )\S+",
lambda m: f"{m.group(1)}{version}{m.group(2)}{today}",
text,
count=1,
)
if not count:
return False
if new_text == text:
return False
write_preserving_newlines(path, new_text)
return True
def rotate_changelog(root: Path, version: str, when: date | None = None) -> bool:
"""Publie `[Unreleased]` en `[X.Y.Z] — date` et ouvre une nouvelle section vide.
Idempotent : si la version est déjà présente dans le fichier, rien n'est fait.
"""
path = root / CHANGELOG
if not path.exists():
return False
text = path.read_text(encoding="utf-8")
if re.search(rf"(?m)^## \[{re.escape(version)}\]", text):
return False
heading = re.search(r"(?m)^## \[Unreleased\][^\n]*\n", text)
if not heading:
return False
today = (when or date.today()).isoformat()
replacement = (
"## [Unreleased]\n"
"\n"
"---\n"
"\n"
f"## [{version}] — {today}\n"
)
text = text[: heading.start()] + replacement + text[heading.end() :]
# Phrase d'en-tête : dernière version livrée
text = re.sub(
r"(?s)> \*\*En cours de développement\*\* : les changements non publiés sont dans la section\n"
r"> \[Unreleased\]\(#unreleased\)\. La dernière version publiée est \*\*[\d.]+\*\*\.",
"> **En cours de développement** : les changements à venir sont listés dans la section\n"
"> [Unreleased](#unreleased). La dernière version livrée est "
f"**{version}**.",
text,
count=1,
)
text = re.sub(
r"(La dernière version (?:publiée|livrée) est \*\*)[\d.]+(\*\*)",
rf"\g<1>{version}\g<2>",
text,
count=1,
)
write_preserving_newlines(path, text)
return True
def apply_version(
root: Path,
version: str,
*,
sync: bool = True,
changelog: bool = True,
when: date | None = None,
) -> list[str]:
"""Écrit `VERSION` et synchronise tous les fichiers dérivés.
Retourne la liste (relative au dépôt) des fichiers modifiés.
"""
touched: list[str] = []
version_path = root / VERSION_FILE
previous = read_version(root)
if previous != version or not version_path.exists():
write_preserving_newlines(version_path, version + "\n")
touched.append(VERSION_FILE)
if sync:
if sync_package_json(root, version):
touched.append(PACKAGE_JSON)
touched.extend(sync_desktop(root, version))
touched.extend(sync_readmes(root, version))
if sync_roadmap(root, version, when):
touched.append(ROADMAP)
if changelog and rotate_changelog(root, version, when):
touched.append(CHANGELOG)
return touched
def stage(root: Path, files: list[str]) -> None:
"""`git add` des fichiers modifiés (les fichiers absents sont ignorés)."""
existing = [rel for rel in dict.fromkeys(files) if (root / rel).exists()]
if existing:
git("add", "--", *existing, root=root)
# ── CLI ────────────────────────────────────────────────────────────────────
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="bump_version.py",
description="ObsiGate — version SemVer (source unique de vérité : VERSION).",
)
parser.add_argument(
"--bump",
choices=("auto", *BUMP_KINDS),
default="auto",
help="incrément à appliquer (défaut : auto = commits depuis le dernier tag)",
)
parser.add_argument("--major", action="store_true", help="raccourci --bump major")
parser.add_argument("--minor", action="store_true", help="raccourci --bump minor")
parser.add_argument("--patch", action="store_true", help="raccourci --bump patch")
parser.add_argument("--set", metavar="X.Y.Z", help="fixe la version sans incrément")
parser.add_argument(
"--from-message-file",
metavar="PATH",
help="détecte l'incrément depuis un fichier de message de commit",
)
parser.add_argument("--message", metavar="TEXT", help="détecte l'incrément depuis un message")
parser.add_argument(
"--default-kind",
choices=BUMP_KINDS,
help="incrément appliqué quand le message est vide (commit via éditeur)",
)
parser.add_argument("--dry-run", action="store_true", help="affiche sans écrire")
parser.add_argument(
"--sync",
dest="sync",
action="store_true",
default=True,
help="synchroniser les fichiers dérivés (défaut)",
)
parser.add_argument(
"--no-files",
"--no-sync",
dest="sync",
action="store_false",
help="ne pas toucher aux fichiers dérivés",
)
parser.add_argument(
"--changelog",
dest="changelog",
action="store_true",
default=True,
help="rotation de CHANGELOG.md (défaut)",
)
parser.add_argument(
"--no-changelog",
dest="changelog",
action="store_false",
help="ne pas toucher à CHANGELOG.md",
)
parser.add_argument("--stage", action="store_true", help="`git add` des fichiers modifiés")
parser.add_argument(
"--files-out",
metavar="PATH",
help="écrit la liste des fichiers modifiés (un par ligne) — utilisée par les hooks git",
)
parser.add_argument("--commit", action="store_true", help="commit `chore(release): vX.Y.Z`")
parser.add_argument("--tag", action="store_true", help="créer le tag annoté vX.Y.Z")
parser.add_argument("--push", action="store_true", help="pousser la branche et les tags")
parser.add_argument(
"--print-version", action="store_true", help="afficher la version courante"
)
return parser
def resolve_bump(args: argparse.Namespace, root: Path) -> str | None:
"""Détermine l'incrément à appliquer (``None`` = aucun bump)."""
for flag, kind in (("major", "major"), ("minor", "minor"), ("patch", "patch")):
if getattr(args, flag):
return kind
if args.bump != "auto":
return args.bump
if args.from_message_file:
message = Path(args.from_message_file).read_text(encoding="utf-8", errors="replace")
return detect_bump_from_message(message, default=args.default_kind)
if args.message:
return detect_bump_from_message(args.message, default=args.default_kind)
return detect_bump_from_commits(root)
def main(argv: list[str] | None = None) -> int:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
args = build_parser().parse_args(argv)
root = REPO_ROOT
if args.print_version:
print(read_version(root))
return 0
current = read_version(root)
# Version déjà fixée explicitement
if args.set:
parsed = parse_version(args.set)
if not parsed:
print(f"✗ Version invalide : {args.set!r} (attendu MAJEUR.MINEUR.CORRECTIF)", file=sys.stderr)
return 1
next_version = format_version(parsed)
kind = "set"
else:
# Un incrément est-il déjà préparé dans l'arbre de travail (commit avorté,
# version posée à la main, premier passage avant tout commit) ?
committed = head_version(root)
last_tag = last_tag_version(root)
prepared = (committed is not None and current != committed) or (
committed is None and last_tag is not None and current != last_tag
)
if prepared:
kind = "none"
next_version = current
else:
kind = resolve_bump(args, root)
if kind is None:
print("Version inchangée (commit sans bump : merge, revert ou release).")
return 3
next_version = format_version(bump_version(parse_version(current) or (0, 0, 0), kind))
print(f"ObsiGate version : {current} → {next_version} ({kind})")
if args.dry_run:
print("── DRY RUN ─────────────────────────────")
for rel in (
VERSION_FILE,
PACKAGE_JSON,
DESKTOP_CONF,
DESKTOP_CARGO,
DESKTOP_LOCK,
ROADMAP,
CHANGELOG,
"README.md",
"README.fr.md",
):
if (root / rel).exists():
print(f" à synchroniser : {rel}")
print(" (aucune modification, aucun tag créé)")
return 0
if kind == "none" and (root / VERSION_FILE).exists():
# Rien à incrémenter : on resynchronise seulement les satellites.
touched = apply_version(root, current, sync=args.sync, changelog=False)
else:
touched = apply_version(root, next_version, sync=args.sync, changelog=args.changelog)
if touched:
print(" fichiers mis à jour : " + ", ".join(dict.fromkeys(touched)))
if args.files_out:
list_path = Path(args.files_out)
if not list_path.is_absolute():
list_path = root / list_path
list_path.write_text(
"".join(f"{rel}\n" for rel in dict.fromkeys(touched)), encoding="utf-8"
)
if args.stage and touched:
stage(root, touched)
if args.commit:
stage(root, touched or [VERSION_FILE])
git("commit", "-m", f"chore(release): v{next_version}", root=root)
print(f"✓ Commit de release créé : v{next_version}")
if args.tag or args.push:
tag = f"v{next_version}"
if git("rev-parse", "-q", "--verify", f"refs/tags/{tag}", root=root) is None:
if git("tag", "-a", tag, "-m", f"ObsiGate {tag}", root=root) is None:
print(f"✗ Impossible de créer le tag {tag}", file=sys.stderr)
return 1
print(f"✓ Tag créé : {tag}")
else:
print(f"ℹ Tag {tag} déjà présent.")
if args.push:
branch = git("rev-parse", "--abbrev-ref", "HEAD", root=root) or "main"
git("push", "origin", branch, "--follow-tags", root=root)
print(f"✓ Branche {branch} et tags poussés.")
return 0
if __name__ == "__main__":
sys.exit(main())