gabriel / musehub public
seed_pull_requests.py python
751 lines 30.6 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """Seed pull requests with full lifecycle coverage.
2
3 Inserts 8+ pull requests per existing seeded repo covering:
4 - Open PRs (active, awaiting review)
5 - Merged PRs (with merge commit references and timestamps)
6 - Closed/rejected PRs (declined without merge)
7 - Conflict scenarios (two branches editing the same measure range)
8 - Cross-repo PRs (fork → upstream)
9
10 Lifecycle states:
11 open — PR submitted, awaiting review or merge
12 merged — branch merged; merge_commit_id and merged_at populated
13 closed — PR closed without merge (rejected, withdrawn, superseded)
14
15 Conflict scenarios are documented inline: two branches that edit overlapping
16 measure ranges produce a conflicted state that a human must resolve before
17 either can merge cleanly into main.
18
19 Prerequisites:
20 - seed_musehub.py must have run first (repos and commits must exist)
21 - Issue #452 (seed_commits) must have seeded commit history
22
23 Idempotent: checks existing PR count before inserting; pass --force to wipe
24 and re-insert all PR seed data.
25
26 Run inside the container:
27 docker compose exec musehub python3 /app/scripts/seed_pull_requests.py
28 docker compose exec musehub python3 /app/scripts/seed_pull_requests.py --force
29 """
30 from __future__ import annotations
31
32 import asyncio
33 import hashlib
34 import sys
35 import uuid
36 from datetime import datetime, timedelta, timezone
37 from typing import Any
38
39 from sqlalchemy import select, text
40 from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
41 from sqlalchemy.orm import sessionmaker
42
43 from musehub.config import settings
44 from musehub.db.musehub_models import (
45 MusehubCommit,
46 MusehubPRComment,
47 MusehubPRReview,
48 MusehubPullRequest,
49 MusehubRepo,
50 )
51
52 # ---------------------------------------------------------------------------
53 # Helpers
54 # ---------------------------------------------------------------------------
55
56 UTC = timezone.utc
57
58
59 def _now(days: int = 0, hours: int = 0) -> datetime:
60 return datetime.now(tz=UTC) - timedelta(days=days, hours=hours)
61
62
63 def _sha(seed: str) -> str:
64 return hashlib.sha256(seed.encode()).hexdigest()
65
66
67 def _uid(seed: str) -> str:
68 return str(uuid.UUID(bytes=hashlib.md5(seed.encode()).digest()))
69
70
71 # ---------------------------------------------------------------------------
72 # Stable repo IDs — must match seed_musehub.py
73 # ---------------------------------------------------------------------------
74
75 REPO_NEO_SOUL = "repo-neo-soul-00000001"
76 REPO_MODAL_JAZZ = "repo-modal-jazz-000001"
77 REPO_AMBIENT = "repo-ambient-textures-1"
78 REPO_AFROBEAT = "repo-afrobeat-grooves-1"
79 REPO_MICROTONAL = "repo-microtonal-etudes1"
80 REPO_DRUM_MACHINE = "repo-drum-machine-00001"
81 REPO_CHANSON = "repo-chanson-minimale-1"
82 REPO_GRANULAR = "repo-granular-studies-1"
83 REPO_FUNK_SUITE = "repo-funk-suite-0000001"
84 REPO_JAZZ_TRIO = "repo-jazz-trio-0000001"
85 # Fork repos (cross-repo PRs — fork → upstream)
86 REPO_NEO_SOUL_FORK = "repo-neo-soul-fork-0001"
87 REPO_AMBIENT_FORK = "repo-ambient-fork-0001"
88
89 # Owner map — needed to set PR author correctly
90 REPO_OWNER: dict[str, str] = {
91 REPO_NEO_SOUL: "gabriel",
92 REPO_MODAL_JAZZ: "gabriel",
93 REPO_AMBIENT: "sofia",
94 REPO_AFROBEAT: "aaliya",
95 REPO_MICROTONAL: "chen",
96 REPO_DRUM_MACHINE: "fatou",
97 REPO_CHANSON: "pierre",
98 REPO_GRANULAR: "yuki",
99 REPO_FUNK_SUITE: "marcus",
100 REPO_JAZZ_TRIO: "marcus",
101 REPO_NEO_SOUL_FORK: "marcus",
102 REPO_AMBIENT_FORK: "yuki",
103 }
104
105 # Collaborator pairs: repo → list of reviewers who are not the owner
106 REPO_REVIEWERS: dict[str, list[str]] = {
107 REPO_NEO_SOUL: ["marcus", "sofia", "aaliya"],
108 REPO_MODAL_JAZZ: ["marcus", "sofia"],
109 REPO_AMBIENT: ["yuki", "pierre", "gabriel"],
110 REPO_AFROBEAT: ["fatou", "marcus", "gabriel"],
111 REPO_MICROTONAL: ["sofia", "yuki"],
112 REPO_DRUM_MACHINE: ["aaliya", "marcus"],
113 REPO_CHANSON: ["sofia", "gabriel"],
114 REPO_GRANULAR: ["sofia", "chen"],
115 REPO_FUNK_SUITE: ["gabriel", "aaliya"],
116 REPO_JAZZ_TRIO: ["gabriel", "sofia"],
117 REPO_NEO_SOUL_FORK: ["gabriel"],
118 REPO_AMBIENT_FORK: ["sofia"],
119 }
120
121 # All primary repos (non-fork) — full PR suite
122 PRIMARY_REPOS = [
123 REPO_NEO_SOUL,
124 REPO_MODAL_JAZZ,
125 REPO_AMBIENT,
126 REPO_AFROBEAT,
127 REPO_MICROTONAL,
128 REPO_DRUM_MACHINE,
129 REPO_CHANSON,
130 REPO_GRANULAR,
131 REPO_FUNK_SUITE,
132 REPO_JAZZ_TRIO,
133 ]
134
135 # Fork repos — cross-repo PRs only
136 FORK_REPOS = [REPO_NEO_SOUL_FORK, REPO_AMBIENT_FORK]
137
138 # Upstream repo for each fork (cross-repo PR target)
139 FORK_UPSTREAM: dict[str, str] = {
140 REPO_NEO_SOUL_FORK: REPO_NEO_SOUL,
141 REPO_AMBIENT_FORK: REPO_AMBIENT,
142 }
143
144
145 # ---------------------------------------------------------------------------
146 # PR templates per repo — 8+ entries covering the full lifecycle
147 # ---------------------------------------------------------------------------
148 #
149 # Lifecycle distribution per primary repo (10 PRs each):
150 # 3 open — actively being worked on or awaiting review
151 # 4 merged — branch fully integrated into main
152 # 2 closed — rejected, withdrawn, or superseded by another PR
153 # 1 conflict — two concurrent branches editing overlapping measure ranges
154 # (represented as a closed PR noting the conflict)
155 #
156 # Cross-repo PRs (fork → upstream): 2 additional PRs per fork repo.
157 # ---------------------------------------------------------------------------
158
159 def _make_prs(
160 repo_id: str,
161 owner: str,
162 reviewers: list[str],
163 merge_commit_ids: list[str],
164 days_base: int,
165 ) -> list[dict[str, Any]]:
166 """Build the full lifecycle PR list for a single repo.
167
168 merge_commit_ids is taken from the repo's existing commit history so that
169 merged PRs reference real commit SHAs — the model constraint allows any
170 string, so we use seeded commit IDs from seed_musehub.py.
171
172 days_base controls the temporal spread relative to now.
173 """
174 reviewer_a = reviewers[0] if reviewers else owner
175 reviewer_b = reviewers[1] if len(reviewers) > 1 else reviewer_a
176 mc = merge_commit_ids
177
178 # Stable merge commit references — fall back to generated SHAs if commits
179 # are fewer than expected.
180 def _mc(idx: int) -> str | None:
181 return mc[idx] if idx < len(mc) else None
182
183 return [
184 # ── OPEN PRs (3) ──────────────────────────────────────────────────
185 dict(
186 pr_id=_uid(f"pr2-{repo_id}-open-1"),
187 repo_id=repo_id,
188 title="Feat: add counter-melody layer to verse sections",
189 body=(
190 "## Changes\n"
191 "Adds a secondary melodic voice in the upper register (measures 1–8, 17–24).\n\n"
192 "## Musical Analysis\n"
193 "The counter-melody creates harmonic tension against the main theme, "
194 "raising the Tension metric by +0.08 and Complexity by +0.05.\n\n"
195 "## Review notes\n"
196 "Please check voice-leading in bars 5–6 — the major 7th leap may be too angular."
197 ),
198 state="open",
199 from_branch="feat/counter-melody-verse",
200 to_branch="main",
201 author=owner,
202 created_at=_now(days=days_base + 6),
203 ),
204 dict(
205 pr_id=_uid(f"pr2-{repo_id}-open-2"),
206 repo_id=repo_id,
207 title="Experiment: alternate bridge harmony (tritone substitution)",
208 body=(
209 "## Summary\n"
210 "Replaces the ii-V-I cadence in the bridge with a tritone substitution "
211 "(bII7 → I). Adds chromatic colour without disrupting the groove.\n\n"
212 "## A/B comparison\n"
213 "Original: Dm7 → G7 → Cmaj7\n"
214 "Proposed: Db7 → Cmaj7\n\n"
215 "## Status\n"
216 "Awaiting feedback from collaborators before committing to the change."
217 ),
218 state="open",
219 from_branch="experiment/bridge-tritone-sub",
220 to_branch="main",
221 author=reviewer_a,
222 created_at=_now(days=days_base + 3),
223 ),
224 dict(
225 pr_id=_uid(f"pr2-{repo_id}-open-3"),
226 repo_id=repo_id,
227 title="Feat: dynamic automation — swell into final chorus",
228 body=(
229 "## Overview\n"
230 "Adds volume and filter automation curves across the 4-bar pre-chorus "
231 "(measures 29–32) to build energy into the final chorus.\n\n"
232 "## Details\n"
233 "- Main pad: +6dB over 4 bars (linear ramp)\n"
234 "- Hi-pass filter: 200Hz → 20Hz over same range\n"
235 "- Reverb send: +3dB on downbeat of bar 33\n\n"
236 "## Test\n"
237 "Exported test render attached. The swell is audible but not aggressive."
238 ),
239 state="open",
240 from_branch="feat/dynamic-swell-final-chorus",
241 to_branch="main",
242 author=owner,
243 created_at=_now(days=days_base + 1),
244 ),
245 # ── MERGED PRs (4) ────────────────────────────────────────────────
246 dict(
247 pr_id=_uid(f"pr2-{repo_id}-merged-1"),
248 repo_id=repo_id,
249 title="Refactor: humanize all MIDI timing (±12ms variance)",
250 body=(
251 "## Changes\n"
252 "Applied `muse humanize --natural` to all tracks. "
253 "Groove score improved from 0.71 to 0.83.\n\n"
254 "## Tracks affected\n"
255 "- drums: ±12ms on hits, ±8ms on hats\n"
256 "- bass: ±6ms per note\n"
257 "- keys: ±4ms per chord\n\n"
258 "## Verified\n"
259 "Full export A/B comparison confirmed improvement in perceived groove."
260 ),
261 state="merged",
262 from_branch="fix/humanize-midi-timing",
263 to_branch="main",
264 merge_commit_id=_mc(0),
265 merged_at=_now(days=days_base + 14),
266 author=owner,
267 created_at=_now(days=days_base + 16),
268 ),
269 dict(
270 pr_id=_uid(f"pr2-{repo_id}-merged-2"),
271 repo_id=repo_id,
272 title="Fix: resolve voice-leading errors (parallel 5ths bars 7–8)",
273 body=(
274 "## Problem\n"
275 "Parallel 5ths between soprano and bass in bars 7–8 violate classical "
276 "voice-leading rules and produce a hollow, unintentional sound.\n\n"
277 "## Fix\n"
278 "Moved soprano from G4 to B4 on beat 3 of bar 7, introducing a 3rd "
279 "above the alto line and eliminating the parallel motion.\n\n"
280 "## Validation\n"
281 "Voice-leading analysis report: 0 parallel 5ths, 0 parallel octaves."
282 ),
283 state="merged",
284 from_branch="fix/voice-leading-parallel-fifths",
285 to_branch="main",
286 merge_commit_id=_mc(1),
287 merged_at=_now(days=days_base + 20),
288 author=reviewer_a,
289 created_at=_now(days=days_base + 22),
290 ),
291 dict(
292 pr_id=_uid(f"pr2-{repo_id}-merged-3"),
293 repo_id=repo_id,
294 title="Feat: add breakdown section (bass + drums only, 4 bars)",
295 body=(
296 "## Motivation\n"
297 "The arrangement transitions directly from the second chorus into the "
298 "outro without a moment of release. A minimal breakdown restores energy "
299 "contrast before the final section.\n\n"
300 "## Implementation\n"
301 "Added 4-bar section after measure 48:\n"
302 "- All instruments muted except bass and kick\n"
303 "- Bass plays root + 5th alternating pattern\n"
304 "- Kick on all four beats (half-time feel)\n\n"
305 "## Energy curve\n"
306 "Peak energy drops from 0.91 to 0.42 in the breakdown, then rises to "
307 "0.95 at the final chorus downbeat."
308 ),
309 state="merged",
310 from_branch="feat/breakdown-section-pre-outro",
311 to_branch="main",
312 merge_commit_id=_mc(2),
313 merged_at=_now(days=days_base + 30),
314 author=owner,
315 created_at=_now(days=days_base + 32),
316 ),
317 dict(
318 pr_id=_uid(f"pr2-{repo_id}-merged-4"),
319 repo_id=repo_id,
320 title="Fix: remove accidental octave doubling in chorus voicing",
321 body=(
322 "## Issue\n"
323 "Rhodes and strings both play the root in octave unison during the "
324 "chorus (bars 33–40). The doubling thins out the mid-range and causes "
325 "phase cancellation on mono playback.\n\n"
326 "## Fix\n"
327 "Transposed strings up a major 3rd — now playing a 10th above bass, "
328 "creating a richer spread voicing.\n\n"
329 "## Result\n"
330 "Stereo width score: 0.68 → 0.74. Mono compatibility confirmed."
331 ),
332 state="merged",
333 from_branch="fix/chorus-octave-doubling",
334 to_branch="main",
335 merge_commit_id=_mc(3) if len(mc) > 3 else _mc(0),
336 merged_at=_now(days=days_base + 40),
337 author=reviewer_b if reviewer_b != owner else reviewer_a,
338 created_at=_now(days=days_base + 42),
339 ),
340 # ── CLOSED/REJECTED PRs (2) ───────────────────────────────────────
341 dict(
342 pr_id=_uid(f"pr2-{repo_id}-closed-1"),
343 repo_id=repo_id,
344 title="Experiment: half-time feel for entire track",
345 body=(
346 "## Concept\n"
347 "Proposed converting the entire arrangement to a half-time feel "
348 "(kick on beats 1 and 3 only, snare on beat 3).\n\n"
349 "## Outcome\n"
350 "After review, the consensus was that the half-time feel loses the "
351 "rhythmic momentum that defines the track's character. "
352 "The change is too drastic.\n\n"
353 "## Decision\n"
354 "Closing without merge. A more surgical application (breakdown only) "
355 "will be explored in a separate PR."
356 ),
357 state="closed",
358 from_branch="experiment/half-time-full-track",
359 to_branch="main",
360 author=reviewer_a,
361 created_at=_now(days=days_base + 50),
362 ),
363 dict(
364 pr_id=_uid(f"pr2-{repo_id}-closed-2"),
365 repo_id=repo_id,
366 title="Feat: add rap vocal layer (rejected — genre scope)",
367 body=(
368 "## Proposal\n"
369 "Adds a spoken-word / rap vocal layer over the second verse. "
370 "Sample rhythm: 16th-note triplet flow.\n\n"
371 "## Review feedback\n"
372 "The addition of a rap layer changes the core genre identity of the "
373 "composition. The project description and tags do not include hip-hop "
374 "or spoken word. This feature would need a fork or a separate project.\n\n"
375 "## Decision\n"
376 "Rejected by owner. Closing. Contributor encouraged to fork and "
377 "explore in their own namespace."
378 ),
379 state="closed",
380 from_branch="feat/rap-vocal-overlay",
381 to_branch="main",
382 author=reviewer_b if reviewer_b != owner else reviewer_a,
383 created_at=_now(days=days_base + 55),
384 ),
385 # ── CONFLICT SCENARIO ─────────────────────────────────────────────
386 #
387 # Two branches both edit measures 25–32 (the bridge/transition zone).
388 # Branch A adds a string countermelody; Branch B rewrites the chord
389 # changes. Both modify the same measure range — merging one will
390 # require manual conflict resolution before the other can land.
391 #
392 # Here we model the conflicted branch as a separate closed PR with a
393 # clear conflict explanation. The "winning" change was already merged
394 # (merged-3 above). The conflicting PR is closed with a note to rebase.
395 dict(
396 pr_id=_uid(f"pr2-{repo_id}-conflict-1"),
397 repo_id=repo_id,
398 title="Feat: rewrite bridge chord changes (CONFLICT — bars 25–32)",
399 body=(
400 "## Overview\n"
401 "Proposed complete reharmonisation of the bridge (bars 25–32):\n"
402 "- Original: I → IV → V → I\n"
403 "- Proposed: I → bVII → IV → bVII → I (Mixolydian cadence)\n\n"
404 "## Conflict\n"
405 "This PR conflicts with `feat/breakdown-section-pre-outro` which also "
406 "modifies bars 25–32 to insert the bass+drums breakdown. Both branches "
407 "modify the same measure range and cannot be auto-merged.\n\n"
408 "## Status\n"
409 "Closed — the breakdown PR merged first (#merged-3). "
410 "This PR must be rebased on the updated main and the chord rewrite "
411 "adjusted to target bars 33–40 instead. Reopening as a follow-up."
412 ),
413 state="closed",
414 from_branch="feat/bridge-mixolydian-reharmony",
415 to_branch="main",
416 author=reviewer_a,
417 created_at=_now(days=days_base + 28),
418 ),
419 ]
420
421
422 def _make_cross_repo_prs(
423 fork_repo_id: str,
424 upstream_repo_id: str,
425 fork_owner: str,
426 merge_commit_ids: list[str],
427 ) -> list[dict[str, Any]]:
428 """Build cross-repo PRs from a fork toward its upstream.
429
430 These represent the canonical 'fork → upstream' contribution flow:
431 a contributor forks the repo, adds their own changes, and opens a PR
432 targeting the upstream main branch.
433 """
434
435 def _mc(idx: int) -> str | None:
436 return merge_commit_ids[idx] if idx < len(merge_commit_ids) else None
437
438 return [
439 dict(
440 pr_id=_uid(f"pr2-cross-{fork_repo_id}-open"),
441 repo_id=upstream_repo_id,
442 title=f"Feat (fork/{fork_owner}): add extended intro variation",
443 body=(
444 "## Fork contribution\n"
445 f"This PR comes from the fork `{fork_owner}/{upstream_repo_id}`. "
446 "It proposes adding a 16-bar intro variation that establishes the "
447 "harmonic language before the main theme enters.\n\n"
448 "## Changes\n"
449 "- New intro: 16 bars of sparse pad and bass drone\n"
450 "- Main theme entry delayed to bar 17\n"
451 "- Cross-fade from intro texture to full arrangement\n\n"
452 "## Cross-repo note\n"
453 f"Opened from fork `{fork_owner}` — all commits come from the fork's "
454 "main branch at HEAD."
455 ),
456 state="open",
457 from_branch=f"forks/{fork_owner}/feat/extended-intro",
458 to_branch="main",
459 author=fork_owner,
460 created_at=_now(days=8),
461 ),
462 dict(
463 pr_id=_uid(f"pr2-cross-{fork_repo_id}-merged"),
464 repo_id=upstream_repo_id,
465 title=f"Fix (fork/{fork_owner}): correct tempo marking in metadata",
466 body=(
467 "## Fork contribution\n"
468 f"Fix contributed from fork `{fork_owner}/{upstream_repo_id}`.\n\n"
469 "## Problem\n"
470 "The `tempo_bpm` metadata field was set to 96 but the actual MIDI "
471 "content runs at 92 BPM. This mismatch confuses playback sync.\n\n"
472 "## Fix\n"
473 "Updated tempo_bpm to 92 in repo metadata. "
474 "Verified against MIDI clock ticks.\n\n"
475 "## Cross-repo note\n"
476 f"Merged from fork `{fork_owner}` into upstream main."
477 ),
478 state="merged",
479 from_branch=f"forks/{fork_owner}/fix/tempo-metadata-correction",
480 to_branch="main",
481 merge_commit_id=_mc(0),
482 merged_at=_now(days=12),
483 author=fork_owner,
484 created_at=_now(days=14),
485 ),
486 ]
487
488
489 # ---------------------------------------------------------------------------
490 # Review seeds — adds realistic review submissions to key PRs
491 # ---------------------------------------------------------------------------
492
493 def _make_reviews(pr_id: str, reviewer: str, state: str, body: str) -> dict[str, Any]:
494 return dict(
495 id=_uid(f"review-{pr_id}-{reviewer}"),
496 pr_id=pr_id,
497 reviewer_username=reviewer,
498 state=state,
499 body=body,
500 submitted_at=_now(days=1) if state != "pending" else None,
501 created_at=_now(days=2),
502 )
503
504
505 # ---------------------------------------------------------------------------
506 # PR comment seeds — inline review comments on specific tracks/regions
507 # ---------------------------------------------------------------------------
508
509 def _make_pr_comment(
510 pr_id: str,
511 repo_id: str,
512 author: str,
513 body: str,
514 target_type: str = "general",
515 target_track: str | None = None,
516 target_beat_start: float | None = None,
517 target_beat_end: float | None = None,
518 parent_comment_id: str | None = None,
519 ) -> dict[str, Any]:
520 return dict(
521 comment_id=_uid(f"prcomment-{pr_id}-{author}-{body[:20]}"),
522 pr_id=pr_id,
523 repo_id=repo_id,
524 author=author,
525 body=body,
526 target_type=target_type,
527 target_track=target_track,
528 target_beat_start=target_beat_start,
529 target_beat_end=target_beat_end,
530 target_note_pitch=None,
531 parent_comment_id=parent_comment_id,
532 created_at=_now(days=1),
533 )
534
535
536 # ---------------------------------------------------------------------------
537 # Main seed function
538 # ---------------------------------------------------------------------------
539
540 async def seed(db: AsyncSession, force: bool = False) -> None:
541 """Seed pull requests with full lifecycle coverage.
542
543 Idempotent: skips if PRs already exist, unless --force is passed.
544 Depends on seed_musehub.py having already populated repos and commits.
545 """
546 print("🌱 Seeding pull requests — full lifecycle dataset…")
547
548 # Guard: check that repos exist (seed_musehub.py must have run first)
549 result = await db.execute(text("SELECT COUNT(*) FROM musehub_repos"))
550 repo_count = result.scalar() or 0
551 if repo_count == 0:
552 print(" ❌ No repos found — run seed_musehub.py first.")
553 return
554
555 # Idempotency check — count PRs created by this script (use stable prefix)
556 result = await db.execute(
557 text("SELECT COUNT(*) FROM musehub_pull_requests WHERE pr_id LIKE 'pr2-%'")
558 )
559 existing_pr_count = result.scalar() or 0
560
561 if existing_pr_count > 0 and not force:
562 print(
563 f" ⚠️ {existing_pr_count} seeded PR(s) already exist — skipping. "
564 "Pass --force to wipe and reseed."
565 )
566 return
567
568 if existing_pr_count > 0 and force:
569 print(" 🗑 --force: clearing previously seeded PRs (pr2-* prefix)…")
570 await db.execute(
571 text("DELETE FROM musehub_pull_requests WHERE pr_id LIKE 'pr2-%'")
572 )
573 await db.flush()
574
575 # Fetch the first few commit IDs per repo to use as merge commit references.
576 # We take early commits (not the latest HEAD) to simulate realistic merge
577 # points that predate the current branch tip.
578 commit_ids_by_repo: dict[str, list[str]] = {}
579 for repo_id in PRIMARY_REPOS + FORK_REPOS:
580 res = await db.execute(
581 select(MusehubCommit.commit_id)
582 .where(MusehubCommit.repo_id == repo_id)
583 .order_by(MusehubCommit.timestamp)
584 .limit(8)
585 )
586 rows = res.scalars().all()
587 commit_ids_by_repo[repo_id] = list(rows)
588
589 # Fetch days_ago per repo (used to spread PR timestamps)
590 repo_days: dict[str, int] = {}
591 res = await db.execute(select(MusehubRepo.repo_id, MusehubRepo.created_at))
592 for row in res.all():
593 rid, created_at = row
594 delta = datetime.now(tz=UTC) - (
595 created_at if created_at.tzinfo else created_at.replace(tzinfo=UTC)
596 )
597 repo_days[rid] = max(0, delta.days)
598
599 # ── Primary repos: 10 PRs each ────────────────────────────────────────
600 total_prs = 0
601 all_reviews: list[dict[str, Any]] = []
602 all_comments: list[dict[str, Any]] = []
603
604 for repo_id in PRIMARY_REPOS:
605 owner = REPO_OWNER.get(repo_id, "gabriel")
606 reviewers = REPO_REVIEWERS.get(repo_id, ["sofia"])
607 mc = commit_ids_by_repo.get(repo_id, [])
608 days_base = repo_days.get(repo_id, 60)
609
610 prs = _make_prs(repo_id, owner, reviewers, mc, days_base)
611
612 for pr in prs:
613 db.add(MusehubPullRequest(**pr))
614 total_prs += 1
615
616 await db.flush()
617
618 # Add reviews to the first open PR and the first merged PR
619 open_pr_id = _uid(f"pr2-{repo_id}-open-1")
620 merged_pr_id = _uid(f"pr2-{repo_id}-merged-1")
621 reviewer_a = reviewers[0] if reviewers else owner
622 reviewer_b = reviewers[1] if len(reviewers) > 1 else reviewer_a
623
624 all_reviews.extend([
625 _make_reviews(
626 open_pr_id, reviewer_a, "changes_requested",
627 "The counter-melody leap in bar 5 is too wide for the register. "
628 "Consider stepping by 3rds instead of a 7th. Otherwise this is lovely."
629 ),
630 _make_reviews(
631 open_pr_id, reviewer_b, "approved",
632 "Approved from a harmonic standpoint. Voicing tension is intentional "
633 "per the discussion — the resolution in bar 8 justifies the leap."
634 ),
635 _make_reviews(
636 merged_pr_id, reviewer_a, "approved",
637 "Humanization is well-calibrated. The ±12ms on drums is right on "
638 "the edge of perceptible — just enough to breathe without dragging. ✅"
639 ),
640 ])
641
642 # Inline PR comments on the open counter-melody PR
643 comment_id = _uid(f"prcomment-{open_pr_id}-{reviewer_a}-The counter-melody")
644 all_comments.extend([
645 _make_pr_comment(
646 open_pr_id, repo_id, reviewer_a,
647 "The counter-melody leap in bar 5 (major 7th) feels too angular. "
648 "The voice jumps up a major 7th where the harmonic context suggests "
649 "a 3rd or 5th would feel more idiomatic.",
650 target_type="region",
651 target_track="counter_melody",
652 target_beat_start=17.0,
653 target_beat_end=21.0,
654 ),
655 _make_pr_comment(
656 open_pr_id, repo_id, owner,
657 "Intentional — the 7th creates a moment of suspension before the "
658 "resolution in bar 8. I'll add a passing tone to soften it if needed.",
659 target_type="region",
660 target_track="counter_melody",
661 target_beat_start=17.0,
662 target_beat_end=21.0,
663 parent_comment_id=comment_id,
664 ),
665 _make_pr_comment(
666 open_pr_id, repo_id, reviewer_b,
667 "Overall structure looks solid. The counter-melody enters at the "
668 "right harmonic moment — bar 2 of the verse.",
669 target_type="general",
670 ),
671 ])
672
673 print(f" ✅ Primary repo PRs: {total_prs} across {len(PRIMARY_REPOS)} repos")
674
675 # ── Fork repos: cross-repo PRs ────────────────────────────────────────
676 cross_pr_count = 0
677 for fork_repo_id in FORK_REPOS:
678 upstream_id = FORK_UPSTREAM[fork_repo_id]
679 fork_owner = REPO_OWNER.get(fork_repo_id, "marcus")
680 mc = commit_ids_by_repo.get(fork_repo_id, [])
681
682 cross_prs = _make_cross_repo_prs(fork_repo_id, upstream_id, fork_owner, mc)
683 for pr in cross_prs:
684 db.add(MusehubPullRequest(**pr))
685 cross_pr_count += 1
686
687 await db.flush()
688 print(f" ✅ Cross-repo (fork → upstream) PRs: {cross_pr_count}")
689
690 # ── Reviews ───────────────────────────────────────────────────────────
691 for review in all_reviews:
692 db.add(MusehubPRReview(**review))
693 await db.flush()
694 print(f" ✅ PR reviews: {len(all_reviews)}")
695
696 # ── Inline PR comments ────────────────────────────────────────────────
697 for comment in all_comments:
698 db.add(MusehubPRComment(**comment))
699 await db.flush()
700 print(f" ✅ PR inline comments: {len(all_comments)}")
701
702 await db.commit()
703
704 # Summary
705 print()
706 _print_summary(total_prs, cross_pr_count, len(all_reviews), len(all_comments))
707
708
709 def _print_summary(prs: int, cross_prs: int, reviews: int, comments: int) -> None:
710 BASE = "http://localhost:10001"
711 print("=" * 72)
712 print("🎵 SEED PULL REQUESTS — COMPLETE")
713 print("=" * 72)
714 print(f" PRs seeded : {prs} primary + {cross_prs} cross-repo = {prs + cross_prs} total")
715 print(f" Reviews : {reviews}")
716 print(f" PR comments : {comments}")
717 print()
718 print(" Lifecycle coverage:")
719 print(" open — 3 per primary repo (awaiting review or merge)")
720 print(" merged — 4 per primary repo (with merge commit + merged_at)")
721 print(" closed — 2 per primary repo (rejected / withdrawn)")
722 print(" conflict— 1 per primary repo (overlapping measure range, closed)")
723 print()
724 print(" Cross-repo PRs (fork → upstream):")
725 print(" marcus/neo-soul-fork → gabriel/neo-soul-experiment")
726 print(" yuki/ambient-fork → sofia/ambient-textures-vol-1")
727 print()
728 print(" Sample PR URLs:")
729 print(f" {BASE}/gabriel/neo-soul-experiment/pulls")
730 print(f" {BASE}/sofia/ambient-textures-vol-1/pulls")
731 print(f" {BASE}/marcus/funk-suite-no-1/pulls")
732 print("=" * 72)
733 print("✅ Seed complete.")
734 print("=" * 72)
735
736
737 async def main() -> None:
738 """Entry point — connects to the DB and runs the seed."""
739 force = "--force" in sys.argv
740 db_url: str = settings.database_url or ""
741 engine = create_async_engine(db_url, echo=False)
742 async_session = sessionmaker( # type: ignore[call-overload] # SQLAlchemy 2.x async stubs don't type class_= kwarg correctly
743 engine, class_=AsyncSession, expire_on_commit=False
744 )
745 async with async_session() as db:
746 await seed(db, force=force)
747 await engine.dispose()
748
749
750 if __name__ == "__main__":
751 asyncio.run(main())