diff --git a/src/repo_manager/commands/registrar_reconcile.py b/src/repo_manager/commands/registrar_reconcile.py index b3606b1..2108322 100644 --- a/src/repo_manager/commands/registrar_reconcile.py +++ b/src/repo_manager/commands/registrar_reconcile.py @@ -102,6 +102,43 @@ def _missing_identifiers(repo: Path) -> dict[str, list[str]]: } +def _absent_from_projection(repo: Path, api_base: str) -> list[str]: + """Workplans whose identifier is in the file but not on the authoritative hub. + + These are registration work, but `_missing_identifiers` cannot see them: the + file *has* an identifier, so nothing is missing by that measure and the run + short-circuits to `noop`. C-06 handles exactly this case — an authoritative + identifier absent from a projection — and marks it fixable, so the only + thing standing between these records and registration was the early return + (CUST-WP-0068-T06). + + Only reports records the hub genuinely does not hold. A record the hub holds + under a *different* identifier is a duplicate registration, which is an + identity decision and deliberately not handled here. + """ + workplans_dir = repo / "workplans" + if not workplans_dir.is_dir(): + return [] + absent: list[str] = [] + for path in sorted(workplans_dir.glob("*.md")): + parsed = parse_workplan_file(path, repo_root=repo) + if parsed.frontmatter.get("type") != "workplan" or not parsed.id: + continue + if not parsed.state_hub_workstream_id: + continue + try: + response = httpx.get( + f"{api_base.rstrip('/')}/workplans/{parsed.state_hub_workstream_id}", + timeout=10.0, + ) + except httpx.HTTPError: + # Never invent registration work from a transport failure. + return [] + if response.status_code == 404: + absent.append(parsed.id) + return absent + + def _requested_identity_findings( identity: dict[str, Any], requested: dict[str, list[str]], @@ -577,7 +614,16 @@ def registrar_reconcile( }, cid, ) - if not any(before.values()) and not repair_projection_id and not bootstrap_empty_projection: + absent = _absent_from_projection(repo, api_base) + if absent: + evidence["absent_from_projection"] = absent + + if ( + not any(before.values()) + and not absent + and not repair_projection_id + and not bootstrap_empty_projection + ): evidence["missing_after"] = before # Bind on this path too. Nothing needs minting, but a previous run may # have minted and failed to bind — without this, such records stay diff --git a/src/repo_manager/commands/repo_onboard.py b/src/repo_manager/commands/repo_onboard.py index c9446c2..265ce0e 100644 --- a/src/repo_manager/commands/repo_onboard.py +++ b/src/repo_manager/commands/repo_onboard.py @@ -181,6 +181,19 @@ def onboard_repo( else: result.action = "updated" + # Correct a stale remote before anything else depends on it. The forge + # migration moved every repository from Gitea to Forgejo but never updated + # the hub, leaving 50 records pointing at a retired forge — and + # `fix-consistency` matches a checkout to its record *by remote_url*, so it + # could not find them and refused to register any of their work records. + # The working copy's origin is the derivable truth (CUST-WP-0068-T06). + if remote and existing and existing.get("remote_url") != remote: + code, _ = _hub(api_base, "PATCH", f"/repos/{slug}", {"remote_url": remote}) + result.step( + "remote_url_corrected", code in (200, 201), + was=existing.get("remote_url"), now=remote, status=code, + ) + code, patched = _hub(api_base, "PATCH", f"/repos/{slug}", _classification_body(data)) if code in (200, 201): result.step("hub_classification", True, status=code)