seed_pull_requests.py
python
| 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 muse python3 /app/scripts/seed_pull_requests.py |
| 28 | docker compose exec muse 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/musehub/ui" |
| 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()) |