From cdd5cef37372dd4ae7d603aa4098c97ab969b0c1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 26 Aug 2026 02:11:23 +0200 Subject: [PATCH] fix(backfill): source task identities from the forge, not a workstation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first implementation took local filesystem paths. Central has no workstation checkout and must not depend on one: ADR-012 decision 1 makes the forge the projection source, and a backfill reading someone's laptop would reintroduce the exact coupling that ADR removes. Surfaced concretely — central's postgres is not reachable from the workstation (only the API tunnel, which is HTTP), so the local-path variant cannot reach the database it needs to update, while the pod can clone the forge and already holds the connection. A repository that cannot be cloned contributes nothing rather than reducing what the rest can identify. Refs STATE-WP-0083-T06 Co-Authored-By: Claude Opus 5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 2583210@bnt-lap001 Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006 --- api/services/task_record_id_backfill.py | 75 +++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/api/services/task_record_id_backfill.py b/api/services/task_record_id_backfill.py index f37aecb..c7d43ec 100644 --- a/api/services/task_record_id_backfill.py +++ b/api/services/task_record_id_backfill.py @@ -114,3 +114,78 @@ async def backfill_task_record_ids( if not dry_run: row.record_id = record_id return report + + +async def backfill_from_forge( + session: Any, + repo_slugs: list[str], + *, + forge_base: str | None = None, + dry_run: bool = True, +) -> BackfillReport: + """Backfill from repositories cloned out of the forge. + + The local-path variant above needs a workstation checkout, which central + does not have and should not depend on: `ADR-012` decision 1 makes the forge + the projection source, and a backfill sourced from someone's laptop would + reintroduce exactly the coupling that ADR removes. + + Central can clone the forge directly, so it reads the pairing from the same + place it derives everything else. + """ + import tempfile + + from api.services.forge_projection import ( + DEFAULT_FORGE_BASE, + ForgeDeriveError, + _run_git, + ) + + base = forge_base or DEFAULT_FORGE_BASE + report = BackfillReport() + pairs: dict[str, str] = {} + + for slug in repo_slugs: + url = f"{base.rstrip('/')}/{slug}.git" + with tempfile.TemporaryDirectory(prefix=f"backfill-{slug}-") as tmp: + try: + _run_git("clone", "--depth", "1", "--quiet", url, tmp) + except (ForgeDeriveError, Exception): + # A repository that cannot be read contributes nothing. It must + # not silently reduce what the rest can identify. + continue + repo_pairs, repo_report = collect_pairs([Path(tmp)]) + report.scanned_files += repo_report.scanned_files + report.conflicts.extend(repo_report.conflicts) + for uid, rid in repo_pairs.items(): + prior = pairs.get(uid) + if prior and prior != rid: + report.conflicts.append({"uuid": uid, "first": prior, "second": rid}) + continue + pairs[uid] = rid + + report.pairs_found = len(pairs) + if not pairs: + return report + + from sqlalchemy import select + + from api.models.task import Task + + rows = list((await session.execute(select(Task))).scalars()) + by_id = {str(r.id): r for r in rows} + for uid, rid in pairs.items(): + row = by_id.get(uid) + if row is None: + report.unmatched_uuids += 1 + continue + if row.record_id == rid: + report.already_set += 1 + continue + if row.record_id and row.record_id != rid: + report.conflicts.append({"uuid": uid, "first": row.record_id, "second": rid}) + continue + report.updated += 1 + if not dry_run: + row.record_id = rid + return report