feat(onboard): add rmgr repo-onboard, the missing managed_repos write path

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 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
This commit is contained in:
tegwick 2026-08-24 23:33:22 +02:00
parent 885575802c
commit 1be6d85397
3 changed files with 357 additions and 0 deletions

View file

@ -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

View file

@ -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)

View file

@ -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