repo-manager/tests/test_repo_onboard.py
tegwick 1be6d85397 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
2026-08-24 23:33:22 +02:00

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