#!/usr/bin/env python3 from __future__ import annotations import argparse import re import subprocess import sys from dataclasses import dataclass from pathlib import Path ROOT = Path(__file__).resolve().parents[1] VERSION_FILE = ROOT / "VERSION" ENV_EXAMPLE = ROOT / ".env.example" ENV_STAGE_EXAMPLE = ROOT / ".env.stage.example" COMPOSE_FILE = ROOT / "docker-compose.yml" SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") BREAKING_RE = re.compile(r"(^|\n)BREAKING CHANGE:|^[a-z]+(?:\([^)]+\))?!:", re.IGNORECASE | re.MULTILINE) FEAT_RE = re.compile(r"^feat(?:\([^)]+\))?:", re.IGNORECASE) @dataclass(frozen=True, order=True) class Version: major: int minor: int patch: int def __str__(self) -> str: return f"{self.major}.{self.minor}.{self.patch}" @classmethod def parse(cls, value: str) -> "Version": match = SEMVER_RE.fullmatch(value.strip()) if not match: raise ValueError(f"Ungueltige SemVer-Version: {value}") return cls(*(int(part) for part in match.groups())) @classmethod def parse_tag(cls, value: str) -> "Version | None": match = TAG_RE.fullmatch(value.strip()) if not match: return None return cls(*(int(part) for part in match.groups())) def bump(self, kind: str) -> "Version": if kind == "major": return Version(self.major + 1, 0, 0) if kind == "minor": return Version(self.major, self.minor + 1, 0) if kind == "patch": return Version(self.major, self.minor, self.patch + 1) raise ValueError(f"Unbekannter Bump-Typ: {kind}") def run_git(*args: str) -> str: result = subprocess.run( ["git", *args], cwd=ROOT, check=True, capture_output=True, text=True, ) return result.stdout.strip() def latest_live_tag() -> tuple[str | None, Version | None]: output = run_git("tag", "--list", "v*") best_tag: str | None = None best_version: Version | None = None for line in output.splitlines(): version = Version.parse_tag(line) if version is None: continue if best_version is None or version > best_version: best_tag = line best_version = version return best_tag, best_version def commits_since(ref: str | None) -> list[str]: range_spec = f"{ref}..HEAD" if ref else "HEAD" output = run_git("log", "--format=%s%n%b%x1e", range_spec) return [chunk.strip() for chunk in output.split("\x1e") if chunk.strip()] def detect_bump(commits: list[str]) -> str: if not commits: return "patch" for commit in commits: if BREAKING_RE.search(commit): return "major" for commit in commits: subject = commit.splitlines()[0].strip() if FEAT_RE.match(subject): return "minor" return "patch" def current_repo_version() -> Version: return Version.parse(VERSION_FILE.read_text(encoding="utf-8").strip()) def replace_or_append(path: Path, prefix: str, value: str) -> None: lines = path.read_text(encoding="utf-8").splitlines() for idx, line in enumerate(lines): if line.startswith(prefix): lines[idx] = f"{prefix}{value}" path.write_text("\n".join(lines) + "\n", encoding="utf-8") return lines.append(f"{prefix}{value}") path.write_text("\n".join(lines) + "\n", encoding="utf-8") def replace_regex(path: Path, pattern: str, replacement: str) -> None: content = path.read_text(encoding="utf-8") updated, count = re.subn(pattern, replacement, content, count=1, flags=re.MULTILINE) if count != 1: raise RuntimeError(f"Konnte Muster in {path} nicht eindeutig ersetzen") path.write_text(updated, encoding="utf-8") def apply_version(version: Version) -> None: VERSION_FILE.write_text(f"{version}\n", encoding="utf-8") replace_or_append(ENV_EXAMPLE, "APP_VERSION=", str(version)) replace_or_append(ENV_STAGE_EXAMPLE, "APP_VERSION=", str(version)) replace_regex(COMPOSE_FILE, r"(APP_VERSION:\s+\$\{APP_VERSION:-)([^}]+)(\})", rf"\g<1>{version}\3") def ensure_clean_worktree() -> None: status = run_git("status", "--porcelain") if status: raise RuntimeError("Git-Worktree ist nicht sauber. Bitte zuerst committen oder stagen.") def cmd_suggest(_: argparse.Namespace) -> int: tag, base = latest_live_tag() if base is None: base = current_repo_version() commits = commits_since(tag) bump = detect_bump(commits) next_version = base if not commits else base.bump(bump) print(f"latest_live_tag={tag or 'none'}") print(f"base_version={base}") print(f"detected_bump={bump}") print(f"next_version={next_version}") return 0 def cmd_apply_auto(args: argparse.Namespace) -> int: if args.require_clean: ensure_clean_worktree() tag, base = latest_live_tag() if base is None: base = current_repo_version() commits = commits_since(tag) bump = detect_bump(commits) next_version = base if not commits else base.bump(bump) apply_version(next_version) print(f"applied_version={next_version}") print(f"detected_bump={bump}") return 0 def cmd_apply(args: argparse.Namespace) -> int: if args.require_clean: ensure_clean_worktree() version = Version.parse(args.version) apply_version(version) print(f"applied_version={version}") return 0 def cmd_tag_live(_: argparse.Namespace) -> int: version = current_repo_version() tag_name = f"v{version}" existing = run_git("tag", "--list", tag_name) if existing.strip(): print(f"tag_exists={tag_name}") return 0 subprocess.run(["git", "tag", tag_name], cwd=ROOT, check=True) print(f"tag_created={tag_name}") return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Automatische Versionierung fuer Stundenfuchs") sub = parser.add_subparsers(dest="command", required=True) suggest = sub.add_parser("suggest", help="Naechste Version aus Commits seit letztem Live-Tag ableiten") suggest.set_defaults(func=cmd_suggest) apply_auto = sub.add_parser("apply-auto", help="Naechste Version automatisch anwenden") apply_auto.add_argument("--require-clean", action="store_true", help="Abbrechen, wenn der Worktree nicht sauber ist") apply_auto.set_defaults(func=cmd_apply_auto) apply_manual = sub.add_parser("apply", help="Konkrete Version anwenden") apply_manual.add_argument("version", help="SemVer-Version, z. B. 1.0.4") apply_manual.add_argument("--require-clean", action="store_true", help="Abbrechen, wenn der Worktree nicht sauber ist") apply_manual.set_defaults(func=cmd_apply) tag_live = sub.add_parser("tag-live", help="Aktuelle Repo-Version als Live-Tag anlegen") tag_live.set_defaults(func=cmd_tag_live) return parser def main() -> int: parser = build_parser() args = parser.parse_args() try: return args.func(args) except subprocess.CalledProcessError as exc: print(exc.stderr or str(exc), file=sys.stderr) return exc.returncode or 1 except Exception as exc: print(str(exc), file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())