gabriel / musehub public
musehub_proposals.py python
2,216 lines 83.4 KB Hotspot
Raw
sha256:400438cf8bc700a611f1ba798aa9def68290f487dc19f7dbf317985ad17050c9 chore: delete muse/prose domain — hallucinated, never existed Sonnet 4.6 minor ⚠ breaking 37 days ago
1 """MuseHub merge proposal persistence adapter — single point of DB access for proposals.
2
3 This module is the ONLY place that touches the ``musehub_proposals`` table.
4 Route handlers delegate here; no business logic lives in routes.
5
6 Boundary rules:
7 - Must NOT import state stores, SSE queues, or LLM clients.
8 - May import ORM models from musehub.db domain-specific modules.
9 - May import Pydantic response models from musehub.models.musehub.
10 - May import musehub.core.genesis for genesis ID computation.
11
12 Merge strategy
13 --------------
14 ``merge_commit`` is the only strategy at MVP. It creates a new commit on
15 ``to_branch`` whose parent_ids are [to_branch head, from_branch head], then
16 updates the ``to_branch`` head pointer and marks the proposal as merged.
17
18 If either branch has no commits yet (no head commit), the merge is rejected with
19 a ``ValueError`` — there is nothing to merge.
20 """
21
22 import logging
23 from collections.abc import Awaitable, Callable
24 from datetime import datetime, timezone
25
26 import sqlalchemy as sa
27 from sqlalchemy import ColumnElement, func, select
28 from sqlalchemy.ext.asyncio import AsyncSession
29
30 from musehub.core.genesis import compute_branch_id, compute_comment_id, compute_proposal_id, compute_review_id, compute_simulation_id
31 from musehub.db.musehub_identity_models import MusehubIdentity
32 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitGraph, MusehubCommitRef
33 from musehub.db.musehub_social_models import (
34 MusehubProposal,
35 MusehubProposalComment,
36 MusehubProposalReview,
37 MusehubProposalSimulation,
38 )
39 from musehub.services.proposal_dag import (
40 CycleError,
41 ProposalDag,
42 blocked_by_numbers,
43 blocks_numbers,
44 create_dependency_edges,
45 is_blocked,
46 load_dag_for_proposals,
47 )
48 from musehub.muse_cli.snapshot import compute_commit_id, compute_snapshot_id
49
50
51 class BranchNotFoundError(Exception):
52 """Raised when a required branch does not exist in the repo."""
53 from musehub.types.json_types import JSONObject, StrDict
54 from musehub.models.musehub import (
55 DomainHeatEntry,
56 DomainHeatResponse,
57 MergeReadinessResponse,
58 MergeResultEmbed,
59 ProposalCommentListResponse,
60 ProposalCommentResponse,
61 ProposalListEntry,
62 ProposalListFilters,
63 ProposalListResponse,
64 ProposalResponse,
65 ProposalReviewListResponse,
66 ProposalReviewResponse,
67 SimulationListResponse,
68 SimulationResponse,
69 )
70
71 type _CommentMap = dict[str, ProposalCommentResponse]
72
73 logger = logging.getLogger(__name__)
74
75
76 def _utc_now() -> datetime:
77 return datetime.now(tz=timezone.utc)
78
79
80
81 def _symbols_from_delta(delta: JSONObject | None) -> list[str]:
82 """Extract unique symbol addresses from a structured_delta dict.
83
84 Only child_op addresses that contain ``::`` are returned — file-level ops
85 without a symbol component are intentionally excluded.
86 """
87 if not isinstance(delta, dict):
88 return []
89 seen: set[str] = set()
90 for file_op in delta.get("ops") or []:
91 if not isinstance(file_op, dict):
92 continue
93 for child_op in file_op.get("child_ops") or []:
94 if not isinstance(child_op, dict):
95 continue
96 addr = child_op.get("address", "")
97 if "::" in addr and addr not in seen:
98 seen.add(addr)
99 return list(seen)
100
101
102 async def _touched_symbols_for_branch(
103 session: AsyncSession, repo_id: str, branch: str
104 ) -> list[str]:
105 """Return the union of symbol addresses touched by all commits on ``branch``."""
106 rows = (await session.execute(
107 select(MusehubCommit.structured_delta)
108 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
109 .where(
110 MusehubCommitRef.repo_id == repo_id,
111 MusehubCommit.branch == branch,
112 MusehubCommit.structured_delta.isnot(None),
113 )
114 )).scalars().all()
115 seen: set[str] = set()
116 for delta in rows:
117 seen.update(_symbols_from_delta(delta))
118 return list(seen)
119
120
121 def _to_proposal_response(
122 row: MusehubProposal,
123 *,
124 dag: ProposalDag | None = None,
125 simulations: "SimulationListResponse | None" = None,
126 merge_result: MergeResultEmbed | None = None,
127 url_prefix: str = "",
128 ) -> ProposalResponse:
129 from musehub.models.musehub import MergeConditions, MergeStrategy, ProposalType
130 mc_raw = getattr(row, "merge_conditions", None)
131 mc = MergeConditions.model_validate(mc_raw) if mc_raw else None
132
133 blocked_by: list[int] = []
134 blocks: list[int] = []
135 is_blocked_flag = False
136 if dag is not None:
137 blocked_by = blocked_by_numbers(dag, row.proposal_id)
138 blocks = blocks_numbers(dag, row.proposal_id)
139 is_blocked_flag = is_blocked(dag, row.proposal_id)
140
141 latest_simulations: dict[str, dict] = {}
142 if simulations is not None:
143 for sim in simulations.simulations:
144 latest_simulations[sim.simulation_type] = {
145 "simulation_id": sim.simulation_id,
146 "result": sim.result,
147 "is_stale": sim.is_stale,
148 "from_branch_commit_id": sim.from_branch_commit_id,
149 "duration_ms": sim.duration_ms,
150 "created_at": sim.created_at.isoformat(),
151 }
152
153 return ProposalResponse(
154 proposal_id=row.proposal_id,
155 proposal_number=row.proposal_number,
156 url=f"{url_prefix}/proposals/{row.proposal_id}" if url_prefix else "",
157 title=row.title,
158 body=row.body,
159 state=row.state,
160 from_branch=row.from_branch,
161 to_branch=row.to_branch,
162 merge_commit_id=row.merge_commit_id,
163 merged_at=row.merged_at,
164 author=row.author,
165 created_at=row.created_at,
166 proposal_type=ProposalType(getattr(row, "proposal_type", "state_merge")),
167 is_draft=getattr(row, "is_draft", False),
168 merge_conditions=mc,
169 merge_strategy=MergeStrategy(getattr(row, "merge_strategy", "overlay")),
170 selective_domains=getattr(row, "selective_domains", None),
171 risk_score=getattr(row, "risk_score", None),
172 dimensional_risk=dict(getattr(row, "dimensional_risk", None) or {}),
173 blocked_by=blocked_by,
174 blocks=blocks,
175 is_blocked=is_blocked_flag,
176 latest_simulations=latest_simulations,
177 proposer_signature=getattr(row, "proposer_signature", None),
178 proposer_public_key=getattr(row, "proposer_public_key", None),
179 from_snapshot_id=getattr(row, "from_snapshot_id", None),
180 to_snapshot_id=getattr(row, "to_snapshot_id", None),
181 merge_result=merge_result,
182 )
183
184
185 async def _get_branch(
186 session: AsyncSession, repo_id: str, branch_name: str
187 ) -> MusehubBranch | None:
188 """Return the branch record by repo + name, or None."""
189 stmt = select(MusehubBranch).where(
190 MusehubBranch.repo_id == repo_id,
191 MusehubBranch.name == branch_name,
192 )
193 return (await session.execute(stmt)).scalar_one_or_none()
194
195
196 async def create_proposal(
197 session: AsyncSession,
198 *,
199 repo_id: str,
200 title: str,
201 from_branch: str,
202 to_branch: str,
203 body: str = "",
204 author: str = "",
205 author_identity_id: str = "",
206 proposal_type: str = "state_merge",
207 is_draft: bool = False,
208 merge_strategy: str = "overlay",
209 merge_conditions: JSONObject | None = None,
210 selective_domains: list[str] | None = None,
211 depends_on: list[str] | None = None,
212 proposer_signature: str | None = None,
213 proposer_public_key: str | None = None,
214 url_prefix: str = "",
215 ) -> ProposalResponse:
216 """Persist a new merge proposal in ``open`` state and return its wire representation.
217
218 ``author`` identifies the user opening the proposal — typically the MSign handle
219 from the request context, or a display name from the seed script.
220
221 Raises ``BranchNotFoundError`` if ``from_branch`` does not exist in the repo;
222 the caller should surface this as HTTP 404.
223 """
224 branch = await _get_branch(session, repo_id, from_branch)
225 if branch is None:
226 raise BranchNotFoundError(f"Branch '{from_branch}' not found in repo {repo_id}")
227 from_snapshot_id = branch.head_commit_id
228 to_branch_row = await _get_branch(session, repo_id, to_branch)
229 to_snapshot_id = to_branch_row.head_commit_id if to_branch_row else None
230
231 # Assign the next sequential proposal_number for this repo (1-based, like GitHub)
232 max_num_result = await session.execute(
233 select(func.max(MusehubProposal.proposal_number)).where(
234 MusehubProposal.repo_id == repo_id
235 )
236 )
237 max_num: int | None = max_num_result.scalar_one_or_none()
238 next_num = (max_num or 0) + 1
239
240 touched = await _touched_symbols_for_branch(session, repo_id, from_branch)
241 _created_at = _utc_now()
242 initial_state = "drafting" if is_draft else "open"
243 proposal = MusehubProposal(
244 proposal_id=compute_proposal_id(
245 repo_id, author_identity_id, from_branch, to_branch, _created_at.isoformat()
246 ),
247 repo_id=repo_id,
248 proposal_number=next_num,
249 title=title,
250 body=body,
251 state=initial_state,
252 from_branch=from_branch,
253 to_branch=to_branch,
254 author=author,
255 touched_symbols=touched,
256 created_at=_created_at,
257 proposal_type=proposal_type,
258 is_draft=is_draft,
259 merge_strategy=merge_strategy,
260 merge_conditions=merge_conditions,
261 selective_domains=selective_domains,
262 proposer_signature=proposer_signature,
263 proposer_public_key=proposer_public_key,
264 from_snapshot_id=from_snapshot_id,
265 to_snapshot_id=to_snapshot_id,
266 )
267 session.add(proposal)
268 await session.flush()
269
270 # Persist dependency edges — validates existence, detects cycles before commit
271 if depends_on:
272 await create_dependency_edges(session, proposal.proposal_id, depends_on)
273
274 await session.refresh(proposal)
275 logger.info("✅ Created proposal '%s' (%s → %s) in repo %s", title, from_branch, to_branch, repo_id)
276 return _to_proposal_response(proposal, url_prefix=url_prefix)
277
278
279 def _risk_band_conditions(bands: list[str]) -> list[ColumnElement[bool]]:
280 """Return SQLAlchemy conditions that match risk_score to the given band names.
281
282 Each band maps to a half-open interval on [0.0, 1.0]:
283 critical ≥ 0.75
284 high 0.50 – 0.74…
285 medium 0.25 – 0.49…
286 low 0.01 – 0.24…
287 none == 0.0 (or NULL)
288
289 Multiple bands are OR-ed together.
290 """
291 band_ranges: dict[str, tuple[float | None, float | None]] = {
292 "critical": (0.75, None),
293 "high": (0.50, 0.75),
294 "medium": (0.25, 0.50),
295 "low": (0.01, 0.25),
296 "none": (None, 0.01),
297 }
298 clauses = []
299 for band in bands:
300 lo, hi = band_ranges.get(band, (None, None))
301 if band == "none":
302 clauses.append(
303 sa.or_(
304 MusehubProposal.risk_score.is_(None),
305 MusehubProposal.risk_score == 0.0,
306 )
307 )
308 elif lo is not None and hi is not None:
309 clauses.append(
310 sa.and_(
311 MusehubProposal.risk_score >= lo,
312 MusehubProposal.risk_score < hi,
313 )
314 )
315 elif lo is not None:
316 clauses.append(MusehubProposal.risk_score >= lo)
317 return clauses
318
319
320 async def list_proposals(
321 session: AsyncSession,
322 repo_id: str,
323 *,
324 state: str = "all",
325 cursor: str | None = None,
326 limit: int = 20,
327 filters: ProposalListFilters | None = None,
328 url_prefix: str = "",
329 ) -> ProposalListResponse:
330 """Return merge proposals for a repo with cursor-based keyset pagination.
331
332 ``state`` may be ``"open"``, ``"merged"``, ``"closed"``, ``"all"``, or any
333 value in the extended 7-state machine accepted by ``ProposalListFilters``.
334
335 When ``filters`` is provided, the following additional predicates are applied:
336 - ``filters.risk_band`` → ``risk_score`` range filter (OR across bands)
337 - ``filters.domain`` → ``risk_score > 0`` when "code" is included;
338 other domains require per-domain risk rows
339 (Phase 2 DB prerequisite)
340 - ``filters.author_type`` → LEFT JOIN to ``musehub_identities``
341 - ``filters.assigned_reviewer`` → EXISTS sub-select on ``musehub_proposal_reviews``
342 - ``filters.sort`` → ordering; ``merge_ready_first`` uses an approval
343 count sub-select to surface ready proposals first
344
345 ``cursor`` is the ISO 8601 ``created_at`` of the last seen proposal (opaque
346 to callers — pass ``nextCursor`` from a previous response verbatim).
347
348 Args:
349 session: Async database session.
350 repo_id: Target repository ID.
351 state: Proposal state filter; defaults to "all".
352 cursor: Pagination cursor (opaque ISO 8601 string).
353 limit: Page size.
354 filters: Optional ``ProposalListFilters``; overrides ``state``, ``limit``,
355 ``cursor``, and ``sort`` when provided. ``state`` in ``filters``
356 takes precedence over the top-level ``state`` kwarg.
357
358 Returns:
359 ``ProposalListResponse`` with paginated proposals and a ``nextCursor``.
360
361 Raises:
362 ValueError: If ``cursor`` is not a valid ISO 8601 datetime string.
363 """
364 f = filters
365 effective_state = (f.state if f else None) or state
366 effective_limit = (f.limit if f else None) or limit
367 effective_cursor = (f.cursor if f else None) or cursor
368 effective_sort = (f.sort if f else None) or "newest"
369
370 conditions: list[ColumnElement[bool]] = [MusehubProposal.repo_id == repo_id]
371 if effective_state != "all":
372 conditions.append(MusehubProposal.state == effective_state)
373
374 stmt = select(MusehubProposal)
375
376 # ── Author-type filter (requires join to identities) ───────────────────────
377 if f and f.author_type != "all":
378 stmt = stmt.join(
379 MusehubIdentity,
380 MusehubIdentity.handle == MusehubProposal.author,
381 isouter=True,
382 )
383 if f.author_type == "human":
384 conditions.append(
385 sa.or_(
386 MusehubIdentity.identity_type == "human",
387 MusehubIdentity.identity_type.is_(None),
388 )
389 )
390 else:
391 conditions.append(MusehubIdentity.identity_type == f.author_type)
392
393 # ── Risk-band filter ───────────────────────────────────────────────────────
394 if f and f.risk_band:
395 band_clauses = _risk_band_conditions(f.risk_band)
396 if band_clauses:
397 conditions.append(sa.or_(*band_clauses))
398
399 # ── Domain filter — code only at Phase 2 (other domains require risk rows) ─
400 if f and f.domain:
401 domain_clauses = []
402 if "code" in f.domain:
403 domain_clauses.append(
404 sa.and_(
405 MusehubProposal.risk_score.is_not(None),
406 MusehubProposal.risk_score > 0.0,
407 )
408 )
409 if domain_clauses:
410 conditions.append(sa.or_(*domain_clauses))
411
412 # ── Proposal-type filter ──────────────────────────────────────────────────
413 if f and f.proposal_type:
414 conditions.append(MusehubProposal.proposal_type.in_(f.proposal_type))
415
416 # ── Is-draft filter ───────────────────────────────────────────────────────
417 if f and f.is_draft is not None:
418 conditions.append(MusehubProposal.is_draft == f.is_draft)
419
420 # ── Merge-strategy filter ─────────────────────────────────────────────────
421 if f and f.merge_strategy:
422 conditions.append(MusehubProposal.merge_strategy.in_(f.merge_strategy))
423
424 # ── Assigned-reviewer filter ───────────────────────────────────────────────
425 if f and f.assigned_reviewer:
426 reviewer_subq = (
427 select(MusehubProposalReview.proposal_id)
428 .where(
429 MusehubProposalReview.reviewer_username == f.assigned_reviewer,
430 MusehubProposalReview.state.in_(["pending", "approved", "changes_requested"]),
431 )
432 .correlate(MusehubProposal)
433 )
434 conditions.append(MusehubProposal.proposal_id.in_(reviewer_subq))
435
436 # ── Count total matching rows (re-use same joins as data query) ───────────
437 count_stmt = stmt.with_only_columns(
438 func.count(MusehubProposal.proposal_id)
439 ).where(*conditions).order_by(None)
440 total: int = (await session.execute(count_stmt)).scalar_one()
441
442 # ── Sort order ─────────────────────────────────────────────────────────────
443 order_clauses: list[ColumnElement[bool]]
444 cursor_ascending: bool # True → > cursor, False → < cursor
445 if effective_sort == "oldest":
446 order_clauses = [MusehubProposal.created_at.asc()]
447 cursor_ascending = True
448 elif effective_sort == "risk_desc":
449 order_clauses = [MusehubProposal.risk_score.desc().nulls_last()]
450 cursor_ascending = False
451 elif effective_sort == "risk_asc":
452 order_clauses = [MusehubProposal.risk_score.asc().nulls_last()]
453 cursor_ascending = False
454 elif effective_sort == "merge_ready_first":
455 # Sub-select: count approved reviews per proposal; ready = count>=2 AND breakage=0
456 approval_subq = (
457 select(func.count(MusehubProposalReview.review_id))
458 .where(
459 MusehubProposalReview.proposal_id == MusehubProposal.proposal_id,
460 MusehubProposalReview.state == "approved",
461 )
462 .correlate(MusehubProposal)
463 .scalar_subquery()
464 )
465 is_ready = sa.case(
466 (
467 sa.and_(
468 approval_subq >= _DEFAULT_REQUIRED_APPROVALS,
469 MusehubProposal.breakage_count == 0,
470 ),
471 0,
472 ),
473 else_=1,
474 )
475 order_clauses = [is_ready, MusehubProposal.created_at.desc()]
476 cursor_ascending = False
477 else:
478 # newest (default)
479 order_clauses = [MusehubProposal.created_at.desc()]
480 cursor_ascending = False
481
482 # ── Cursor predicate ───────────────────────────────────────────────────────
483 data_conditions = list(conditions)
484 if effective_cursor is not None:
485 cursor_dt = datetime.fromisoformat(effective_cursor)
486 if cursor_ascending:
487 data_conditions.append(MusehubProposal.created_at > cursor_dt)
488 else:
489 data_conditions.append(MusehubProposal.created_at < cursor_dt)
490
491 rows = list(
492 (
493 await session.execute(
494 stmt.where(*data_conditions).order_by(*order_clauses).limit(effective_limit + 1)
495 )
496 ).scalars()
497 )
498
499 next_cursor: str | None = None
500 if len(rows) == effective_limit + 1:
501 next_cursor = rows[effective_limit - 1].created_at.isoformat()
502 rows = rows[:effective_limit]
503
504 return ProposalListResponse(
505 proposals=[_to_proposal_response(r, url_prefix=url_prefix) for r in rows],
506 total=total,
507 next_cursor=next_cursor,
508 )
509
510
511 async def get_proposal(
512 session: AsyncSession,
513 repo_id: str,
514 proposal_id: str,
515 *,
516 url_prefix: str = "",
517 ) -> ProposalResponse | None:
518 """Return a single proposal enriched with DAG position and simulation summaries."""
519 stmt = select(MusehubProposal).where(
520 MusehubProposal.repo_id == repo_id,
521 MusehubProposal.proposal_id == proposal_id,
522 )
523 row = (await session.execute(stmt)).scalar_one_or_none()
524 if row is None:
525 return None
526 dag = await load_dag_for_proposals(session, [proposal_id])
527 sims = await list_simulations(session, repo_id, proposal_id)
528 return _to_proposal_response(row, dag=dag, simulations=sims, url_prefix=url_prefix)
529
530
531 async def update_proposal(
532 session: AsyncSession,
533 repo_id: str,
534 proposal_id: str,
535 *,
536 title: str | None = None,
537 body: str | None = None,
538 proposal_type: str | None = None,
539 merge_strategy: str | None = None,
540 ) -> ProposalResponse | None:
541 """Apply a partial update to a proposal. Returns None if not found."""
542 stmt = select(MusehubProposal).where(
543 MusehubProposal.repo_id == repo_id,
544 MusehubProposal.proposal_id == proposal_id,
545 )
546 row = (await session.execute(stmt)).scalar_one_or_none()
547 if row is None:
548 return None
549 if title is not None:
550 row.title = title
551 if body is not None:
552 row.body = body
553 if proposal_type is not None:
554 row.proposal_type = proposal_type
555 if merge_strategy is not None:
556 row.merge_strategy = merge_strategy
557 await session.commit()
558 await session.refresh(row)
559 dag = await load_dag_for_proposals(session, [proposal_id])
560 sims = await list_simulations(session, repo_id, proposal_id)
561 return _to_proposal_response(row, dag=dag, simulations=sims)
562
563
564 async def _resolve_ancestor_manifest(
565 session: AsyncSession,
566 repo_id: str,
567 from_branch: str,
568 to_branch: str,
569 ) -> StrDict | None:
570 """Find the common ancestor snapshot manifest for a branch pair.
571
572 Walks the from_branch commit history looking for the first commit whose
573 parent_ids overlap with commits reachable from to_branch. This is a
574 lightweight merge-base approximation suitable for server-side strategy
575 computation (not a full LCA traversal).
576
577 Returns None if no ancestor can be found (new repo, orphan branches).
578 """
579 from musehub.graph.walk import walk_dag_async
580 from musehub.services.musehub_snapshot import get_snapshot_manifest
581
582 to_b = await _get_branch(session, repo_id, to_branch)
583 from_b = await _get_branch(session, repo_id, from_branch)
584 if to_b is None or from_b is None:
585 return None
586
587 # Walk 1: collect to_branch ancestry (bounded BFS)
588 to_commit_ids: set[str] = set()
589
590 async def _to_adj(cid: str) -> list[str]:
591 commit = await session.get(MusehubCommit, cid)
592 return commit.parent_ids if commit and commit.parent_ids else []
593
594 async for cid in walk_dag_async(
595 [to_b.head_commit_id] if to_b.head_commit_id else [],
596 _to_adj,
597 max_nodes=200,
598 ):
599 to_commit_ids.add(cid)
600
601 # Walk 2: first-parent walk on from_branch, looking for merge base
602 candidate_id: str | None = None
603
604 async def _from_adj(cid: str) -> list[str]:
605 nonlocal candidate_id
606 commit = await session.get(MusehubCommit, cid)
607 if commit is None:
608 return []
609 for parent_id in (commit.parent_ids or []):
610 if parent_id in to_commit_ids:
611 candidate_id = parent_id
612 return [] # stop walking once merge base is found
613 return commit.parent_ids[:1] if commit.parent_ids else []
614
615 async for _ in walk_dag_async(
616 [from_b.head_commit_id] if from_b.head_commit_id else [],
617 _from_adj,
618 max_nodes=200,
619 ):
620 if candidate_id:
621 break
622
623 if not candidate_id:
624 return None
625
626 ancestor_commit = await session.get(MusehubCommit, candidate_id)
627 if ancestor_commit is None or not ancestor_commit.snapshot_id:
628 return None
629
630 return await get_snapshot_manifest(session, ancestor_commit.snapshot_id)
631
632
633 async def _rebase_commits(
634 session: AsyncSession,
635 repo_id: str,
636 proposal: "MusehubProposal",
637 merger_handle: str,
638 to_b: "MusehubBranch | None",
639 from_b: "MusehubBranch | None",
640 to_manifest: StrDict,
641 compute_commit_id_fn: Callable[..., str],
642 upsert_snapshot_entries_fn: Callable[..., Awaitable[None]],
643 ) -> str:
644 """Replay each from_branch commit individually onto to_branch (linear history).
645
646 Algorithm:
647 1. Collect from_branch commits not reachable from to_branch, oldest-first.
648 2. For each commit in order:
649 a. Compute its file delta vs its parent snapshot.
650 b. Apply that delta to the running rebased state (starts = to_manifest).
651 c. Upsert the new snapshot.
652 d. Create a new MusehubCommit with parent = previous replayed commit.
653 3. Return the commit_id of the tip (last replayed commit).
654 """
655 from musehub.services.musehub_snapshot import get_snapshot_manifest
656
657 to_head_cid = to_b.head_commit_id if to_b else None
658
659 # Collect from_branch commits not on to_branch (walk parent_ids BFS).
660 from_head_cid = from_b.head_commit_id if from_b else None
661 if not from_head_cid:
662 raise ValueError(f"from_branch '{proposal.from_branch}' has no commits")
663
664 to_ancestors: set[str] = set()
665 if to_head_cid:
666 _q: list[str] = [to_head_cid]
667 while _q:
668 _cid = _q.pop()
669 if _cid in to_ancestors:
670 continue
671 to_ancestors.add(_cid)
672 _row = await session.get(MusehubCommit, _cid)
673 if _row:
674 _q.extend(_row.parent_ids or [])
675
676 # BFS from from_head, collecting commits not in to_ancestors.
677 from_commits_unordered: list[MusehubCommit] = []
678 _seen: set[str] = set()
679 _frontier = [from_head_cid]
680 while _frontier:
681 _cid = _frontier.pop(0)
682 if _cid in _seen or _cid in to_ancestors:
683 continue
684 _seen.add(_cid)
685 _c = await session.get(MusehubCommit, _cid)
686 if _c is None:
687 continue
688 from_commits_unordered.append(_c)
689 _frontier.extend(p for p in (_c.parent_ids or []) if p not in _seen)
690
691 # Topological sort: oldest-first (Kahn's algorithm on parent_ids subset).
692 cid_set = {c.commit_id for c in from_commits_unordered}
693 in_degree: dict[str, int] = {c.commit_id: 0 for c in from_commits_unordered}
694 children: dict[str, list[MusehubCommit]] = {c.commit_id: [] for c in from_commits_unordered}
695 for c in from_commits_unordered:
696 for p in (c.parent_ids or []):
697 if p in cid_set:
698 children[p].append(c)
699 in_degree[c.commit_id] += 1
700 queue = [c for c in from_commits_unordered if in_degree[c.commit_id] == 0]
701 from_commits: list[MusehubCommit] = []
702 while queue:
703 c = queue.pop(0)
704 from_commits.append(c)
705 for child in children[c.commit_id]:
706 in_degree[child.commit_id] -= 1
707 if in_degree[child.commit_id] == 0:
708 queue.append(child)
709
710 # Look up generation of to_head for commit graph.
711 _to_gen = 0
712 if to_head_cid:
713 _tg_row = await session.get(MusehubCommitGraph, to_head_cid)
714 if _tg_row:
715 _to_gen = _tg_row.generation
716
717 # Replay each commit.
718 rebased_state: StrDict = dict(to_manifest)
719 current_parent_id: str = to_head_cid or ""
720 current_gen = _to_gen
721 tip_cid = current_parent_id
722
723 for orig in from_commits:
724 # Compute file delta: orig_snapshot vs orig's parent snapshot.
725 orig_manifest = await get_snapshot_manifest(session, orig.snapshot_id) if orig.snapshot_id else {}
726 parent_manifest: StrDict = {}
727 for p in (orig.parent_ids or []):
728 _p_row = await session.get(MusehubCommit, p)
729 if _p_row and _p_row.snapshot_id:
730 parent_manifest = await get_snapshot_manifest(session, _p_row.snapshot_id)
731 break
732
733 # Apply delta to rebased_state.
734 for path, oid in orig_manifest.items():
735 rebased_state[path] = oid
736 for path in list(parent_manifest.keys()):
737 if path not in orig_manifest:
738 rebased_state.pop(path, None)
739
740 # Upsert new snapshot for this rebased state.
741 rebased_snap_id = compute_snapshot_id(rebased_state)
742 await upsert_snapshot_entries_fn(session, repo_id, rebased_snap_id, rebased_state)
743
744 # Create the replayed commit.
745 committed_at = _utc_now()
746 new_parent_ids = [current_parent_id] if current_parent_id else []
747 new_cid = compute_commit_id_fn(
748 new_parent_ids, rebased_snap_id, orig.message,
749 committed_at.isoformat(), author=orig.author or merger_handle,
750 signer_public_key="",
751 )
752 session.add(MusehubCommit(
753 commit_id=new_cid, branch=proposal.to_branch,
754 parent_ids=new_parent_ids, message=orig.message,
755 author=orig.author or merger_handle, timestamp=committed_at,
756 snapshot_id=rebased_snap_id,
757 structured_delta=orig.structured_delta,
758 agent_id=orig.agent_id or "", model_id=orig.model_id or "",
759 ))
760 session.add(MusehubCommitRef(repo_id=repo_id, commit_id=new_cid))
761 current_gen += 1
762 session.add(MusehubCommitGraph(
763 commit_id=new_cid, parent_ids=new_parent_ids,
764 generation=current_gen, snapshot_id=rebased_snap_id,
765 ))
766
767 current_parent_id = new_cid
768 tip_cid = new_cid
769
770 if not tip_cid:
771 raise ValueError("rebase produced no commits — from_branch has no unique commits")
772
773 return tip_cid
774
775
776 async def merge_proposal(
777 session: AsyncSession,
778 repo_id: str,
779 proposal_id: str,
780 *,
781 merge_strategy: str = "merge_commit",
782 merger_handle: str = "",
783 commit_history: str = "merge",
784 ) -> ProposalResponse:
785 """Merge an open proposal.
786
787 Args:
788 merge_strategy: Content/snapshot merge strategy — how file manifests combine.
789 ``overlay`` (default), ``weave``, ``replay``, ``selective``.
790 ``overlay`` (default), ``weave``, ``replay``, ``selective``.
791 commit_history: VCS commit graph style.
792 ``merge`` (default) — new commit with two parents [to_head, from_head].
793 ``squash`` — one new commit, parent = [to_head] only.
794 ``rebase`` — replay each from_branch commit linearly (TODO).
795
796 Raises:
797 ValueError: Proposal not found or branch has no commits.
798 RuntimeError: Proposal is already merged or closed (surfaces as 409).
799 """
800 stmt = select(MusehubProposal).where(
801 MusehubProposal.repo_id == repo_id,
802 MusehubProposal.proposal_id == proposal_id,
803 )
804 proposal = (await session.execute(stmt)).scalar_one_or_none()
805 if proposal is None:
806 raise ValueError(f"Proposal {proposal_id} not found in repo {repo_id}")
807
808 if proposal.state not in ("open", "approved"):
809 raise RuntimeError(f"Proposal {proposal_id} is already {proposal.state}")
810
811 # Gate on hard dependencies — check require_dependency_merged from merge_conditions
812 mc_raw = proposal.merge_conditions or {}
813 if mc_raw.get("require_dependency_merged", True):
814 dag = await load_dag_for_proposals(session, [proposal_id])
815 if is_blocked(dag, proposal_id):
816 unmerged_nums = blocked_by_numbers(dag, proposal_id)
817 raise RuntimeError(
818 f"Proposal {proposal_id} cannot be merged: "
819 f"unmerged dependencies: proposal numbers {unmerged_nums}"
820 )
821
822 from_b = await _get_branch(session, repo_id, proposal.from_branch)
823 to_b = await _get_branch(session, repo_id, proposal.to_branch)
824
825 # Collect parent commit IDs for the merge commit.
826 parent_ids: list[str] = []
827 if to_b is not None and to_b.head_commit_id is not None:
828 parent_ids.append(to_b.head_commit_id)
829 if from_b is not None and from_b.head_commit_id is not None:
830 parent_ids.append(from_b.head_commit_id)
831
832 if not parent_ids:
833 raise ValueError(
834 f"Cannot merge: neither '{proposal.from_branch}' nor '{proposal.to_branch}' has any commits"
835 )
836
837 # Squash: drop from_head from parent_ids so the result is linear.
838 if commit_history == "squash" and to_b is not None and to_b.head_commit_id is not None:
839 parent_ids = [to_b.head_commit_id]
840
841 from musehub.services.musehub_snapshot import get_snapshot_manifest, upsert_snapshot_entries
842 from musehub.services.proposal_merge_strategies import execute_merge_strategy
843
844 to_manifest: StrDict = {}
845 if to_b is not None and to_b.head_commit_id is not None:
846 to_head = await session.get(MusehubCommit, to_b.head_commit_id)
847 if to_head is not None and to_head.snapshot_id:
848 to_manifest = await get_snapshot_manifest(session, to_head.snapshot_id)
849
850 from_manifest: StrDict = {}
851 from_head_snapshot_id: str | None = None
852 if from_b is not None and from_b.head_commit_id is not None:
853 from_head = await session.get(MusehubCommit, from_b.head_commit_id)
854 if from_head is not None and from_head.snapshot_id:
855 from_head_snapshot_id = from_head.snapshot_id
856 from_manifest = await get_snapshot_manifest(session, from_head.snapshot_id)
857
858 # Resolve ancestor manifest for three-way strategies.
859 # The ancestor is the snapshot at the point from_branch was cut from to_branch.
860 # We approximate this as the earliest commit on from_branch that has a parent
861 # on to_branch — or the repo's first commit if the branch predates any tracking.
862 # For OVERLAY (default), the ancestor is used only for conflict audit.
863 ancestor_manifest: StrDict | None = None
864 strategy_name = getattr(proposal, "merge_strategy", "overlay") or "overlay"
865 # Caller can override via merge_strategy parameter (passed as the kwarg).
866 if merge_strategy and merge_strategy != "merge_commit":
867 strategy_name = merge_strategy
868 if strategy_name in ("weave", "replay", "selective", "phased"):
869 # Walk from_branch commit history to find the merge-base with to_branch.
870 # Simplified: look for the first from_branch commit whose parent is in to_branch.
871 ancestor_manifest = await _resolve_ancestor_manifest(
872 session, repo_id, proposal.from_branch, proposal.to_branch
873 )
874
875 selective_domains: list[str] | None = getattr(proposal, "selective_domains", None)
876
877 merge_result = execute_merge_strategy(
878 strategy_name,
879 to_manifest,
880 from_manifest,
881 ancestor_manifest=ancestor_manifest,
882 selective_domains=selective_domains,
883 )
884 merged_manifest: StrDict = merge_result.manifest
885
886 logger.info(
887 "🔀 Merge strategy=%s added=%d modified=%d removed=%d conflicts=%d domains=%s",
888 merge_result.strategy,
889 merge_result.files_added,
890 merge_result.files_modified,
891 merge_result.files_removed,
892 len(merge_result.conflicts),
893 merge_result.domains_merged,
894 )
895
896 # ── Commit creation — varies by commit_history style ────────────────
897 merge_commit_id: str
898
899 if commit_history == "rebase":
900 # Replay each from_branch commit individually onto to_branch.
901 # Walk from_branch ancestors oldest-first, stopping at to_branch head.
902 merge_commit_id = await _rebase_commits(
903 session, repo_id, proposal, merger_handle,
904 to_b, from_b, to_manifest,
905 compute_commit_id, upsert_snapshot_entries,
906 )
907 else:
908 # merge and squash: create a single new commit.
909 merged_snapshot_id = compute_snapshot_id(merged_manifest)
910 await upsert_snapshot_entries(session, repo_id, merged_snapshot_id, merged_manifest)
911
912 merge_message = f"Merge '{proposal.from_branch}' into '{proposal.to_branch}' — proposal: {proposal.title}"
913 committed_at = _utc_now()
914 merge_commit_id = compute_commit_id(
915 parent_ids, merged_snapshot_id, merge_message,
916 committed_at.isoformat(), author=merger_handle, signer_public_key="",
917 )
918 session.add(MusehubCommit(
919 commit_id=merge_commit_id, branch=proposal.to_branch,
920 parent_ids=parent_ids, message=merge_message,
921 author=merger_handle, timestamp=committed_at, snapshot_id=merged_snapshot_id,
922 ))
923 session.add(MusehubCommitRef(repo_id=repo_id, commit_id=merge_commit_id))
924
925 _parent_gen_q = await session.execute(
926 select(MusehubCommitGraph.commit_id, MusehubCommitGraph.generation)
927 .where(MusehubCommitGraph.commit_id.in_(parent_ids))
928 )
929 _parent_gens = [row[1] for row in _parent_gen_q.all()]
930 _merge_generation = (max(_parent_gens) + 1) if _parent_gens else 0
931 session.add(MusehubCommitGraph(
932 commit_id=merge_commit_id, parent_ids=parent_ids,
933 generation=_merge_generation, snapshot_id=merged_snapshot_id,
934 ))
935
936 # Advance (or create) the to_branch head pointer.
937 if to_b is None:
938 to_b = MusehubBranch(
939 branch_id=compute_branch_id(repo_id, proposal.to_branch),
940 repo_id=repo_id,
941 name=proposal.to_branch,
942 head_commit_id=merge_commit_id,
943 )
944 session.add(to_b)
945 else:
946 to_b.head_commit_id = merge_commit_id
947
948 # Delete the source branch so it disappears from refs. This lets
949 # `muse fetch --prune` clean up the local tracking ref automatically,
950 # matching the behaviour users expect after a proposal merge.
951 if from_b is not None:
952 await session.delete(from_b)
953
954 # Refresh touched_symbols from the from_branch commits before marking merged.
955 # This gives the most accurate symbol set at the moment of merge, capturing
956 # any commits pushed to from_branch after the proposal was created.
957 touched = await _touched_symbols_for_branch(session, repo_id, proposal.from_branch)
958 proposal.touched_symbols = touched
959
960 # Mark proposal as merged and record the exact merge timestamp.
961 proposal.state = "merged"
962 proposal.merge_commit_id = merge_commit_id
963 proposal.merged_at = _utc_now()
964
965 await session.flush()
966 await session.refresh(proposal)
967 logger.info(
968 "✅ Merged proposal %s ('%s' → '%s') in repo %s, merge commit %s",
969 proposal_id,
970 proposal.from_branch,
971 proposal.to_branch,
972 repo_id,
973 merge_commit_id,
974 )
975
976 embed = MergeResultEmbed(
977 status="merged",
978 commit_id=merge_commit_id,
979 strategy=merge_result.strategy,
980 on_conflict=None,
981 history=commit_history,
982 conflicts=[c.path for c in merge_result.conflicts],
983 files_changed={
984 "added": merge_result.files_added,
985 "modified": merge_result.files_modified,
986 "deleted": merge_result.files_removed,
987 },
988 semver_impact="",
989 )
990 return _to_proposal_response(proposal, merge_result=embed)
991
992
993 # ---------------------------------------------------------------------------
994 # Reopen proposal
995 # ---------------------------------------------------------------------------
996
997
998 async def close_proposal(
999 session: AsyncSession,
1000 repo_id: str,
1001 proposal_id: str,
1002 ) -> ProposalResponse:
1003 """Set an open proposal to ``closed`` state.
1004
1005 Raises:
1006 KeyError: proposal not found in this repo.
1007 RuntimeError: proposal is already closed or merged.
1008 """
1009 proposal = (await session.execute(
1010 select(MusehubProposal).where(
1011 MusehubProposal.proposal_id == proposal_id,
1012 MusehubProposal.repo_id == repo_id,
1013 )
1014 )).scalar_one_or_none()
1015 if proposal is None:
1016 raise KeyError(f"Proposal {proposal_id} not found in repo {repo_id}")
1017 if proposal.state != "open":
1018 raise RuntimeError(f"Proposal {proposal_id} is already {proposal.state}")
1019 proposal.state = "closed"
1020 await session.flush()
1021 await session.refresh(proposal)
1022 return _to_proposal_response(proposal)
1023
1024
1025 async def reopen_proposal(
1026 session: AsyncSession,
1027 repo_id: str,
1028 proposal_id: str,
1029 ) -> ProposalResponse:
1030 """Reset a merged or closed proposal back to ``open`` state.
1031
1032 Clears ``merge_commit_id`` and ``merged_at`` so the proposal can be
1033 re-merged after a corrupt commit is cleaned up (e.g. bug #36).
1034
1035 Raises:
1036 KeyError: proposal not found in this repo.
1037 RuntimeError: proposal is already open.
1038 """
1039 proposal = (await session.execute(
1040 select(MusehubProposal).where(
1041 MusehubProposal.proposal_id == proposal_id,
1042 MusehubProposal.repo_id == repo_id,
1043 )
1044 )).scalar_one_or_none()
1045 if proposal is None:
1046 raise KeyError(f"Proposal {proposal_id} not found in repo {repo_id}")
1047 if proposal.state == "open":
1048 raise RuntimeError(f"Proposal {proposal_id} is already open")
1049 proposal.state = "open"
1050 proposal.merge_commit_id = None
1051 proposal.merged_at = None
1052 await session.flush()
1053 await session.refresh(proposal)
1054 return _to_proposal_response(proposal)
1055
1056
1057 # ---------------------------------------------------------------------------
1058 # Proposal review comments
1059 # ---------------------------------------------------------------------------
1060
1061
1062 def _to_comment_response(row: MusehubProposalComment) -> ProposalCommentResponse:
1063 dim_ref: JSONObject = row.dimension_ref or {}
1064 return ProposalCommentResponse(
1065 comment_id=row.comment_id,
1066 proposal_id=row.proposal_id,
1067 author=row.author,
1068 author_user_id=getattr(row, "author_user_id", None),
1069 agent_id=getattr(row, "agent_id", None) or None,
1070 model_id=getattr(row, "model_id", None) or None,
1071 body=row.body,
1072 target_type=str(dim_ref.get("type", "general")),
1073 target_track=str(dim_ref["track"]) if "track" in dim_ref else None,
1074 target_beat_start=float(dim_ref["beat_start"]) if isinstance(dim_ref.get("beat_start"), (int, float)) else None,
1075 target_beat_end=float(dim_ref["beat_end"]) if isinstance(dim_ref.get("beat_end"), (int, float)) else None,
1076 target_note_pitch=int(dim_ref["pitch"]) if isinstance(dim_ref.get("pitch"), int) else None,
1077 parent_comment_id=row.parent_comment_id,
1078 symbol_address=row.symbol_address,
1079 created_at=row.created_at,
1080 updated_at=getattr(row, "updated_at", None),
1081 is_deleted=bool(getattr(row, "is_deleted", False)),
1082 )
1083
1084
1085 async def create_proposal_comment(
1086 session: AsyncSession,
1087 *,
1088 proposal_id: str,
1089 repo_id: str,
1090 author: str,
1091 author_identity_id: str = "",
1092 body: str,
1093 target_type: str = "general",
1094 target_track: str | None = None,
1095 target_beat_start: float | None = None,
1096 target_beat_end: float | None = None,
1097 target_note_pitch: int | None = None,
1098 parent_comment_id: str | None = None,
1099 symbol_address: str | None = None,
1100 ) -> ProposalCommentResponse:
1101 """Persist a new review comment on a proposal and return its wire representation.
1102
1103 ``author`` is the MSign handle of the reviewer.
1104 ``parent_comment_id`` must be an existing top-level comment on the same proposal
1105 when creating a threaded reply; the caller validates this constraint before
1106 calling here.
1107
1108 Raises ``ValueError`` if the proposal does not exist in the given repo.
1109 """
1110 stmt = select(MusehubProposal).where(
1111 MusehubProposal.proposal_id == proposal_id,
1112 MusehubProposal.repo_id == repo_id,
1113 )
1114 proposal = (await session.execute(stmt)).scalar_one_or_none()
1115 if proposal is None:
1116 raise ValueError(f"Proposal {proposal_id} not found in repo {repo_id}")
1117
1118 dimension_ref: JSONObject = {"type": target_type}
1119 if target_track is not None:
1120 dimension_ref["track"] = target_track
1121 if target_beat_start is not None:
1122 dimension_ref["beat_start"] = target_beat_start
1123 if target_beat_end is not None:
1124 dimension_ref["beat_end"] = target_beat_end
1125 if target_note_pitch is not None:
1126 dimension_ref["pitch"] = target_note_pitch
1127
1128 _created_at = _utc_now()
1129 comment = MusehubProposalComment(
1130 comment_id=compute_comment_id(proposal_id, author_identity_id, _created_at.isoformat()),
1131 proposal_id=proposal_id,
1132 repo_id=repo_id,
1133 author=author,
1134 body=body,
1135 dimension_ref=dimension_ref,
1136 parent_comment_id=parent_comment_id,
1137 symbol_address=symbol_address or None,
1138 created_at=_created_at,
1139 )
1140 session.add(comment)
1141 await session.flush()
1142 await session.refresh(comment)
1143 logger.info("✅ Created proposal comment %s on proposal %s by %s", comment.comment_id, proposal_id, author)
1144 return _to_comment_response(comment)
1145
1146
1147 async def backfill_comment_author_user_ids(
1148 session: AsyncSession,
1149 repo_id: str | None = None,
1150 batch: int = 500,
1151 ) -> int:
1152 """Populate author_user_id on proposal comments that have none.
1153
1154 Joins musehub_proposal_comments → musehub_identities on handle (author)
1155 and writes the identity_id. Idempotent — rows already populated are skipped.
1156 Returns count of rows updated.
1157 """
1158 from musehub.db.musehub_identity_models import MusehubIdentity
1159
1160 where = [MusehubProposalComment.author_user_id.is_(None)]
1161 if repo_id:
1162 where.append(MusehubProposalComment.repo_id == repo_id)
1163
1164 rows = (await session.execute(
1165 select(MusehubProposalComment).where(*where).limit(batch)
1166 )).scalars().all()
1167
1168 if not rows:
1169 return 0
1170
1171 handles = {r.author for r in rows}
1172 identity_rows = (await session.execute(
1173 select(MusehubIdentity.handle, MusehubIdentity.identity_id)
1174 .where(MusehubIdentity.handle.in_(handles))
1175 )).all()
1176 handle_to_id = {h: iid for h, iid in identity_rows}
1177
1178 updated = 0
1179 for row in rows:
1180 uid = handle_to_id.get(row.author)
1181 if uid:
1182 row.author_user_id = uid
1183 updated += 1
1184
1185 if updated:
1186 await session.flush()
1187
1188 return updated
1189
1190
1191 async def list_proposal_comments(
1192 session: AsyncSession,
1193 proposal_id: str,
1194 repo_id: str,
1195 cursor: str | None = None,
1196 limit: int = 20,
1197 ) -> ProposalCommentListResponse:
1198 """Return review comments for a proposal with cursor-based keyset pagination.
1199
1200 Comments are assembled into a two-level thread tree from the current page.
1201 Top-level comments (``parent_comment_id`` is None) form the root list.
1202 Each carries a ``replies`` list with direct children that appear within
1203 the same page, sorted by ``created_at`` ascending. Grandchildren are not
1204 supported — callers should reply to the original top-level comment.
1205
1206 ``cursor`` is the ISO 8601 ``created_at`` of the last seen comment
1207 (opaque to callers — pass ``nextCursor`` from a previous response
1208 verbatim). Omit to start from the beginning. ``total`` covers all
1209 comments on the proposal regardless of the current page.
1210 """
1211 conditions = [
1212 MusehubProposalComment.proposal_id == proposal_id,
1213 MusehubProposalComment.repo_id == repo_id,
1214 MusehubProposalComment.is_deleted.is_(False),
1215 ]
1216
1217 count_stmt = select(func.count(MusehubProposalComment.comment_id)).where(*conditions)
1218 total: int = (await session.execute(count_stmt)).scalar_one()
1219
1220 data_conditions = list(conditions)
1221 if cursor is not None:
1222 data_conditions.append(
1223 MusehubProposalComment.created_at > datetime.fromisoformat(cursor)
1224 )
1225
1226 rows = list(
1227 (
1228 await session.execute(
1229 select(MusehubProposalComment)
1230 .where(*data_conditions)
1231 .order_by(MusehubProposalComment.created_at)
1232 .limit(limit + 1)
1233 )
1234 ).scalars()
1235 )
1236
1237 next_cursor: str | None = None
1238 if len(rows) == limit + 1:
1239 next_cursor = rows[limit - 1].created_at.isoformat()
1240 rows = rows[:limit]
1241
1242 # Build id → response map first; attach replies in a second pass.
1243 top_level: list[ProposalCommentResponse] = []
1244 by_id: _CommentMap = {}
1245 for row in rows:
1246 resp = _to_comment_response(row)
1247 by_id[row.comment_id] = resp
1248 if row.parent_comment_id is None:
1249 top_level.append(resp)
1250
1251 for row in rows:
1252 if row.parent_comment_id is not None:
1253 parent = by_id.get(row.parent_comment_id)
1254 if parent is not None:
1255 parent.replies.append(by_id[row.comment_id])
1256
1257 return ProposalCommentListResponse(comments=top_level, total=total, next_cursor=next_cursor)
1258
1259
1260 # ---------------------------------------------------------------------------
1261 # Proposal reviews (reviewer assignment + approval workflow)
1262 # ---------------------------------------------------------------------------
1263
1264
1265 def _to_review_response(row: MusehubProposalReview) -> ProposalReviewResponse:
1266 return ProposalReviewResponse(
1267 id=row.review_id,
1268 proposal_id=row.proposal_id,
1269 reviewer_username=row.reviewer_username,
1270 state=row.state,
1271 body=row.body,
1272 submitted_at=row.submitted_at,
1273 created_at=row.created_at,
1274 )
1275
1276
1277 async def _assert_proposal_exists(session: AsyncSession, repo_id: str, proposal_id: str) -> None:
1278 """Raise ``ValueError`` if the proposal does not exist in the given repo."""
1279 stmt = select(MusehubProposal).where(
1280 MusehubProposal.proposal_id == proposal_id,
1281 MusehubProposal.repo_id == repo_id,
1282 )
1283 proposal = (await session.execute(stmt)).scalar_one_or_none()
1284 if proposal is None:
1285 raise ValueError(f"Proposal {proposal_id} not found in repo {repo_id}")
1286
1287
1288 async def request_reviewers(
1289 session: AsyncSession,
1290 *,
1291 repo_id: str,
1292 proposal_id: str,
1293 reviewers: list[str],
1294 ) -> ProposalReviewListResponse:
1295 """Add reviewer assignments to a proposal, creating a ``pending`` row for each.
1296
1297 Idempotent: if a reviewer already has a row (in any state), the existing row
1298 is left unchanged so a submitted approval is never reset by a re-request.
1299
1300 Raises ``ValueError`` if the proposal does not exist in the repo.
1301
1302 Returns the full updated review list for the proposal.
1303 """
1304 await _assert_proposal_exists(session, repo_id, proposal_id)
1305
1306 for username in reviewers:
1307 existing_stmt = select(MusehubProposalReview).where(
1308 MusehubProposalReview.proposal_id == proposal_id,
1309 MusehubProposalReview.reviewer_username == username,
1310 )
1311 existing = (await session.execute(existing_stmt)).scalar_one_or_none()
1312 if existing is None:
1313 now = _utc_now()
1314 identity_stmt = select(MusehubIdentity.identity_id).where(
1315 MusehubIdentity.handle == username
1316 )
1317 reviewer_identity_id = (await session.execute(identity_stmt)).scalar_one_or_none() or username
1318 review = MusehubProposalReview(
1319 review_id=compute_review_id(proposal_id, reviewer_identity_id, now.isoformat()),
1320 proposal_id=proposal_id,
1321 reviewer_username=username,
1322 state="pending",
1323 created_at=now,
1324 )
1325 session.add(review)
1326 logger.info("✅ Requested review from '%s' on proposal %s", username, proposal_id)
1327
1328 await session.flush()
1329 return await list_reviews(session, repo_id=repo_id, proposal_id=proposal_id)
1330
1331
1332 async def remove_reviewer(
1333 session: AsyncSession,
1334 *,
1335 repo_id: str,
1336 proposal_id: str,
1337 username: str,
1338 ) -> ProposalReviewListResponse:
1339 """Remove a pending review request for ``username`` on a proposal.
1340
1341 Only ``pending`` rows may be removed — submitted reviews are immutable to
1342 preserve the audit trail.
1343
1344 Raises ``ValueError`` if the proposal does not exist, the reviewer was never
1345 requested, or the reviewer has already submitted a non-pending review.
1346
1347 Returns the updated review list.
1348 """
1349 await _assert_proposal_exists(session, repo_id, proposal_id)
1350
1351 stmt = select(MusehubProposalReview).where(
1352 MusehubProposalReview.proposal_id == proposal_id,
1353 MusehubProposalReview.reviewer_username == username,
1354 )
1355 row = (await session.execute(stmt)).scalar_one_or_none()
1356 if row is None:
1357 raise ValueError(f"Reviewer '{username}' was not requested on proposal {proposal_id}")
1358 if row.state != "pending":
1359 raise ValueError(
1360 f"Cannot remove reviewer '{username}': review already submitted (state={row.state})"
1361 )
1362
1363 await session.delete(row)
1364 await session.flush()
1365 logger.info("✅ Removed review request for '%s' from proposal %s", username, proposal_id)
1366 return await list_reviews(session, repo_id=repo_id, proposal_id=proposal_id)
1367
1368
1369 async def list_reviews(
1370 session: AsyncSession,
1371 *,
1372 repo_id: str,
1373 proposal_id: str,
1374 state: str | None = None,
1375 cursor: str | None = None,
1376 limit: int = 20,
1377 ) -> ProposalReviewListResponse:
1378 """Return reviews for a proposal with cursor-based keyset pagination.
1379
1380 ``state`` may be one of ``pending``, ``approved``, ``changes_requested``,
1381 or ``dismissed``. When ``None``, all reviews are returned.
1382 Results are ordered by ``created_at`` ascending.
1383
1384 ``cursor`` is the ISO 8601 ``created_at`` of the last seen review
1385 (opaque to callers — pass ``nextCursor`` from a previous response
1386 verbatim). Omit to start from the beginning.
1387
1388 Raises ``ValueError`` if the proposal does not exist in the repo.
1389 """
1390 await _assert_proposal_exists(session, repo_id, proposal_id)
1391
1392 conditions = [MusehubProposalReview.proposal_id == proposal_id]
1393 if state is not None:
1394 conditions.append(MusehubProposalReview.state == state)
1395
1396 count_stmt = select(func.count(MusehubProposalReview.review_id)).where(*conditions)
1397 total: int = (await session.execute(count_stmt)).scalar_one()
1398
1399 data_conditions = list(conditions)
1400 if cursor is not None:
1401 data_conditions.append(
1402 MusehubProposalReview.created_at > datetime.fromisoformat(cursor)
1403 )
1404
1405 rows = list(
1406 (
1407 await session.execute(
1408 select(MusehubProposalReview)
1409 .where(*data_conditions)
1410 .order_by(MusehubProposalReview.created_at)
1411 .limit(limit + 1)
1412 )
1413 ).scalars()
1414 )
1415
1416 next_cursor: str | None = None
1417 if len(rows) == limit + 1:
1418 next_cursor = rows[limit - 1].created_at.isoformat()
1419 rows = rows[:limit]
1420
1421 return ProposalReviewListResponse(
1422 reviews=[_to_review_response(r) for r in rows],
1423 total=total,
1424 next_cursor=next_cursor,
1425 )
1426
1427
1428 async def submit_review(
1429 session: AsyncSession,
1430 *,
1431 repo_id: str,
1432 proposal_id: str,
1433 reviewer_username: str,
1434 reviewer_identity_id: str = "",
1435 verdict: str,
1436 body: str = "",
1437 ) -> ProposalReviewResponse:
1438 """Submit or update a formal review verdict for ``reviewer_username`` on a proposal.
1439
1440 ``verdict`` maps to a new state:
1441 - ``approve`` → ``approved``
1442 - ``request_changes`` → ``changes_requested``
1443
1444 If an existing row for this reviewer already exists, it is updated in-place,
1445 so changing from approve → request_changes (or vice versa) works by resubmitting.
1446 Ad-hoc reviews (no prior reviewer request) are also allowed.
1447
1448 Raises ``ValueError`` if the proposal does not exist in the repo.
1449 """
1450 await _assert_proposal_exists(session, repo_id, proposal_id)
1451
1452 _VERDICT_TO_STATE: StrDict = {
1453 "approve": "approved",
1454 "request_changes": "changes_requested",
1455 }
1456 if verdict not in _VERDICT_TO_STATE:
1457 raise ValueError(f"Invalid verdict '{verdict}'. Must be approve or request_changes.")
1458 new_state = _VERDICT_TO_STATE[verdict]
1459
1460 stmt = select(MusehubProposalReview).where(
1461 MusehubProposalReview.proposal_id == proposal_id,
1462 MusehubProposalReview.reviewer_username == reviewer_username,
1463 )
1464 row = (await session.execute(stmt)).scalar_one_or_none()
1465
1466 now = _utc_now()
1467 if row is None:
1468 row = MusehubProposalReview(
1469 review_id=compute_review_id(proposal_id, reviewer_identity_id, now.isoformat()),
1470 proposal_id=proposal_id,
1471 reviewer_username=reviewer_username,
1472 state=new_state,
1473 body=body or None,
1474 submitted_at=now,
1475 created_at=now,
1476 )
1477 session.add(row)
1478 else:
1479 row.state = new_state
1480 row.body = body or None
1481 row.submitted_at = now
1482
1483 await session.flush()
1484 await session.refresh(row)
1485 logger.info(
1486 "✅ Review submitted by '%s' on proposal %s: verdict=%s state=%s",
1487 reviewer_username,
1488 proposal_id,
1489 verdict,
1490 new_state,
1491 )
1492 return _to_review_response(row)
1493
1494 # ── Proposal list enrichment ──────────────────────────────────────────────────
1495
1496 _RISK_BAND_THRESHOLDS: list[tuple[float, str]] = [
1497 (0.75, "critical"),
1498 (0.50, "high"),
1499 (0.25, "medium"),
1500 (0.01, "low"),
1501 ]
1502
1503
1504 def _score_to_band(score: float) -> str:
1505 """Map a [0.0, 1.0] risk score to a human-readable band label.
1506
1507 Thresholds:
1508 ≥ 0.75 → "critical"
1509 ≥ 0.50 → "high"
1510 ≥ 0.25 → "medium"
1511 > 0.0 → "low"
1512 0.0 → "none"
1513 """
1514 for threshold, band in _RISK_BAND_THRESHOLDS:
1515 if score >= threshold:
1516 return band
1517 return "none"
1518
1519
1520 # Default required-approvals when merge_conditions is null.
1521 _DEFAULT_REQUIRED_APPROVALS = 2
1522
1523 # Domain weight map used for aggregate risk score. Unknown domains default to 1.0.
1524 _DOMAIN_WEIGHTS: dict[str, float] = {
1525 "code": 1.2,
1526 "midi": 1.0,
1527 "stems": 1.0,
1528 "pay": 1.5,
1529 }
1530
1531
1532 class _ProposalPrefetch:
1533 """Holds pre-fetched batch data for a page of proposals.
1534
1535 All DB reads for an entire page happen once in
1536 ``enrich_proposal_list_batch``; each ``enrich_proposal_list_entry`` call
1537 consults these in-memory maps — zero additional DB I/O per row.
1538 """
1539
1540 def __init__(
1541 self,
1542 *,
1543 reviews_by_proposal: dict[str, list[MusehubProposalReview]],
1544 author_types: dict[str, str],
1545 dag: ProposalDag | None = None,
1546 conflict_counts: dict[str, int | None] | None = None,
1547 ) -> None:
1548 self.reviews_by_proposal = reviews_by_proposal
1549 self.author_types = author_types
1550 self.dag: ProposalDag = dag or ProposalDag()
1551 # proposal_id → conflict_scan.result["conflict_count"]; None if not run
1552 self.conflict_counts: dict[str, int | None] = conflict_counts or {}
1553
1554
1555 async def _prefetch_for_batch(
1556 proposals: list[MusehubProposal],
1557 session: AsyncSession,
1558 ) -> _ProposalPrefetch:
1559 """Run the batch pre-fetch queries for a page of proposals.
1560
1561 Issues exactly two DB queries regardless of page size:
1562 1. All reviews for every proposal in the page.
1563 2. Identity types for every author in the page.
1564
1565 Args:
1566 proposals: ORM rows for the current page.
1567 session: Shared async session.
1568
1569 Returns:
1570 ``_ProposalPrefetch`` with maps keyed by proposal_id / author handle.
1571 """
1572 proposal_ids = [p.proposal_id for p in proposals]
1573 author_handles = list({p.author for p in proposals if p.author})
1574
1575 # Query 1 — reviews
1576 reviews_by_proposal: dict[str, list[MusehubProposalReview]] = {pid: [] for pid in proposal_ids}
1577 if proposal_ids:
1578 review_rows = list(
1579 (
1580 await session.execute(
1581 select(MusehubProposalReview).where(
1582 MusehubProposalReview.proposal_id.in_(proposal_ids)
1583 )
1584 )
1585 ).scalars()
1586 )
1587 for row in review_rows:
1588 reviews_by_proposal[row.proposal_id].append(row)
1589
1590 # Query 2 — identity types
1591 author_types: dict[str, str] = {}
1592 if author_handles:
1593 identity_rows = list(
1594 (
1595 await session.execute(
1596 select(MusehubIdentity.handle, MusehubIdentity.identity_type).where(
1597 MusehubIdentity.handle.in_(author_handles)
1598 )
1599 )
1600 ).all()
1601 )
1602 for handle, itype in identity_rows:
1603 author_types[handle] = itype
1604
1605 # Query 3 — dependency DAG (partial, scoped to this page + neighbours)
1606 dag = await load_dag_for_proposals(session, proposal_ids)
1607
1608 # Query 4 — latest conflict_scan simulation per proposal (for list summary)
1609 conflict_counts: dict[str, int | None] = {pid: None for pid in proposal_ids}
1610 if proposal_ids:
1611 sim_rows = list(
1612 (
1613 await session.execute(
1614 select(
1615 MusehubProposalSimulation.proposal_id,
1616 MusehubProposalSimulation.result,
1617 ).where(
1618 MusehubProposalSimulation.proposal_id.in_(proposal_ids),
1619 MusehubProposalSimulation.simulation_type == "conflict_scan",
1620 )
1621 )
1622 ).all()
1623 )
1624 for pid, result_json in sim_rows:
1625 if isinstance(result_json, dict):
1626 conflict_counts[pid] = result_json.get("conflict_count")
1627
1628 return _ProposalPrefetch(
1629 reviews_by_proposal=reviews_by_proposal,
1630 author_types=author_types,
1631 dag=dag,
1632 conflict_counts=conflict_counts,
1633 )
1634
1635
1636 def _enrich_one(
1637 proposal: MusehubProposal,
1638 prefetch: _ProposalPrefetch,
1639 ) -> ProposalListEntry:
1640 """Compute all display-facing fields for a single proposal list row.
1641
1642 This is the single source of truth for what the proposals list view renders
1643 per row. It does not issue any DB queries — all needed data comes from
1644 ``prefetch``, which is populated by ``_prefetch_for_batch`` before this
1645 function is called.
1646
1647 Computed fields (all server-side):
1648 - active_domains: domains with non-zero risk_score
1649 - domain_risk / domain_risk_band: derived from proposal.risk_score
1650 (currently a single code-domain score; extended as more domains land)
1651 - aggregate_risk_score: weighted mean across active domains
1652 - aggregate_risk_band: band for the aggregate score
1653 - approval_count / domains_approved / domains_pending_review:
1654 derived from pre-fetched reviews
1655 - all_merge_conditions_met: approval_count >= required_approvals
1656 and breakage_count == 0
1657 - author_type: resolved from pre-fetched MusehubIdentity rows
1658
1659 Performance contract:
1660 Zero DB I/O. All reads come from the ``prefetch`` maps. Typical
1661 wall time: < 1ms per row on warm prefetch data.
1662
1663 Args:
1664 proposal: ORM row for this proposal.
1665 prefetch: Pre-fetched batch data from ``_prefetch_for_batch``.
1666
1667 Returns:
1668 ``ProposalListEntry`` with all fields populated.
1669
1670 Raises:
1671 ValueError: If ``proposal.risk_score`` is outside ``[0.0, 1.0]``.
1672 """
1673 pid = proposal.proposal_id
1674
1675 # ── Risk ─────────────────────────────────────────────────────────────────
1676 # Use dimensional_risk dict (Phase 1 ORM columns) when present; fall back
1677 # to the scalar risk_score as code-domain risk for backwards compatibility.
1678 raw_dimensional = dict(getattr(proposal, "dimensional_risk", None) or {})
1679 if raw_dimensional:
1680 domain_risk = {d: float(v) for d, v in raw_dimensional.items() if float(v) > 0.0}
1681 else:
1682 code_risk = float(proposal.risk_score or 0.0)
1683 if not (0.0 <= code_risk <= 1.0):
1684 raise ValueError(f"proposal {pid}: risk_score {code_risk!r} out of [0, 1]")
1685 domain_risk = {"code": code_risk} if code_risk > 0.0 else {}
1686 active_domains = list(domain_risk.keys())
1687 domain_risk_band = {d: _score_to_band(v) for d, v in domain_risk.items()}
1688
1689 # Weighted aggregate
1690 if domain_risk:
1691 total_weight = sum(_DOMAIN_WEIGHTS.get(d, 1.0) for d in domain_risk)
1692 aggregate_risk_score = sum(
1693 v * _DOMAIN_WEIGHTS.get(d, 1.0) for d, v in domain_risk.items()
1694 ) / total_weight
1695 else:
1696 aggregate_risk_score = 0.0
1697 aggregate_risk_band = _score_to_band(aggregate_risk_score)
1698
1699 # ── Dependency position ───────────────────────────────────────────────────
1700 dag = prefetch.dag
1701 dep_blocked_by = blocked_by_numbers(dag, pid)
1702 dep_blocks = blocks_numbers(dag, pid)
1703 dep_is_blocked = is_blocked(dag, pid)
1704 mc_raw = proposal.merge_conditions or {}
1705 require_dep_merged: bool = mc_raw.get("require_dependency_merged", True)
1706 deps_satisfied = (not require_dep_merged) or (not dep_is_blocked)
1707
1708 # ── Reviews ───────────────────────────────────────────────────────────────
1709 reviews = prefetch.reviews_by_proposal.get(pid, [])
1710 approved_reviews = [r for r in reviews if r.state == "approved"]
1711 approval_count = len(approved_reviews)
1712 required_approvals = _DEFAULT_REQUIRED_APPROVALS
1713 domains_approved = ["code"] if approval_count > 0 and "code" in active_domains else []
1714 domains_pending_review = [d for d in active_domains if d not in domains_approved]
1715 all_merge_conditions_met = (
1716 approval_count >= required_approvals
1717 and proposal.breakage_count == 0
1718 and deps_satisfied
1719 )
1720
1721 # ── Author type ───────────────────────────────────────────────────────────
1722 author_type = prefetch.author_types.get(proposal.author, "human")
1723 agent_model: str | None = getattr(proposal, "agent_model", None)
1724 agent_spawned_by: str | None = getattr(proposal, "agent_spawned_by", None)
1725
1726 # ── Symbol preview ────────────────────────────────────────────────────────
1727 touched = list(proposal.touched_symbols or [])
1728 touched_symbols_preview = touched[:3]
1729
1730 return ProposalListEntry(
1731 proposal_id=proposal.proposal_id,
1732 proposal_number=proposal.proposal_number,
1733 title=(proposal.title[:80] + "…") if len(proposal.title) > 80 else proposal.title,
1734 state=proposal.state,
1735 proposal_type=getattr(proposal, "proposal_type", "state_merge"),
1736 from_branch=proposal.from_branch,
1737 to_branch=proposal.to_branch,
1738 author=proposal.author,
1739 author_type=author_type,
1740 created_at=proposal.created_at,
1741 merged_at=proposal.merged_at,
1742 is_draft=getattr(proposal, "is_draft", proposal.state == "drafting"),
1743 active_domains=active_domains,
1744 domain_risk=domain_risk,
1745 domain_risk_band=domain_risk_band,
1746 aggregate_risk_score=round(aggregate_risk_score, 4),
1747 aggregate_risk_band=aggregate_risk_band,
1748 approval_count=approval_count,
1749 required_approvals=required_approvals,
1750 domains_approved=domains_approved,
1751 domains_pending_review=domains_pending_review,
1752 all_merge_conditions_met=all_merge_conditions_met,
1753 blocked_by=dep_blocked_by,
1754 blocks=dep_blocks,
1755 is_blocked=dep_is_blocked,
1756 symbols_changed=proposal.symbols_changed,
1757 breakage_count=proposal.breakage_count,
1758 test_gap_count=proposal.test_gap_count,
1759 touched_symbols_preview=touched_symbols_preview,
1760 midi_tracks_changed=getattr(proposal, "midi_tracks_changed", 0),
1761 midi_notes_delta=getattr(proposal, "midi_notes_delta", 0),
1762 harmonic_tension_delta=getattr(proposal, "harmonic_tension_delta", None),
1763 payment_claim_count=getattr(proposal, "payment_claim_count", 0),
1764 payment_ledger_delta_nano=getattr(proposal, "payment_ledger_delta_nano", 0),
1765 payment_avax_address=getattr(proposal, "payment_avax_address", None),
1766 payment_settling=proposal.state == "settling" and "pay" in active_domains,
1767 agent_model=agent_model,
1768 agent_spawned_by=agent_spawned_by,
1769 merge_strategy=getattr(proposal, "merge_strategy", "overlay") or "overlay",
1770 simulation_conflict_count=prefetch.conflict_counts.get(pid),
1771 )
1772
1773
1774 async def enrich_proposal_list_entry(
1775 proposal: MusehubProposal,
1776 session: AsyncSession,
1777 ) -> ProposalListEntry:
1778 """Compute all display-facing fields for a single proposal list row.
1779
1780 Convenience wrapper around ``_enrich_one`` for callers that need to enrich
1781 a single proposal without a pre-existing batch context. Issues its own
1782 prefetch queries (2 DB round-trips).
1783
1784 For a full page (≥2 proposals) prefer ``enrich_proposal_list_batch`` which
1785 amortises the prefetch cost across all rows via a single parallel pass.
1786
1787 Args:
1788 proposal: ORM row for this proposal. Must have ``repo_id`` set.
1789 session: Async database session.
1790
1791 Returns:
1792 ``ProposalListEntry`` with all fields populated.
1793
1794 Raises:
1795 ValueError: If ``proposal.risk_score`` is outside ``[0.0, 1.0]``.
1796 """
1797 prefetch = await _prefetch_for_batch([proposal], session)
1798 return _enrich_one(proposal, prefetch)
1799
1800
1801 async def enrich_proposal_list_batch(
1802 proposals: list[MusehubProposal],
1803 session: AsyncSession,
1804 ) -> list[ProposalListEntry]:
1805 """Enrich a full page of proposal rows in a single parallel pass.
1806
1807 Pre-fetch strategy:
1808 Issues exactly 2 DB queries for the entire batch (reviews + identity
1809 types), then calls ``_enrich_one`` for each row synchronously.
1810 The synchronous per-row work is CPU-only (no I/O), so no concurrency
1811 overhead is needed.
1812
1813 Ordering:
1814 Result list is in the same order as ``proposals``.
1815
1816 Args:
1817 proposals: ORM rows for the current page (typically ≤ 20).
1818 session: Async session shared across the batch.
1819
1820 Returns:
1821 List of ``ProposalListEntry`` in the same order as ``proposals``.
1822
1823 Performance target: < 50ms for 20 proposals (dominated by the 2 DB
1824 queries; per-row computation is < 0.1ms).
1825 """
1826 if not proposals:
1827 return []
1828 prefetch = await _prefetch_for_batch(proposals, session)
1829 return [_enrich_one(p, prefetch) for p in proposals]
1830
1831
1832 async def get_domain_heat(
1833 repo_id: str,
1834 state: str,
1835 session: AsyncSession,
1836 ) -> DomainHeatResponse:
1837 """Return per-domain proposal counts and average risk for the heat bar.
1838
1839 Runs a single aggregation query against ``musehub_proposals`` filtered by
1840 ``repo_id`` and ``state``. The heat bar currently reflects the code domain
1841 only (the only domain with populated risk scores at this phase); additional
1842 domains are added as multi-domain risk rows land in Phase 2.
1843
1844 ``avg_risk`` is the arithmetic mean of non-zero ``risk_score`` values for
1845 proposals in the given state. Domains with zero matching proposals are
1846 omitted from the response dict.
1847
1848 Args:
1849 repo_id: Repository to query.
1850 state: Proposal state filter (e.g. ``"open"``). Pass ``"open"`` for
1851 the standard heat bar view. Pass ``"all"`` to skip the state
1852 filter entirely.
1853 session: Async session.
1854
1855 Returns:
1856 ``DomainHeatResponse`` with ``domains`` dict and ``total_open`` count.
1857
1858 Performance target: < 20ms (single aggregation query, no per-row work).
1859 """
1860 conditions = [MusehubProposal.repo_id == repo_id]
1861 if state != "all":
1862 conditions.append(MusehubProposal.state == state)
1863
1864 total: int = (
1865 await session.execute(
1866 select(func.count(MusehubProposal.proposal_id)).where(*conditions)
1867 )
1868 ).scalar_one()
1869
1870 # Code domain: all proposals in this repo/state (code is the only domain for now).
1871 # avg_risk computed from non-null, non-zero risk_score values only.
1872 risk_rows = list(
1873 (
1874 await session.execute(
1875 select(MusehubProposal.risk_score).where(
1876 *conditions,
1877 MusehubProposal.risk_score.isnot(None),
1878 MusehubProposal.risk_score > 0.0,
1879 )
1880 )
1881 ).scalars()
1882 )
1883 avg_risk = round(sum(risk_rows) / len(risk_rows), 4) if risk_rows else 0.0
1884
1885 # All known domains — code count = total (single-domain repos); others = 0.
1886 # Multi-domain heat will populate midi/stems/pay when those proposals land.
1887 domains: dict[str, DomainHeatEntry] = {
1888 "code": DomainHeatEntry(count=total, avg_risk=avg_risk),
1889 "midi": DomainHeatEntry(count=0, avg_risk=0.0),
1890 "stems": DomainHeatEntry(count=0, avg_risk=0.0),
1891 "pay": DomainHeatEntry(count=0, avg_risk=0.0),
1892 }
1893
1894 return DomainHeatResponse(domains=domains, total_open=total)
1895
1896
1897 async def get_merge_readiness(
1898 repo_id: str,
1899 session: AsyncSession,
1900 ) -> MergeReadinessResponse:
1901 """Bucket all non-merged proposals into readiness categories.
1902
1903 Categories:
1904 ready: approval_count >= required threshold AND breakage_count == 0
1905 settling: state == 'settling'
1906 needs_review: not settling, conditions not fully met
1907
1908 Dependency-blocked proposals (``blocked_by`` non-empty) are not yet
1909 tracked in the DB at this phase; ``blocked`` will always be empty until
1910 the dependency graph table lands in Phase 2.
1911
1912 Runs in one DB query; no per-row enrichment.
1913
1914 Args:
1915 repo_id: Repository to query.
1916 session: Async session.
1917
1918 Returns:
1919 ``MergeReadinessResponse`` with ``ready``, ``blocked``, ``settling``,
1920 and ``needs_review`` lists of proposal numbers.
1921
1922 Performance target: < 20ms.
1923 """
1924 rows = list(
1925 (
1926 await session.execute(
1927 select(
1928 MusehubProposal.proposal_number,
1929 MusehubProposal.state,
1930 MusehubProposal.breakage_count,
1931 ).where(
1932 MusehubProposal.repo_id == repo_id,
1933 MusehubProposal.state.notin_(["merged", "abandoned"]),
1934 )
1935 )
1936 ).all()
1937 )
1938
1939 # Pre-fetch approval counts in one query
1940 proposal_numbers_to_check = [r.proposal_number for r in rows]
1941 if proposal_numbers_to_check:
1942 approval_counts_rows = list(
1943 (
1944 await session.execute(
1945 select(
1946 MusehubProposal.proposal_number,
1947 func.count(MusehubProposalReview.review_id).label("approved_count"),
1948 )
1949 .join(
1950 MusehubProposalReview,
1951 (MusehubProposalReview.proposal_id == MusehubProposal.proposal_id)
1952 & (MusehubProposalReview.state == "approved"),
1953 isouter=True,
1954 )
1955 .where(
1956 MusehubProposal.repo_id == repo_id,
1957 MusehubProposal.proposal_number.in_(proposal_numbers_to_check),
1958 )
1959 .group_by(MusehubProposal.proposal_number)
1960 )
1961 ).all()
1962 )
1963 approval_by_number: dict[int, int] = {r.proposal_number: r.approved_count for r in approval_counts_rows}
1964 else:
1965 approval_by_number = {}
1966
1967 ready: list[int] = []
1968 blocked: list[int] = []
1969 settling: list[int] = []
1970 needs_review: list[int] = []
1971
1972 for row in rows:
1973 if row.state == "settling":
1974 settling.append(row.proposal_number)
1975 elif (
1976 approval_by_number.get(row.proposal_number, 0) >= _DEFAULT_REQUIRED_APPROVALS
1977 and row.breakage_count == 0
1978 ):
1979 ready.append(row.proposal_number)
1980 else:
1981 needs_review.append(row.proposal_number)
1982
1983 return MergeReadinessResponse(
1984 ready=ready,
1985 blocked=blocked,
1986 settling=settling,
1987 needs_review=needs_review,
1988 )
1989
1990
1991 # ─────────────────────────────────────────────────────────────────────────────
1992 # Phase 4 — Simulation Engine
1993 # ─────────────────────────────────────────────────────────────────────────────
1994
1995 _VALID_SIMULATION_TYPES = {"conflict_scan", "risk_projection", "dependency_order"}
1996
1997
1998 def _to_simulation_response(row: MusehubProposalSimulation, *, is_stale: bool) -> SimulationResponse:
1999 return SimulationResponse(
2000 simulation_id=row.simulation_id,
2001 proposal_id=row.proposal_id,
2002 simulation_type=row.simulation_type,
2003 result=row.result,
2004 is_stale=is_stale,
2005 from_branch_commit_id=row.from_branch_commit_id,
2006 duration_ms=row.duration_ms,
2007 created_at=row.created_at,
2008 expires_at=row.expires_at,
2009 )
2010
2011
2012 async def _current_from_commit(
2013 session: AsyncSession,
2014 repo_id: str,
2015 from_branch: str,
2016 ) -> str:
2017 """Return the current head_commit_id of from_branch, or '' if missing."""
2018 branch = await _get_branch(session, repo_id, from_branch)
2019 return (branch.head_commit_id or "") if branch else ""
2020
2021
2022 async def run_simulation(
2023 session: AsyncSession,
2024 repo_id: str,
2025 proposal_id: str,
2026 simulation_type: str,
2027 ) -> SimulationResponse:
2028 """Run a simulation for the proposal and upsert the cached result.
2029
2030 Always recomputes — use get_simulation to read the cache without re-running.
2031
2032 Raises:
2033 ValueError: Unknown simulation_type or proposal not found.
2034 """
2035 import time
2036
2037 from musehub.services.proposal_simulation import (
2038 simulate_conflict_scan,
2039 simulate_dependency_order,
2040 simulate_risk_projection,
2041 )
2042 from musehub.services.musehub_snapshot import get_snapshot_manifest
2043
2044 if simulation_type not in _VALID_SIMULATION_TYPES:
2045 raise ValueError(
2046 f"Unknown simulation_type '{simulation_type}'. "
2047 f"Valid types: {sorted(_VALID_SIMULATION_TYPES)}"
2048 )
2049
2050 stmt = select(MusehubProposal).where(
2051 MusehubProposal.repo_id == repo_id,
2052 MusehubProposal.proposal_id == proposal_id,
2053 )
2054 proposal = (await session.execute(stmt)).scalar_one_or_none()
2055 if proposal is None:
2056 raise ValueError(f"Proposal {proposal_id} not found in repo {repo_id}")
2057
2058 from_commit_id = await _current_from_commit(session, repo_id, proposal.from_branch)
2059 to_b = await _get_branch(session, repo_id, proposal.to_branch)
2060 from_b = await _get_branch(session, repo_id, proposal.from_branch)
2061
2062 to_manifest: StrDict = {}
2063 if to_b and to_b.head_commit_id:
2064 to_head = await session.get(MusehubCommit, to_b.head_commit_id)
2065 if to_head and to_head.snapshot_id:
2066 to_manifest = await get_snapshot_manifest(session, to_head.snapshot_id)
2067
2068 from_manifest: StrDict = {}
2069 if from_b and from_b.head_commit_id:
2070 from_head = await session.get(MusehubCommit, from_b.head_commit_id)
2071 if from_head and from_head.snapshot_id:
2072 from_manifest = await get_snapshot_manifest(session, from_head.snapshot_id)
2073
2074 strategy_name = getattr(proposal, "merge_strategy", "overlay") or "overlay"
2075 selective_domains: list[str] | None = getattr(proposal, "selective_domains", None)
2076 dimensional_risk: dict[str, float] | None = getattr(proposal, "dimensional_risk", None)
2077
2078 ancestor_manifest: StrDict | None = None
2079 if strategy_name in ("weave", "replay", "selective", "phased"):
2080 ancestor_manifest = await _resolve_ancestor_manifest(
2081 session, repo_id, proposal.from_branch, proposal.to_branch
2082 )
2083
2084 t0 = time.monotonic()
2085 if simulation_type == "conflict_scan":
2086 result_payload = simulate_conflict_scan(
2087 to_manifest,
2088 from_manifest,
2089 ancestor_manifest=ancestor_manifest,
2090 strategy=strategy_name,
2091 selective_domains=selective_domains,
2092 )
2093 elif simulation_type == "risk_projection":
2094 result_payload = simulate_risk_projection(
2095 to_manifest,
2096 from_manifest,
2097 ancestor_manifest=ancestor_manifest,
2098 current_dimensional_risk=dimensional_risk,
2099 strategy=strategy_name,
2100 selective_domains=selective_domains,
2101 )
2102 else: # dependency_order
2103 dag = await load_dag_for_proposals(session, [proposal_id])
2104 result_payload = simulate_dependency_order(dag)
2105
2106 duration_ms = int((time.monotonic() - t0) * 1000)
2107
2108 sim_id = compute_simulation_id(proposal_id, simulation_type, from_commit_id)
2109 now = _utc_now()
2110
2111 # Upsert: update on conflict (same proposal_id + simulation_type)
2112 existing_stmt = select(MusehubProposalSimulation).where(
2113 MusehubProposalSimulation.proposal_id == proposal_id,
2114 MusehubProposalSimulation.simulation_type == simulation_type,
2115 )
2116 existing = (await session.execute(existing_stmt)).scalar_one_or_none()
2117
2118 if existing is not None:
2119 existing.simulation_id = sim_id
2120 existing.from_branch_commit_id = from_commit_id
2121 existing.result = result_payload
2122 existing.duration_ms = duration_ms
2123 existing.created_at = now
2124 row = existing
2125 else:
2126 row = MusehubProposalSimulation(
2127 simulation_id=sim_id,
2128 proposal_id=proposal_id,
2129 simulation_type=simulation_type,
2130 from_branch_commit_id=from_commit_id,
2131 result=result_payload,
2132 duration_ms=duration_ms,
2133 created_at=now,
2134 )
2135 session.add(row)
2136
2137 await session.flush()
2138
2139 logger.info(
2140 "🔬 Simulation %s for proposal %s (%dms)",
2141 simulation_type, proposal_id, duration_ms
2142 )
2143 return _to_simulation_response(row, is_stale=False)
2144
2145
2146 async def get_simulation(
2147 session: AsyncSession,
2148 repo_id: str,
2149 proposal_id: str,
2150 simulation_type: str,
2151 ) -> SimulationResponse | None:
2152 """Return the cached simulation result, or None if never run.
2153
2154 Sets ``is_stale=True`` when the from_branch has advanced since the
2155 simulation was last run. Does NOT re-run; call run_simulation for that.
2156 """
2157 if simulation_type not in _VALID_SIMULATION_TYPES:
2158 raise ValueError(
2159 f"Unknown simulation_type '{simulation_type}'. "
2160 f"Valid types: {sorted(_VALID_SIMULATION_TYPES)}"
2161 )
2162
2163 stmt = select(MusehubProposalSimulation).where(
2164 MusehubProposalSimulation.proposal_id == proposal_id,
2165 MusehubProposalSimulation.simulation_type == simulation_type,
2166 )
2167 row = (await session.execute(stmt)).scalar_one_or_none()
2168 if row is None:
2169 return None
2170
2171 # Staleness check: compare stored commit ID with current branch tip
2172 proposal_stmt = select(MusehubProposal.from_branch).where(
2173 MusehubProposal.proposal_id == proposal_id,
2174 MusehubProposal.repo_id == repo_id,
2175 )
2176 from_branch = (await session.execute(proposal_stmt)).scalar_one_or_none()
2177 is_stale = False
2178 if from_branch:
2179 current_commit = await _current_from_commit(session, repo_id, from_branch)
2180 is_stale = bool(current_commit) and current_commit != row.from_branch_commit_id
2181
2182 return _to_simulation_response(row, is_stale=is_stale)
2183
2184
2185 async def list_simulations(
2186 session: AsyncSession,
2187 repo_id: str,
2188 proposal_id: str,
2189 ) -> SimulationListResponse:
2190 """Return all cached simulations for a proposal.
2191
2192 Includes staleness flags. Never re-runs.
2193 """
2194 # Resolve from_branch once for staleness checks
2195 proposal_stmt = select(MusehubProposal.from_branch).where(
2196 MusehubProposal.proposal_id == proposal_id,
2197 MusehubProposal.repo_id == repo_id,
2198 )
2199 from_branch = (await session.execute(proposal_stmt)).scalar_one_or_none()
2200 current_commit = ""
2201 if from_branch:
2202 current_commit = await _current_from_commit(session, repo_id, from_branch)
2203
2204 rows_stmt = select(MusehubProposalSimulation).where(
2205 MusehubProposalSimulation.proposal_id == proposal_id,
2206 )
2207 rows = list((await session.execute(rows_stmt)).scalars().all())
2208
2209 responses = [
2210 _to_simulation_response(
2211 r,
2212 is_stale=bool(current_commit) and current_commit != r.from_branch_commit_id,
2213 )
2214 for r in rows
2215 ]
2216 return SimulationListResponse(simulations=responses, total=len(responses))
File History 11 commits
sha256:400438cf8bc700a611f1ba798aa9def68290f487dc19f7dbf317985ad17050c9 chore: delete muse/prose domain — hallucinated, never existed Sonnet 4.6 minor 37 days ago
sha256:4d42a346263e7cbbd152c147f3e6f24576f4b4440df9249ffb9fbcf9db699fcb feat: populate url in create_issue and create_proposal responses Sonnet 4.6 minor 37 days ago
sha256:3707eba7ad42cadedf18c8b9c534d839b88cfd1c30924c3c5a3edc74e1d809de feat: add url field to mist, issue, and proposal list/read … Sonnet 4.6 minor 37 days ago
sha256:d110dd71fb7c1f5e064162de1262b2976841a00d7549bc4f441045f5c13ef33f feat: add MergeResultEmbed to ProposalResponse (deliverable 5) Sonnet 4.6 minor 38 days ago
sha256:50b52eda7afb2f122863aef47d684d1a9e4684b48f5f95367fc956e28ceb7d42 refactor: rename merge strategy aliases to canonical names Sonnet 4.6 minor 43 days ago
sha256:af9422a68cbd2db7c88f664388e11134b0ae0057ee5ad14465d82208548a9d7d changing --event to --verdict. displaying changes requested… Human minor 45 days ago
sha256:a909058d727faac4d77f6e659cc0b1f9315efcb6aabfd870d08763525a67093d dialing in --strategy and --history on merge proposal Human minor 45 days ago
sha256:1f95928652d220172ddd56fb71bdd7bee3158e1b86f1b8ba8b2470edde498c6a update proposal detail w/ --history and --strategy flags Human minor 46 days ago
sha256:1b1a47509ee8154b9c61b68e8e871f6f6dd4d48125c5bcc1df61733bd3657c42 insert `MusehubCommitGraph` row when `merge_proposal` creat… Human patch 46 days ago
sha256:f3995ec2c05c9c34b0e4d6e96349a811d0117a1c51d78096d757998ccb3c0520 fix: blobs only in S3/mpack — remove commit/snapshot indivi… Sonnet 4.6 patch 48 days ago
sha256:e597c0b97ade9c3c52ac4735ceb437ee69d1b6f0db61b8d7caa6467c5866566d feat(phase2): write commit objects to S3 at all 5 write sit… Sonnet 4.6 patch 51 days ago