#!/usr/bin/env python3 """Backfill `marketplace_domain_id` for repos created before musehub#120 Phase 2 shipped — one-time, idempotent, never guesses. Every repo whose `marketplace_domain_id` is still NULL is re-resolved via the same `resolve_unambiguous_domain_id_by_category` helper Phase 2 wired into `create_repo` — a repo is only linked when exactly one non-deprecated marketplace domain matches its plain category string (e.g. "code"). Ambiguous (2+ candidates) or unmatched (0 candidates) repos are left NULL and reported explicitly by `repo_id`, never silently skipped. Usage: python3 scripts/backfill_marketplace_domain_links.py # dry-run (default) python3 scripts/backfill_marketplace_domain_links.py --apply # perform the writes Connects via the app's configured DATABASE_URL (same as the running server) — run locally against local Postgres, or inside the container to backfill a deployed environment: docker exec musehub python3 scripts/backfill_marketplace_domain_links.py --apply """ from __future__ import annotations import argparse import asyncio from dataclasses import dataclass, field from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from musehub.db.musehub_domain_models import MusehubDomain from musehub.db.musehub_repo_models import MusehubRepo from musehub.services.musehub_domains import ( record_domain_install, resolve_unambiguous_domain_id_by_category, ) @dataclass class BackfillReport: """Every repo the backfill considered, bucketed by outcome — never a bare count. `linked`/`skipped_ambiguous`/`skipped_no_match` are lists of `repo_id` so nothing is silently under-reported.""" linked: list[str] = field(default_factory=list) skipped_ambiguous: list[str] = field(default_factory=list) skipped_no_match: list[str] = field(default_factory=list) async def backfill_marketplace_domain_links( session: AsyncSession, *, apply: bool, ) -> BackfillReport: """Resolve and (optionally) write `marketplace_domain_id` for every repo that doesn't have one yet. ``apply=False`` (dry-run): computes and returns the report but writes nothing — no `session.commit()`, no ORM attribute mutation. ``apply=True``: writes the resolved link and calls ``record_domain_install`` once per linked repo, matching what `create_repo` already does at creation time (musehub#120 Phase 2), so `install_count` is correct after the backfill too. Caller commits. Idempotent: only repos with `marketplace_domain_id IS NULL` are ever considered — a repo linked by a prior run (or by `create_repo` in the interim) is untouched by construction, not by a special-case check. """ report = BackfillReport() stmt = select(MusehubRepo).where(MusehubRepo.marketplace_domain_id.is_(None)) result = await session.execute(stmt) repos = result.scalars().all() for repo in repos: if repo.domain_id is None: # No category at all to resolve against — same "no match" # bucket as a category with zero live marketplace domains. report.skipped_no_match.append(repo.repo_id) continue resolved = await resolve_unambiguous_domain_id_by_category(session, repo.domain_id) if resolved is None: candidate_count_stmt = select(func.count()).select_from(MusehubDomain).where( MusehubDomain.slug == repo.domain_id, MusehubDomain.is_deprecated.is_(False), ) candidate_count = (await session.execute(candidate_count_stmt)).scalar_one() if candidate_count >= 2: report.skipped_ambiguous.append(repo.repo_id) else: report.skipped_no_match.append(repo.repo_id) continue report.linked.append(repo.repo_id) if apply: repo.marketplace_domain_id = resolved await record_domain_install(session, repo.owner_user_id, resolved) return report def _print_report(report: BackfillReport, apply: bool) -> None: mode = "APPLY" if apply else "DRY-RUN" print(f"\nMarketplace domain link backfill — {mode}") print(f" linked : {len(report.linked)}") for repo_id in report.linked: print(f" + {repo_id}") print(f" skipped (ambiguous): {len(report.skipped_ambiguous)}") for repo_id in report.skipped_ambiguous: print(f" ~ {repo_id}") print(f" skipped (no match) : {len(report.skipped_no_match)}") for repo_id in report.skipped_no_match: print(f" ~ {repo_id}") print() if not apply and report.linked: print("Dry-run only — re-run with --apply to write these links.\n") async def _main_async(apply: bool) -> BackfillReport: from musehub.db.database import AsyncSessionLocal, close_db, init_db await init_db() try: async with AsyncSessionLocal() as session: report = await backfill_marketplace_domain_links(session, apply=apply) if apply: await session.commit() else: await session.rollback() finally: await close_db() return report def main() -> None: parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument( "--apply", action="store_true", help="Perform the writes (default: dry-run, no writes)." ) args = parser.parse_args() report = asyncio.run(_main_async(args.apply)) _print_report(report, args.apply) if __name__ == "__main__": main()