diff --git a/src/repo_manager/cli.py b/src/repo_manager/cli.py index 4d6e1f3..fa1df44 100644 --- a/src/repo_manager/cli.py +++ b/src/repo_manager/cli.py @@ -5,6 +5,7 @@ from __future__ import annotations import argparse import json import os +import sys from pathlib import Path from repo_manager.commands.rapp import add_rapp_parser @@ -76,6 +77,25 @@ def main(argv: list[str] | None = None) -> int: help="Rebuild all authoritative UUIDs after proving the repo projection is empty", ) + p_onboard = sub.add_parser( + "repo-onboard", + help="Onboard a repository into its authoritative State Hub from its classification file", + ) + p_onboard.add_argument("--path", default=".", help="Repository checkout path") + p_onboard.add_argument("--slug", default=None, help="Override the repo slug (default: directory name)") + p_onboard.add_argument( + "--api-base", + default=os.environ.get("STATE_HUB_API_BASE"), + help="Authoritative State Hub API base URL (or set STATE_HUB_API_BASE). " + "Deliberately has no default: a silent localhost default is how " + "onboarding reached a cache instead of central for seven weeks.", + ) + p_onboard.add_argument( + "--dry-run", + action="store_true", + help="Report what would change without writing to the hub", + ) + p_cmd = sub.add_parser( "update-task-status", help="Command repo.work.update_task_status (file + git commit)", @@ -504,6 +524,29 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(result.to_dict(), indent=2)) return 0 if result.status in {"applied", "noop"} else 1 + if args.command == "repo-onboard": + from repo_manager.commands.repo_onboard import OnboardError, onboard_repo + + if not args.api_base: + print( + "ERROR: --api-base is required (or set STATE_HUB_API_BASE). " + "Point it at the authoritative hub, not a local cache.", + file=sys.stderr, + ) + return 2 + try: + report = onboard_repo( + Path(args.path), + api_base=args.api_base, + dry_run=args.dry_run, + slug=args.slug, + ) + except OnboardError as exc: + print(json.dumps({"status": "blocked", "error": str(exc)}, indent=2)) + return 1 + print(json.dumps(report, indent=2)) + return 0 + if args.command == "update-task-status": from repo_manager.commands.task_status import update_task_status diff --git a/src/repo_manager/commands/repo_onboard.py b/src/repo_manager/commands/repo_onboard.py new file mode 100644 index 0000000..c9446c2 --- /dev/null +++ b/src/repo_manager/commands/repo_onboard.py @@ -0,0 +1,216 @@ +"""Onboard a repository into its authoritative State Hub (CUST-WP-0067-T04). + +`hub-record-authority.yaml` assigns ``managed_repos`` to repo-manager as +``file-derived``: the repository's committed ``.repo-classification.yaml`` is +the source of truth and the hub holds a projection of it. Repo Manager owned +that record type without exposing any command for it, so the only working path +lived in the State Hub repo and defaulted to a local address — which is how +seven weeks of onboarding landed in a local cache instead of central. + +The order here is deliberate and follows ADR-010 decision 5: make the source +file correct and *reachable* first, then project it. A hub record whose backing +file is not pushed cannot be re-derived by anyone else. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import httpx +import yaml + +from repo_manager.classification import validate_classification +from repo_manager.gitops import head_sha, is_git_repo + +CLASSIFICATION_FILENAME = ".repo-classification.yaml" + + +class OnboardError(RuntimeError): + """Onboarding cannot proceed and the operator must decide what to do.""" + + +@dataclass +class OnboardResult: + slug: str + action: str = "unknown" + steps: list[dict[str, Any]] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + def step(self, name: str, ok: bool, **detail: Any) -> None: + self.steps.append({"step": name, "ok": ok, **detail}) + + def to_dict(self) -> dict[str, Any]: + return { + "schema": "repo-manager.repo-onboard.v1", + "slug": self.slug, + "action": self.action, + "steps": self.steps, + "warnings": self.warnings, + } + + +def _git(repo: Path, *args: str) -> str: + import subprocess + + proc = subprocess.run( + ["git", "-C", str(repo), *args], capture_output=True, text=True, check=False + ) + return proc.stdout.strip() if proc.returncode == 0 else "" + + +def _load_classification(repo: Path) -> dict[str, Any]: + path = repo / CLASSIFICATION_FILENAME + if not path.is_file(): + raise OnboardError( + f"{CLASSIFICATION_FILENAME} is missing in {repo}. Classification is not " + "mechanical — author it with the repository owner rather than guessing " + "(see CUST-WP-0065-T01)." + ) + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + data = raw.get("repo_classification", raw) + if not isinstance(data, dict): + raise OnboardError(f"{CLASSIFICATION_FILENAME} does not contain a mapping") + issues = validate_classification(data) + if issues: + detail = "; ".join(f"{i.field}: {i.message}" for i in issues) + raise OnboardError(f"{CLASSIFICATION_FILENAME} is invalid: {detail}") + return data + + +def _source_is_reachable(repo: Path, result: OnboardResult) -> None: + """The backing file must be committed and pushed, or nothing can re-derive it.""" + dirty = _git(repo, "status", "--porcelain", "--", CLASSIFICATION_FILENAME) + if dirty: + raise OnboardError( + f"{CLASSIFICATION_FILENAME} has uncommitted changes. Commit it first — " + "the commit is the write (ADR-010 decision 4)." + ) + + upstream = _git(repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") + if not upstream: + raise OnboardError( + "no upstream branch configured; central derives from what it can fetch" + ) + ahead = _git(repo, "rev-list", "--count", "@{u}..HEAD") + if ahead and ahead != "0": + raise OnboardError( + f"{ahead} commit(s) not pushed. Push before onboarding — a hub record " + "whose source is only local cannot be re-derived." + ) + result.step("source_reachable", True, upstream=upstream, head=head_sha(repo)) + + +def _hub(api_base: str, method: str, path: str, body: dict | None = None) -> tuple[int, Any]: + url = api_base.rstrip("/") + path + try: + with httpx.Client(timeout=30.0) as client: + response = client.request(method, url, json=body) + except httpx.HTTPError as exc: + raise OnboardError(f"cannot reach hub at {api_base}: {exc}") from exc + if response.status_code == 204: + return response.status_code, None + try: + return response.status_code, response.json() + except ValueError: + return response.status_code, {"_raw": response.text} + + +def _classification_body(data: dict[str, Any]) -> dict[str, Any]: + keys = ( + "category", + "secondary_domains", + "capability_tags", + "business_stake", + "business_mechanics", + "classified_at", + "classified_by", + "standard_version", + ) + return {k: data[k] for k in keys if data.get(k) is not None} + + +def onboard_repo( + repo_path: Path, + *, + api_base: str, + dry_run: bool = False, + slug: str | None = None, +) -> dict[str, Any]: + repo = Path(repo_path).expanduser().resolve() + if not is_git_repo(repo): + raise OnboardError(f"{repo} is not a git working copy") + + slug = slug or repo.name + result = OnboardResult(slug=slug) + + data = _load_classification(repo) + result.step("classification_valid", True, domain=data.get("domain"), category=data.get("category")) + + _source_is_reachable(repo, result) + + remote = _git(repo, "remote", "get-url", "origin") or None + + code, existing = _hub(api_base, "GET", f"/repos/{slug}") + exists = code == 200 + + if dry_run: + result.action = "would-update" if exists else "would-create" + result.step("hub_projection", True, dry_run=True, exists=exists, api_base=api_base) + return result.to_dict() + + if not exists: + code, created = _hub( + api_base, + "POST", + "/repos/", + { + "slug": slug, + "name": slug, + "domain_slug": data["domain"], + "local_path": str(repo), + "remote_url": remote, + }, + ) + if code not in (200, 201): + raise OnboardError(f"POST /repos/ failed ({code}): {created}") + result.action = "created" + result.step("hub_create", True, status=code) + else: + result.action = "updated" + + code, patched = _hub(api_base, "PATCH", f"/repos/{slug}", _classification_body(data)) + if code in (200, 201): + result.step("hub_classification", True, status=code) + else: + # The authoritative record now exists, which is what stops the repository + # from being recoverable only through a discardable cache. Classification + # is a projection of a file that is committed and pushed, so it can be + # re-derived at any time — do not fail the whole onboarding for it. + result.step("hub_classification", False, status=code, detail=patched) + result.warnings.append( + f"classification not projected (PATCH returned {code}); the record exists " + "and the file remains the source of truth, so re-run once the hub can " + "validate classification" + ) + + # Verify against the hub rather than trusting our own write. + code, verified = _hub(api_base, "GET", f"/repos/{slug}") + if code != 200: + raise OnboardError(f"verification failed: GET /repos/{slug} returned {code}") + if verified.get("category") is None and not any( + st["step"] == "hub_classification" and st["ok"] for st in result.steps + ): + result.action = f"{result.action}-unclassified" + if verified.get("domain_slug") != data["domain"]: + result.warnings.append( + f"hub reports domain_slug={verified.get('domain_slug')!r}, file says {data['domain']!r}" + ) + result.step("verified", True, domain_slug=verified.get("domain_slug")) + return result.to_dict() + + +def render(report: dict[str, Any]) -> str: + return json.dumps(report, indent=2) diff --git a/tests/test_repo_onboard.py b/tests/test_repo_onboard.py new file mode 100644 index 0000000..d399700 --- /dev/null +++ b/tests/test_repo_onboard.py @@ -0,0 +1,98 @@ +"""Onboarding guards (CUST-WP-0067-T04). + +The guards matter more than the happy path: onboarding that silently accepts an +unpushed or unclassified repository is what produces hub records nobody can +re-derive. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from repo_manager.commands import repo_onboard as ro + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True) + + +def _repo(tmp_path: Path, *, classification: str | None = None) -> Path: + repo = tmp_path / "demo-repo" + repo.mkdir() + _git(repo, "init", "-q", "-b", "main") + _git(repo, "config", "user.email", "t@example.com") + _git(repo, "config", "user.name", "t") + (repo / "README.md").write_text("x\n", encoding="utf-8") + if classification is not None: + (repo / ro.CLASSIFICATION_FILENAME).write_text(classification, encoding="utf-8") + _git(repo, "add", "-A") + _git(repo, "commit", "-qm", "init") + return repo + + +VALID = """repo_classification: + category: tooling + domain: infotech +""" + + +def test_missing_classification_is_blocked(tmp_path): + repo = _repo(tmp_path) + with pytest.raises(ro.OnboardError, match="is missing"): + ro.onboard_repo(repo, api_base="http://hub.invalid", dry_run=True) + + +def test_invalid_classification_is_blocked(tmp_path): + repo = _repo(tmp_path, classification="repo_classification:\n category: nope\n domain: bogus\n") + with pytest.raises(ro.OnboardError, match="invalid"): + ro.onboard_repo(repo, api_base="http://hub.invalid", dry_run=True) + + +def test_no_upstream_is_blocked(tmp_path): + """Central derives from what it can fetch, so a local-only branch cannot onboard.""" + repo = _repo(tmp_path, classification=VALID) + with pytest.raises(ro.OnboardError, match="upstream"): + ro.onboard_repo(repo, api_base="http://hub.invalid", dry_run=True) + + +def test_uncommitted_classification_is_blocked(tmp_path): + repo = _repo(tmp_path, classification=VALID) + (repo / ro.CLASSIFICATION_FILENAME).write_text(VALID + " capability_tags: [extra]\n", encoding="utf-8") + with pytest.raises(ro.OnboardError, match="uncommitted"): + ro.onboard_repo(repo, api_base="http://hub.invalid", dry_run=True) + + +def test_unpushed_commits_are_blocked(tmp_path): + """A record whose backing file is only local cannot be re-derived by anyone.""" + origin = tmp_path / "origin.git" + subprocess.run(["git", "init", "-q", "--bare", str(origin)], check=True, capture_output=True) + repo = _repo(tmp_path, classification=VALID) + _git(repo, "remote", "add", "origin", str(origin)) + _git(repo, "push", "-q", "-u", "origin", "main") + (repo / "extra.txt").write_text("y\n", encoding="utf-8") + _git(repo, "add", "-A") + _git(repo, "commit", "-qm", "later") + with pytest.raises(ro.OnboardError, match="not pushed"): + ro.onboard_repo(repo, api_base="http://hub.invalid", dry_run=True) + + +def test_dry_run_reports_without_writing(tmp_path, monkeypatch): + origin = tmp_path / "origin.git" + subprocess.run(["git", "init", "-q", "--bare", str(origin)], check=True, capture_output=True) + repo = _repo(tmp_path, classification=VALID) + _git(repo, "remote", "add", "origin", str(origin)) + _git(repo, "push", "-q", "-u", "origin", "main") + + calls: list[tuple[str, str]] = [] + + def fake_hub(api_base, method, path, body=None): + calls.append((method, path)) + return 404, {} + + monkeypatch.setattr(ro, "_hub", fake_hub) + report = ro.onboard_repo(repo, api_base="http://hub.invalid", dry_run=True, slug="demo") + assert report["action"] == "would-create" + assert all(method == "GET" for method, _ in calls), calls