#!/usr/bin/env python3 """Inspect GovOPlaN release state and suggest the next release commands.""" from __future__ import annotations import argparse import ast from dataclasses import asdict, dataclass, field from datetime import UTC, datetime import json import os from pathlib import Path import shlex import subprocess import sys import tomllib from typing import Any from govoplan_release.http_fetch import fetch_http, validate_http_url META_ROOT = Path(__file__).resolve().parents[2] ROOT = Path(os.environ.get("GOVOPLAN_CORE_ROOT", META_ROOT.parent / "govoplan-core")).resolve() DEFAULT_WORKSPACE_ROOT = ROOT.parent DEFAULT_PUBLIC_CATALOG_URL = "https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json" DEFAULT_PUBLIC_KEYRING_URL = "https://govoplan.add-ideas.de/catalogs/v1/keyring.json" SEVERITY_ORDER = {"blocker": 3, "warning": 2, "info": 1, "ok": 0} @dataclass(frozen=True, slots=True) class SuggestedCommand: title: str command: str cwd: str argv: tuple[str, ...] = () mutating: bool = False reason: str = "" @dataclass(frozen=True, slots=True) class Finding: check_id: str severity: str title: str detail: str commands: tuple[SuggestedCommand, ...] = () @dataclass(frozen=True, slots=True) class RepoState: name: str path: str exists: bool branch: str | None = None upstream: str | None = None ahead: int | None = None behind: int | None = None dirty_entries: tuple[str, ...] = () pyproject_version: str | None = None package_version: str | None = None webui_package_version: str | None = None local_tag_exists: bool | None = None remote_tag_exists: bool | None = None remote_checked: bool = False safe_directory_required: bool = False safe_directory_command: str | None = None errors: tuple[str, ...] = () @property def dirty(self) -> bool: return bool(self.dirty_entries) @dataclass(frozen=True, slots=True) class DoctorReport: generated_at: str workspace_root: str target_version: str | None target_tag: str | None online: bool overall_status: str repos: tuple[RepoState, ...] findings: tuple[Finding, ...] migration_audits: dict[str, dict[str, Any]] = field(default_factory=dict) catalog: dict[str, Any] = field(default_factory=dict) @dataclass(frozen=True, slots=True) class CommandResult: returncode: int stdout: str stderr: str class GitCommandError(RuntimeError): def __init__(self, message: str, *, result: CommandResult) -> None: super().__init__(message) self.result = result def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "command", nargs="?", choices=("status", "next", "interactive"), default="status", help="status prints the report, next prints only guidance, interactive lets you choose commands to run.", ) parser.add_argument("--workspace-root", type=Path, default=DEFAULT_WORKSPACE_ROOT) parser.add_argument("--target-version", help="Release version to evaluate, without leading v. Defaults to core pyproject version.") parser.add_argument("--repo", action="append", default=[], help="Limit checks to a repository name; may be repeated.") parser.add_argument("--online", action="store_true", help="Check remote tags and public catalog/keyring availability.") parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.") parser.add_argument("--details", action="store_true", help="Show full finding details in status output.") parser.add_argument("--no-migration-audit", action="store_true", help="Skip release/dev migration audit subprocesses.") parser.add_argument("--yes", action="store_true", help="In interactive mode, skip the first yes/no prompt for non-mutating commands.") args = parser.parse_args(argv) workspace_root = args.workspace_root.expanduser().resolve() target_version = normalize_version(args.target_version) if args.target_version else read_pyproject_version(ROOT) report = build_report( workspace_root=workspace_root, target_version=target_version, repo_filter=tuple(args.repo), online=args.online, run_migration_audit=not args.no_migration_audit, ) if args.json: print(json.dumps(report_to_dict(report), indent=2, sort_keys=True)) elif args.command == "next": print_next_commands(report) elif args.command == "interactive": return run_interactive( report, assume_yes=args.yes, workspace_root=workspace_root, target_version=target_version, repo_filter=tuple(args.repo), online=args.online, run_migration_audit=not args.no_migration_audit, ) else: print_text_report(report, detailed=args.details) return 1 if report.overall_status == "blocked" else 0 def build_report( *, workspace_root: Path, target_version: str | None, repo_filter: tuple[str, ...] = (), online: bool = False, run_migration_audit: bool = True, ) -> DoctorReport: repo_names = release_repo_names(workspace_root) if repo_filter: wanted = set(repo_filter) repo_names = tuple(name for name in repo_names if name in wanted) missing_filters = tuple(sorted(wanted - set(repo_names))) else: missing_filters = () target_tag = f"v{target_version}" if target_version else None repos = tuple(collect_repo_state(workspace_root / name, target_tag=target_tag, online=online) for name in repo_names) migration_audits = collect_migration_audits(run_migration_audit=run_migration_audit) catalog = collect_catalog_state(workspace_root=workspace_root, target_version=target_version, online=online) findings: list[Finding] = [] for name in missing_filters: findings.append( Finding( check_id="repo-filter", severity="blocker", title=f"Requested repository {name} is not part of the release repo set", detail="Check the repository name or add it to the release catalog module list first.", ) ) findings.extend(repo_findings(repos, target_version=target_version, target_tag=target_tag)) findings.extend(migration_findings(migration_audits, target_version=target_version)) findings.extend(catalog_findings(catalog, target_version=target_version, online=online)) overall_status = status_from_findings(findings) return DoctorReport( generated_at=datetime.now(tz=UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"), workspace_root=str(workspace_root), target_version=target_version, target_tag=target_tag, online=online, overall_status=overall_status, repos=repos, findings=tuple(findings), migration_audits=migration_audits, catalog=catalog, ) def release_repo_names(workspace_root: Path) -> tuple[str, ...]: names = {"govoplan-core"} catalog_script = META_ROOT / "tools" / "release" / "generate-release-catalog.py" if catalog_script.exists(): tree = ast.parse(catalog_script.read_text(encoding="utf-8"), filename=str(catalog_script)) for node in ast.walk(tree): if not isinstance(node, ast.Call): continue if not isinstance(node.func, ast.Name) or node.func.id != "CatalogModule": continue for keyword in node.keywords: if keyword.arg == "repo" and isinstance(keyword.value, ast.Constant) and isinstance(keyword.value.value, str): names.add(keyword.value.value) baseline_file = ROOT / "docs" / "migration-release-baselines.json" if baseline_file.exists(): try: baseline = json.loads(baseline_file.read_text(encoding="utf-8")) releases = baseline.get("releases") if isinstance(baseline, dict) else None latest = releases[-1] if isinstance(releases, list) and releases else None owner_heads = latest.get("owner_heads") if isinstance(latest, dict) else None if isinstance(owner_heads, list): for item in owner_heads: owner = item.get("owner") if isinstance(item, dict) else None if isinstance(owner, str) and owner.startswith("govoplan-"): names.add(owner) except Exception: pass if len(names) <= 2: names.update(path.name for path in workspace_root.glob("govoplan-*") if (path / ".git").is_dir()) return tuple(sorted(names)) def collect_repo_state(path: Path, *, target_tag: str | None, online: bool) -> RepoState: if not path.exists(): return RepoState(name=path.name, path=str(path), exists=False, errors=("repository directory is missing",)) if not (path / ".git").exists(): return RepoState(name=path.name, path=str(path), exists=False, errors=("not a git repository",)) errors: list[str] = [] try: branch = git_text(path, "rev-parse", "--abbrev-ref", "HEAD") status = git_text(path, "status", "--porcelain") except GitCommandError as exc: message = git_error_text(exc.result) if is_dubious_ownership_error(message): return RepoState( name=path.name, path=str(path), exists=True, safe_directory_required=True, safe_directory_command=safe_directory_command(path), errors=(compact_git_error(message),), ) return RepoState(name=path.name, path=str(path), exists=True, errors=(compact_git_error(message),)) upstream = git_text(path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}", check=False) ahead: int | None = None behind: int | None = None if upstream: counts = git_text(path, "rev-list", "--left-right", "--count", "@{upstream}...HEAD", check=False) parts = counts.split() if len(parts) == 2 and all(part.isdigit() for part in parts): behind = int(parts[0]) ahead = int(parts[1]) local_tag_exists: bool | None = None remote_tag_exists: bool | None = None if target_tag: local_tag_exists = git_success(path, "rev-parse", "-q", "--verify", f"refs/tags/{target_tag}") if online: remote_tag_exists = git_success(path, "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{target_tag}", timeout=8) try: pyproject_version = read_pyproject_version(path) package_version = read_json_version(path / "package.json") webui_package_version = read_json_version(path / "webui" / "package.json") except Exception as exc: # noqa: BLE001 - surfaced as a doctor finding. errors.append(str(exc)) pyproject_version = None package_version = None webui_package_version = None return RepoState( name=path.name, path=str(path), exists=True, branch=branch or None, upstream=upstream or None, ahead=ahead, behind=behind, dirty_entries=tuple(line for line in status.splitlines() if line.strip()), pyproject_version=pyproject_version, package_version=package_version, webui_package_version=webui_package_version, local_tag_exists=local_tag_exists, remote_tag_exists=remote_tag_exists, remote_checked=online, errors=tuple(errors), ) def repo_findings(repos: tuple[RepoState, ...], *, target_version: str | None, target_tag: str | None) -> tuple[Finding, ...]: findings: list[Finding] = [] for repo in repos: repo_path = Path(repo.path) if not repo.exists: findings.append( Finding( check_id="repo-exists", severity="blocker", title=f"{repo.name} is missing", detail="The release doctor cannot inspect this repository.", ) ) continue if repo.errors: if repo.safe_directory_required: findings.append( Finding( check_id="repo-safe-directory", severity="blocker", title=f"{repo.name} is blocked by Git dubious-ownership protection", detail="Git refuses to inspect this repository until it is explicitly marked as safe.", commands=( command( Path(repo.path), f"Trust {repo.name} for this user", repo.safe_directory_command or safe_directory_command(Path(repo.path)), mutating=True, ), ), ) ) continue findings.append( Finding( check_id="repo-read", severity="warning", title=f"{repo.name} has unreadable metadata", detail="; ".join(repo.errors), commands=(command(repo_path, f"Inspect {repo.name}", "git status --short"),), ) ) if repo.dirty: preview = "\n".join(repo.dirty_entries[:8]) if len(repo.dirty_entries) > 8: preview += f"\n... {len(repo.dirty_entries) - 8} more" findings.append( Finding( check_id="repo-dirty", severity="blocker", title=f"{repo.name} has uncommitted changes", detail=preview, commands=( command(repo_path, f"Show {repo.name} status", "git status --short"), command(repo_path, f"Show {repo.name} diff summary", "git diff --stat"), ), ) ) if repo.behind: findings.append( Finding( check_id="repo-behind", severity="blocker", title=f"{repo.name} is behind {repo.upstream}", detail=f"Behind by {repo.behind} commit(s). Release from an up-to-date branch.", commands=(command(repo_path, f"Fetch {repo.name}", "git fetch --all --tags --prune"),), ) ) if repo.ahead: findings.append( Finding( check_id="repo-ahead", severity="warning", title=f"{repo.name} has commits not pushed to {repo.upstream}", detail=f"Ahead by {repo.ahead} commit(s). Tags/catalogs should point to pushed commits.", commands=(command(repo_path, f"Push {repo.name}", "git push", mutating=True),), ) ) versions = tuple( value for value in (repo.pyproject_version, repo.package_version, repo.webui_package_version) if value ) if len(set(versions)) > 1: findings.append( Finding( check_id="repo-version-mismatch", severity="warning", title=f"{repo.name} has inconsistent local version files", detail=", ".join( value for value in ( f"pyproject={repo.pyproject_version}" if repo.pyproject_version else "", f"package={repo.package_version}" if repo.package_version else "", f"webui={repo.webui_package_version}" if repo.webui_package_version else "", ) if value ), ) ) if target_version and repo.name == "govoplan-core" and repo.pyproject_version and repo.pyproject_version != target_version: findings.append( Finding( check_id="core-target-version", severity="warning", title="Core pyproject version differs from the target release", detail=f"core={repo.pyproject_version}, target={target_version}", commands=(command(META_ROOT, "Prepare release version", f"tools/release/push-release-tag.sh --version {shlex.quote(target_version)} --strict-migration-audit", mutating=True),), ) ) elif target_version and repo.pyproject_version and repo.pyproject_version != target_version: findings.append( Finding( check_id="module-target-version", severity="info", title=f"{repo.name} is not currently at target version {target_version}", detail=f"local pyproject version is {repo.pyproject_version}. This is fine if the module is not part of this release.", ) ) if target_tag and repo.local_tag_exists is False and repo.pyproject_version == target_version: findings.append( Finding( check_id="tag-local-missing", severity="info", title=f"{repo.name} has no local {target_tag} tag", detail="The release tag is still missing locally.", commands=(command(META_ROOT, "Run release tagging workflow", f"tools/release/push-release-tag.sh --version {shlex.quote(target_version or '')} --strict-migration-audit", mutating=True),), ) ) if target_tag and repo.remote_checked and repo.remote_tag_exists is False and repo.pyproject_version == target_version: findings.append( Finding( check_id="tag-remote-missing", severity="warning", title=f"{repo.name} has no remote {target_tag} tag", detail="The public release tag is missing from origin.", commands=(command(repo_path, f"Push {repo.name} tags", f"git push origin {shlex.quote(target_tag)}", mutating=True),), ) ) return tuple(findings) def collect_migration_audits(*, run_migration_audit: bool) -> dict[str, dict[str, Any]]: if not run_migration_audit: return {} result: dict[str, dict[str, Any]] = {} for track in ("release", "dev"): audit = run_subprocess( [sys.executable, str(META_ROOT / "tools" / "release" / "release-migration-audit.py"), "--track", track, "--json"], cwd=META_ROOT, timeout=30, ) if audit.returncode != 0: result[track] = { "ok": False, "error": (audit.stderr or audit.stdout).strip(), } continue try: payload = json.loads(audit.stdout) except json.JSONDecodeError as exc: result[track] = {"ok": False, "error": f"Could not parse audit JSON: {exc}"} continue payload["ok"] = not payload.get("graph_errors") result[track] = payload return result def migration_findings(migration_audits: dict[str, dict[str, Any]], *, target_version: str | None) -> tuple[Finding, ...]: if not migration_audits: return ( Finding( check_id="migration-audit-skipped", severity="info", title="Migration audits were skipped", detail="Run without --no-migration-audit for release/dev track checks.", ), ) findings: list[Finding] = [] for track in ("release", "dev"): audit = migration_audits.get(track) if not audit: findings.append( Finding( check_id=f"migration-{track}-missing", severity="warning", title=f"{track} migration audit did not run", detail="No audit result is available.", commands=(migration_audit_command(track),), ) ) continue if not audit.get("ok", True): findings.append( Finding( check_id=f"migration-{track}-graph", severity="blocker", title=f"{track} migration graph has errors", detail="\n".join(audit.get("graph_errors") or [audit.get("error", "unknown audit error")]), commands=(migration_audit_command(track),), ) ) strict_errors = tuple(audit.get("strict_errors") or ()) if track == "release" and strict_errors: commands = [migration_audit_command("release", strict=True)] if target_version: commands.append( command( META_ROOT, "Record reviewed release migration baseline", f"{python_bin()} tools/release/release-migration-audit.py --track release --record-release {shlex.quote(target_version)}", mutating=True, ) ) findings.append( Finding( check_id="migration-release-baseline", severity="blocker", title="Release migration heads are not fully recorded", detail="\n".join(strict_errors), commands=tuple(commands), ) ) return tuple(findings) def collect_catalog_state(*, workspace_root: Path, target_version: str | None, online: bool) -> dict[str, Any]: web_root = workspace_root / "addideas-govoplan-website" catalog_path = web_root / "public" / "catalogs" / "v1" / "channels" / "stable.json" keyring_path = web_root / "public" / "catalogs" / "v1" / "keyring.json" state: dict[str, Any] = { "catalog_path": str(catalog_path), "catalog_exists": catalog_path.exists(), "keyring_path": str(keyring_path), "keyring_exists": keyring_path.exists(), "catalog_version": None, "core_release_version": None, "module_versions": {}, "signature_count": 0, "key_count": 0, "public_catalog": None, "public_keyring": None, } if catalog_path.exists(): try: catalog = json.loads(catalog_path.read_text(encoding="utf-8")) state["catalog_version"] = catalog.get("catalog_version") core_release = catalog.get("core_release") if isinstance(catalog.get("core_release"), dict) else {} state["core_release_version"] = core_release.get("version") modules = catalog.get("modules") if isinstance(catalog.get("modules"), list) else [] state["module_versions"] = { item.get("module_id"): item.get("version") for item in modules if isinstance(item, dict) and item.get("module_id") } signatures = catalog.get("signatures") state["signature_count"] = len(signatures) if isinstance(signatures, list) else 0 except Exception as exc: # noqa: BLE001 - surfaced as a doctor finding. state["catalog_error"] = str(exc) if keyring_path.exists(): try: keyring = json.loads(keyring_path.read_text(encoding="utf-8")) keys = keyring.get("keys") state["key_count"] = len(keys) if isinstance(keys, list) else 0 except Exception as exc: # noqa: BLE001 - surfaced as a doctor finding. state["keyring_error"] = str(exc) release_requirements = META_ROOT / "requirements-release.txt" if release_requirements.exists() and target_version: text = release_requirements.read_text(encoding="utf-8") state["requirements_release_mentions_target"] = f"@v{target_version}" in text release_package = ROOT / "webui" / "package.release.json" if release_package.exists() and target_version: text = release_package.read_text(encoding="utf-8") state["webui_release_mentions_target"] = f"#v{target_version}" in text if online: state["public_catalog"] = check_url(DEFAULT_PUBLIC_CATALOG_URL) state["public_keyring"] = check_url(DEFAULT_PUBLIC_KEYRING_URL) return state def catalog_findings(catalog: dict[str, Any], *, target_version: str | None, online: bool) -> tuple[Finding, ...]: findings: list[Finding] = [] if not catalog.get("catalog_exists"): findings.append( Finding( check_id="catalog-missing", severity="warning", title="Stable release catalog is missing locally", detail=str(catalog.get("catalog_path")), commands=(publish_catalog_command(target_version),), ) ) elif catalog.get("catalog_error"): findings.append( Finding( check_id="catalog-invalid", severity="blocker", title="Stable release catalog cannot be parsed", detail=str(catalog["catalog_error"]), ) ) elif target_version and catalog.get("core_release_version") != target_version: findings.append( Finding( check_id="catalog-version", severity="warning", title="Stable catalog does not point at the target core release", detail=f"catalog core={catalog.get('core_release_version')}, target={target_version}", commands=(publish_catalog_command(target_version),), ) ) if catalog.get("catalog_exists") and catalog.get("signature_count", 0) == 0: findings.append( Finding( check_id="catalog-signatures", severity="blocker", title="Stable catalog has no signatures", detail="Operators requiring signed catalogs cannot trust this catalog.", commands=(publish_catalog_command(target_version),), ) ) if not catalog.get("keyring_exists"): findings.append( Finding( check_id="keyring-missing", severity="warning", title="Stable catalog keyring is missing locally", detail=str(catalog.get("keyring_path")), commands=(publish_catalog_command(target_version),), ) ) elif catalog.get("keyring_error"): findings.append( Finding( check_id="keyring-invalid", severity="blocker", title="Stable catalog keyring cannot be parsed", detail=str(catalog["keyring_error"]), ) ) elif catalog.get("key_count", 0) == 0: findings.append( Finding( check_id="keyring-empty", severity="blocker", title="Stable catalog keyring has no active keys", detail="Publish or copy the public keyring before release consumers enforce signatures.", commands=(publish_catalog_command(target_version),), ) ) if target_version and catalog.get("requirements_release_mentions_target") is False: findings.append( Finding( check_id="release-requirements-stale", severity="warning", title="requirements-release.txt does not reference the target tag", detail=f"Expected @v{target_version} in GovOPlaN release package refs.", commands=(command(META_ROOT, "Update release refs through release tag workflow", f"tools/release/push-release-tag.sh --version {shlex.quote(target_version)} --strict-migration-audit", mutating=True),), ) ) if target_version and catalog.get("webui_release_mentions_target") is False: findings.append( Finding( check_id="webui-release-package-stale", severity="warning", title="webui/package.release.json does not reference the target tag", detail=f"Expected #v{target_version} in GovOPlaN WebUI release package refs.", commands=(command(META_ROOT, "Update WebUI release refs through release tag workflow", f"tools/release/push-release-tag.sh --version {shlex.quote(target_version)} --strict-migration-audit", mutating=True),), ) ) if online: for check_id, label, value in ( ("public-catalog", "public stable catalog", catalog.get("public_catalog")), ("public-keyring", "public stable keyring", catalog.get("public_keyring")), ): if isinstance(value, dict) and not value.get("ok"): findings.append( Finding( check_id=check_id, severity="warning", title=f"The {label} is not reachable", detail=str(value.get("error") or value.get("status")), ) ) return tuple(findings) def status_from_findings(findings: list[Finding] | tuple[Finding, ...]) -> str: if any(item.severity == "blocker" for item in findings): return "blocked" if any(item.severity == "warning" for item in findings): return "warning" return "ready" def print_text_report(report: DoctorReport, *, detailed: bool = False) -> None: print(render_text_report(report, detailed=detailed, include_commands=False)) def render_text_report(report: DoctorReport, *, detailed: bool, include_commands: bool) -> str: lines: list[str] = [ "Release Doctor", f" workspace: {report.workspace_root}", f" target: {report.target_version or '-'}", f" status: {report.overall_status.upper()}", f" online checks: {'yes' if report.online else 'no'}", "", ] lines.extend(repo_summary_lines(report, detailed=detailed)) lines.append("") lines.extend(finding_lines(report.findings, detailed=detailed)) if include_commands: lines.append("") lines.extend(next_command_lines(report)) elif not detailed: lines.append("") lines.append("Use --details for full findings, `next` for suggested commands, or `interactive` for guided workflow.") return "\n".join(lines) def repo_summary_lines(report: DoctorReport, *, detailed: bool) -> list[str]: dirty = [repo.name for repo in report.repos if repo.dirty] missing = [repo.name for repo in report.repos if not repo.exists] safe_needed = [repo.name for repo in report.repos if repo.safe_directory_required] ahead = [repo.name for repo in report.repos if repo.ahead] behind = [repo.name for repo in report.repos if repo.behind] lines = [ "Repositories:", f" checked={len(report.repos)} dirty={len(dirty)} missing={len(missing)} ahead={len(ahead)} behind={len(behind)} safe-directory={len(safe_needed)}", ] for label, values in ( ("dirty", dirty), ("missing", missing), ("ahead", ahead), ("behind", behind), ("safe-directory", safe_needed), ): if values: lines.append(f" {label}: {', '.join(values)}") if detailed: lines.append(" repositories:") for repo in report.repos: marker = "!" if repo.dirty or not repo.exists or repo.safe_directory_required else "-" version = repo.pyproject_version or repo.package_version or repo.webui_package_version or "no version" branch = repo.branch or "unknown" lines.append(f" {marker} {repo.name}: {branch}, {version}") return lines def print_findings(findings: tuple[Finding, ...], *, detailed: bool = True) -> None: print("\n".join(finding_lines(findings, detailed=detailed))) def finding_lines(findings: tuple[Finding, ...], *, detailed: bool) -> list[str]: if not findings: return ["Findings: none"] counts = { severity: sum(1 for item in findings if item.severity == severity) for severity in ("blocker", "warning", "info") } lines = [ "Findings:", f" blockers={counts['blocker']} warnings={counts['warning']} info={counts['info']}", ] for item in sorted(findings, key=lambda finding: (-SEVERITY_ORDER.get(finding.severity, 0), finding.check_id, finding.title)): lines.append(f" [{item.severity.upper()}] {item.title}") if detailed and item.detail: for line in item.detail.splitlines(): lines.append(f" {line}") return lines def print_next_commands(report: DoctorReport) -> None: print("\n".join(next_command_lines(report))) def next_command_lines(report: DoctorReport) -> list[str]: commands = collect_suggested_commands(report) lines = ["Next Commands:"] if not commands: lines.append(" No next command suggested.") return lines for index, item in enumerate(commands, start=1): mutating = "mutating" if item.mutating else "safe" lines.append(f" {index}. {item.title} ({mutating})") lines.append(f" cd {item.cwd}") lines.append(f" {item.command}") return lines def run_interactive( report: DoctorReport, *, assume_yes: bool = False, workspace_root: Path, target_version: str | None, repo_filter: tuple[str, ...], online: bool, run_migration_audit: bool, ) -> int: while True: print() print_interactive_summary(report) actions = interactive_actions(report) print() print("Choose an action:") for index, (key, title) in enumerate(actions, start=1): print(f" {index}. {title}") print(" q. Quit") choice = input("> ").strip().lower() if choice in {"q", "quit", "exit"}: return 0 if report.overall_status != "blocked" else 1 if not choice.isdigit() or not (1 <= int(choice) <= len(actions)): print("Please enter an action number or q.") continue action = actions[int(choice) - 1][0] if action == "summary": print("\n".join(finding_lines(report.findings, detailed=False))) elif action == "details": page_text(render_text_report(report, detailed=True, include_commands=True)) elif action == "commands": run_suggested_command_menu(report, assume_yes=assume_yes) elif action == "release-tags": run_release_tag_workflow(report) elif action == "safe-directory": run_safe_directory_workflow(report) elif action == "push-ahead": push_all_ahead_repositories(report) elif action == "commit-push-dirty": commit_and_push_dirty_repositories(report) elif action == "refresh": report = build_report( workspace_root=workspace_root, target_version=target_version, repo_filter=repo_filter, online=online, run_migration_audit=run_migration_audit, ) def print_interactive_summary(report: DoctorReport) -> None: dirty = sum(1 for repo in report.repos if repo.dirty) safe_needed = sum(1 for repo in report.repos if repo.safe_directory_required) ahead = sum(1 for repo in report.repos if repo.ahead) blockers = sum(1 for item in report.findings if item.severity == "blocker") warnings = sum(1 for item in report.findings if item.severity == "warning") print(f"Release Doctor: target={report.target_version or '-'} status={report.overall_status.upper()}") print(f"Repos: checked={len(report.repos)} dirty={dirty} ahead={ahead} safe-directory={safe_needed}") print(f"Findings: blockers={blockers} warnings={warnings}") def interactive_actions(report: DoctorReport) -> list[tuple[str, str]]: actions = [ ("summary", "Show concise findings"), ("details", "Open full report/details"), ("commands", "Choose one suggested command"), ] if report.target_version: actions.append(("release-tags", f"Run release tag workflow for v{report.target_version}")) if any(repo.safe_directory_required for repo in report.repos): actions.append(("safe-directory", "Mark dubious-ownership repositories as safe")) if any(repo.ahead and not repo.dirty for repo in report.repos): actions.append(("push-ahead", "Push all clean repositories with unpushed commits")) if any(repo.dirty for repo in report.repos): actions.append(("commit-push-dirty", "Commit and push all dirty repositories")) actions.append(("refresh", "Refresh release doctor state")) return actions def run_suggested_command_menu(report: DoctorReport, *, assume_yes: bool = False) -> None: commands = collect_suggested_commands(report) if not commands: print("No suggested commands to run.") return while True: print() print("Suggested commands:") for index, item in enumerate(commands, start=1): marker = "!" if item.mutating else "-" print(f" {index}. {marker} {item.title}") print(" b. Back") choice = input("> ").strip().lower() if choice in {"b", "back", "q", "quit"}: return if not choice.isdigit() or not (1 <= int(choice) <= len(commands)): print("Please enter a command number or b.") continue run_suggested_command(commands[int(choice) - 1], assume_yes=assume_yes) def run_suggested_command(item: SuggestedCommand, *, assume_yes: bool = False) -> int | None: print(f"cwd: {item.cwd}") print(f"cmd: {item.command}") if item.mutating: confirmation = input("This command may modify repositories or release state. Type RUN to execute: ").strip() if confirmation != "RUN": print("Skipped.") return None elif not assume_yes: confirmation = input("Run this command? [y/N] ").strip().lower() if confirmation not in {"y", "yes"}: print("Skipped.") return None try: argv = command_argv(item) except ValueError as exc: print(f"Cannot run command safely: {exc}") return 2 result = subprocess.run(argv, cwd=item.cwd, check=False) print(f"Command exited with {result.returncode}.") return result.returncode def run_release_tag_workflow(report: DoctorReport) -> None: if not report.target_version: print("No target version is set. Re-run with --target-version .") return print(f"Release tag workflow for v{report.target_version}") print(" 1. Dry run: print the intended commit/tag/push plan") print(" 2. Real run: commit, tag, and push according to the release script") print(" b. Back") choice = input("> ").strip().lower() if choice in {"b", "back", "q", "quit"}: return if choice == "1": run_suggested_command(release_tag_command(report, dry_run=True), assume_yes=False) elif choice == "2": run_suggested_command(release_tag_command(report, dry_run=False), assume_yes=False) else: print("Please enter 1, 2, or b.") def collect_suggested_commands(report: DoctorReport) -> tuple[SuggestedCommand, ...]: seen: set[tuple[str, str]] = set() commands: list[SuggestedCommand] = [] for finding in sorted(report.findings, key=lambda item: (-SEVERITY_ORDER.get(item.severity, 0), item.check_id, item.title)): for item in finding.commands: key = (item.cwd, item.command) if key in seen: continue seen.add(key) commands.append(item) return tuple(commands) def run_safe_directory_workflow(report: DoctorReport) -> None: repos = tuple(repo for repo in report.repos if repo.safe_directory_required and repo.safe_directory_command) if not repos: print("No dubious-ownership repositories detected.") return print("Git refused to inspect these repositories because of dubious ownership:") for repo in repos: print(f" - {repo.name}: {repo.path}") print() print("The doctor can run the corresponding safe.directory commands for the current user.") confirmation = input("Type SAFE to run them: ").strip() if confirmation != "SAFE": print("Skipped.") return for repo in repos: assert repo.safe_directory_command is not None result = subprocess.run(safe_directory_argv(Path(repo.path)), cwd=ROOT, check=False) print(f"{repo.name}: command exited with {result.returncode}.") def push_all_ahead_repositories(report: DoctorReport) -> None: repos = tuple(repo for repo in report.repos if repo.exists and repo.ahead and not repo.dirty and not repo.safe_directory_required) if not repos: print("No clean repositories with unpushed commits were detected.") return print("These clean repositories have local commits to push:") for repo in repos: print(f" - {repo.name}: ahead={repo.ahead}") confirmation = input("Type PUSH to run git push in all of them: ").strip() if confirmation != "PUSH": print("Skipped.") return for repo in repos: result = subprocess.run(["git", "push"], cwd=repo.path, check=False) print(f"{repo.name}: git push exited with {result.returncode}.") def commit_and_push_dirty_repositories(report: DoctorReport) -> None: repos = tuple( repo for repo in report.repos if repo.exists and repo.dirty and not repo.safe_directory_required ) if not repos: print("No dirty repositories were detected.") return blocked = tuple(repo for repo in repos if repo.behind) eligible = tuple(repo for repo in repos if not repo.behind) if blocked: print("These dirty repositories are behind their upstream and will be skipped:") for repo in blocked: print(f" - {repo.name}: behind={repo.behind}") print() if not eligible: print("No dirty repositories are eligible for bulk commit/push.") return print("This action will run `git add -A`, `git commit`, and `git push` in these repositories:") for repo in eligible: print(f" - {repo.name}: {len(repo.dirty_entries)} dirty entr{'y' if len(repo.dirty_entries) == 1 else 'ies'}") print() print("Review the detailed report first if you are not certain every dirty file belongs in one commit per repository.") message = input("Commit message [Release preparation updates]: ").strip() or "Release preparation updates" confirmation = input("Type COMMIT AND PUSH to continue: ").strip() if confirmation != "COMMIT AND PUSH": print("Skipped.") return for repo in eligible: repo_path = Path(repo.path) print(f"\n== {repo.name} ==") add_result = subprocess.run(["git", "add", "-A"], cwd=repo_path, check=False) if add_result.returncode != 0: print(f"git add failed with {add_result.returncode}; skipping push.") continue if subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=repo_path, check=False).returncode == 0: print("No staged changes after git add; skipping.") continue commit_result = subprocess.run(["git", "commit", "-m", message], cwd=repo_path, check=False) if commit_result.returncode != 0: print(f"git commit failed with {commit_result.returncode}; skipping push.") continue push_result = subprocess.run(["git", "push"], cwd=repo_path, check=False) print(f"git push exited with {push_result.returncode}.") def page_text(text: str) -> None: if sys.stdout.isatty(): try: result = subprocess.run(["less", "-R"], input=text, text=True, check=False) if result.returncode != 127: return except OSError: pass print(text) def report_to_dict(report: DoctorReport) -> dict[str, Any]: return asdict(report) def command( cwd: Path, title: str, value: str, *, mutating: bool = False, reason: str = "", argv: tuple[str, ...] = (), ) -> SuggestedCommand: return SuggestedCommand(title=title, command=value, cwd=str(cwd), argv=argv, mutating=mutating, reason=reason) def command_argv(item: SuggestedCommand) -> tuple[str, ...]: if item.argv: return item.argv argv = tuple(shlex.split(item.command)) if not argv: raise ValueError("empty command") if any(_looks_like_shell_syntax(part) for part in argv): raise ValueError("command contains shell syntax and needs an explicit argv definition") return argv def _looks_like_shell_syntax(value: str) -> bool: return any(marker in value for marker in ("|", "&&", "||", ";", "$(", "`", ">", "<")) def release_tag_command(report: DoctorReport, *, dry_run: bool) -> SuggestedCommand: assert report.target_version is not None suffix = " --dry-run" if dry_run else "" argv = ( "tools/release/push-release-tag.sh", "--version", report.target_version, "--strict-migration-audit", ) + (("--dry-run",) if dry_run else ()) return command( META_ROOT, "Preview release tagging workflow" if dry_run else "Run release tagging workflow", f"tools/release/push-release-tag.sh --version {shlex.quote(report.target_version)} --strict-migration-audit{suffix}", mutating=not dry_run, argv=argv, ) def migration_audit_command(track: str, *, strict: bool = False) -> SuggestedCommand: extra = " --strict" if strict else "" argv = (python_bin_path(), "tools/release/release-migration-audit.py", "--track", track) + (("--strict",) if strict else ()) return command(META_ROOT, f"Run {track} migration audit", f"{python_bin()} tools/release/release-migration-audit.py --track {track}{extra}", argv=argv) def publish_catalog_command(target_version: str | None) -> SuggestedCommand: version = shlex.quote(target_version or "") display_version = target_version or "" key_dir = Path.home() / ".config" / "govoplan" / "release-keys" return command( META_ROOT, "Publish signed release catalog", "KEY_DIR=\"$HOME/.config/govoplan/release-keys\" " f"tools/release/publish-release-catalog.sh --version {version} " "--catalog-signing-key \"release-key-1=$KEY_DIR/release-key-1.pem\" --build-web", mutating=True, argv=( "tools/release/publish-release-catalog.sh", "--version", display_version, "--catalog-signing-key", f"release-key-1={key_dir / 'release-key-1.pem'}", "--build-web", ), ) def python_bin() -> str: return shlex.quote(python_bin_path()) def python_bin_path() -> str: local = META_ROOT / ".venv" / "bin" / "python" return str(local if local.exists() else Path(sys.executable)) def normalize_version(value: str) -> str: return value.removeprefix("v").strip() def read_pyproject_version(path: Path) -> str | None: pyproject = path / "pyproject.toml" if not pyproject.exists(): return None with pyproject.open("rb") as handle: payload = tomllib.load(handle) project = payload.get("project") if isinstance(project, dict) and isinstance(project.get("version"), str): return project["version"] return None def read_json_version(path: Path) -> str | None: if not path.exists(): return None payload = json.loads(path.read_text(encoding="utf-8")) value = payload.get("version") if isinstance(payload, dict) else None return value if isinstance(value, str) else None def git_text(repo: Path, *args: str, check: bool = True) -> str: result = run_subprocess(["git", "-C", str(repo), *args], cwd=repo, timeout=15) if check and result.returncode != 0: raise GitCommandError( result.stderr.strip() or result.stdout.strip() or f"git {' '.join(args)} failed", result=result, ) if result.returncode != 0: return "" return result.stdout.strip() def git_success(repo: Path, *args: str, timeout: int = 15) -> bool: return run_subprocess(["git", "-C", str(repo), *args], cwd=repo, timeout=timeout).returncode == 0 def run_subprocess(argv: list[str], *, cwd: Path, timeout: int) -> CommandResult: try: result = subprocess.run( argv, cwd=cwd, capture_output=True, text=True, timeout=timeout, check=False, ) except subprocess.TimeoutExpired as exc: return CommandResult( returncode=124, stdout=exc.stdout if isinstance(exc.stdout, str) else "", stderr=f"Timed out after {timeout}s: {' '.join(argv)}", ) except OSError as exc: return CommandResult(returncode=127, stdout="", stderr=str(exc)) return CommandResult(returncode=result.returncode, stdout=result.stdout, stderr=result.stderr) def git_error_text(result: CommandResult) -> str: return "\n".join(part for part in (result.stderr.strip(), result.stdout.strip()) if part) def compact_git_error(text: str) -> str: lines = [line.strip() for line in text.splitlines() if line.strip()] if not lines: return "git command failed" return " ".join(lines[:4]) def is_dubious_ownership_error(text: str) -> bool: lowered = text.lower() return "detected dubious ownership" in lowered and "safe.directory" in lowered def safe_directory_command(path: Path) -> str: return "git config --global --add safe.directory " + shlex.quote(str(path.resolve())) def safe_directory_argv(path: Path) -> list[str]: return ["git", "config", "--global", "--add", "safe.directory", str(path.resolve())] def check_url(url: str) -> dict[str, Any]: try: checked_url = validate_http_url(url) response = fetch_http(checked_url, timeout=8, method="HEAD") return {"ok": 200 <= response.status < 400, "status": response.status, "url": checked_url} except (ValueError, OSError) as exc: return {"ok": False, "error": str(exc), "url": url} if __name__ == "__main__": raise SystemExit(main())