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
98 lines
3.7 KiB
Python
98 lines
3.7 KiB
Python
"""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
|