From 1be6d853970ea36818f31e0c50bcbad82bf75678 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 24 Aug 2026 23:33:22 +0200 Subject: [PATCH] feat(onboard): add rmgr repo-onboard, the missing managed_repos write path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hub-record-authority.yaml assigns managed_repos to repo-manager as file-derived, but repo-manager exposed no command for it. The only working path lived in the State Hub repo and defaulted to 127.0.0.1:8000, which is how seven weeks of onboarding landed in a local cache instead of central. Order follows ADR-010 decision 5: make the source file correct and reachable first, then project it. Refuses to onboard when the classification file is missing or invalid, has uncommitted changes, has no upstream, or has unpushed commits — a hub record whose backing file is only local cannot be re-derived by anyone else. --api-base has no default on purpose. A silent localhost default is the original defect, not a convenience. A failed classification PATCH degrades to a warning rather than failing the run: the authoritative record existing is what stops a repository from being recoverable only through a discardable cache, and classification is a projection of a committed file that can be re-derived later. Refs CUST-WP-0067-T04 Co-Authored-By: Claude Opus 5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 2583210@bnt-lap001 Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006 --- src/repo_manager/cli.py | 43 +++++ src/repo_manager/commands/repo_onboard.py | 216 ++++++++++++++++++++++ tests/test_repo_onboard.py | 98 ++++++++++ 3 files changed, 357 insertions(+) create mode 100644 src/repo_manager/commands/repo_onboard.py create mode 100644 tests/test_repo_onboard.py 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