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