gabriel / musehub public

backfill_marketplace_domain_links.py file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:e fix(9A-4 F7): env alias + staging secrets for overseer provenance gate.… · aaronrene · Jul 13, 2026
1 #!/usr/bin/env python3
2 """Backfill `marketplace_domain_id` for repos created before musehub#120
3 Phase 2 shipped β€” one-time, idempotent, never guesses.
4
5 Every repo whose `marketplace_domain_id` is still NULL is re-resolved via
6 the same `resolve_unambiguous_domain_id_by_category` helper Phase 2 wired
7 into `create_repo` β€” a repo is only linked when exactly one non-deprecated
8 marketplace domain matches its plain category string (e.g. "code").
9 Ambiguous (2+ candidates) or unmatched (0 candidates) repos are left NULL
10 and reported explicitly by `repo_id`, never silently skipped.
11
12 Usage:
13 python3 scripts/backfill_marketplace_domain_links.py # dry-run (default)
14 python3 scripts/backfill_marketplace_domain_links.py --apply # perform the writes
15
16 Connects via the app's configured DATABASE_URL (same as the running
17 server) β€” run locally against local Postgres, or inside the container to
18 backfill a deployed environment:
19
20 docker exec musehub python3 scripts/backfill_marketplace_domain_links.py --apply
21 """
22 from __future__ import annotations
23
24 import argparse
25 import asyncio
26 from dataclasses import dataclass, field
27
28 from sqlalchemy import func, select
29 from sqlalchemy.ext.asyncio import AsyncSession
30
31 from musehub.db.musehub_domain_models import MusehubDomain
32 from musehub.db.musehub_repo_models import MusehubRepo
33 from musehub.services.musehub_domains import (
34 record_domain_install,
35 resolve_unambiguous_domain_id_by_category,
36 )
37
38
39 @dataclass
40 class BackfillReport:
41 """Every repo the backfill considered, bucketed by outcome β€” never a
42 bare count. `linked`/`skipped_ambiguous`/`skipped_no_match` are lists
43 of `repo_id` so nothing is silently under-reported."""
44
45 linked: list[str] = field(default_factory=list)
46 skipped_ambiguous: list[str] = field(default_factory=list)
47 skipped_no_match: list[str] = field(default_factory=list)
48
49
50 async def backfill_marketplace_domain_links(
51 session: AsyncSession,
52 *,
53 apply: bool,
54 ) -> BackfillReport:
55 """Resolve and (optionally) write `marketplace_domain_id` for every
56 repo that doesn't have one yet.
57
58 ``apply=False`` (dry-run): computes and returns the report but writes
59 nothing β€” no `session.commit()`, no ORM attribute mutation.
60 ``apply=True``: writes the resolved link and calls
61 ``record_domain_install`` once per linked repo, matching what
62 `create_repo` already does at creation time (musehub#120 Phase 2), so
63 `install_count` is correct after the backfill too. Caller commits.
64
65 Idempotent: only repos with `marketplace_domain_id IS NULL` are ever
66 considered β€” a repo linked by a prior run (or by `create_repo` in the
67 interim) is untouched by construction, not by a special-case check.
68 """
69 report = BackfillReport()
70
71 stmt = select(MusehubRepo).where(MusehubRepo.marketplace_domain_id.is_(None))
72 result = await session.execute(stmt)
73 repos = result.scalars().all()
74
75 for repo in repos:
76 if repo.domain_id is None:
77 # No category at all to resolve against β€” same "no match"
78 # bucket as a category with zero live marketplace domains.
79 report.skipped_no_match.append(repo.repo_id)
80 continue
81
82 resolved = await resolve_unambiguous_domain_id_by_category(session, repo.domain_id)
83 if resolved is None:
84 candidate_count_stmt = select(func.count()).select_from(MusehubDomain).where(
85 MusehubDomain.slug == repo.domain_id,
86 MusehubDomain.is_deprecated.is_(False),
87 )
88 candidate_count = (await session.execute(candidate_count_stmt)).scalar_one()
89 if candidate_count >= 2:
90 report.skipped_ambiguous.append(repo.repo_id)
91 else:
92 report.skipped_no_match.append(repo.repo_id)
93 continue
94
95 report.linked.append(repo.repo_id)
96 if apply:
97 repo.marketplace_domain_id = resolved
98 await record_domain_install(session, repo.owner_user_id, resolved)
99
100 return report
101
102
103 def _print_report(report: BackfillReport, apply: bool) -> None:
104 mode = "APPLY" if apply else "DRY-RUN"
105 print(f"\nMarketplace domain link backfill β€” {mode}")
106 print(f" linked : {len(report.linked)}")
107 for repo_id in report.linked:
108 print(f" + {repo_id}")
109 print(f" skipped (ambiguous): {len(report.skipped_ambiguous)}")
110 for repo_id in report.skipped_ambiguous:
111 print(f" ~ {repo_id}")
112 print(f" skipped (no match) : {len(report.skipped_no_match)}")
113 for repo_id in report.skipped_no_match:
114 print(f" ~ {repo_id}")
115 print()
116 if not apply and report.linked:
117 print("Dry-run only β€” re-run with --apply to write these links.\n")
118
119
120 async def _main_async(apply: bool) -> BackfillReport:
121 from musehub.db.database import AsyncSessionLocal, close_db, init_db
122
123 await init_db()
124 try:
125 async with AsyncSessionLocal() as session:
126 report = await backfill_marketplace_domain_links(session, apply=apply)
127 if apply:
128 await session.commit()
129 else:
130 await session.rollback()
131 finally:
132 await close_db()
133 return report
134
135
136 def main() -> None:
137 parser = argparse.ArgumentParser(
138 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
139 )
140 parser.add_argument(
141 "--apply", action="store_true", help="Perform the writes (default: dry-run, no writes)."
142 )
143 args = parser.parse_args()
144
145 report = asyncio.run(_main_async(args.apply))
146 _print_report(report, args.apply)
147
148
149 if __name__ == "__main__":
150 main()