1224 lines
50 KiB
Python
1224 lines
50 KiB
Python
#!/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 urllib.error import URLError
|
|
from urllib.parse import urlparse
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
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 <x.y.z>.")
|
|
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 "<x.y.z>")
|
|
display_version = target_version or "<x.y.z>"
|
|
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]:
|
|
parsed = urlparse(url)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
return {"ok": False, "error": "URL must use http:// or https:// with a hostname", "url": url}
|
|
request = Request(url, method="HEAD")
|
|
try:
|
|
with urlopen(request, timeout=8) as response: # noqa: S310 - validated operator-requested release availability check. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
|
return {"ok": 200 <= response.status < 400, "status": response.status, "url": url}
|
|
except URLError as exc:
|
|
return {"ok": False, "error": str(exc), "url": url}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|