gabriel / musehub public
musehub_proposals.py python
2,256 lines 84.9 KB
Raw
sha256:52ab8032b6bf2313b627a3d858051072dfd3f21f5f2d79bee113928f12510f99 feat(#143): wire is_deleted end-to-end for proposal comments Sonnet 5 minor ⚠ breaking 10 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 include_deleted: bool = False,
1198 ) -> ProposalCommentListResponse:
1199 """Return review comments for a proposal with cursor-based keyset pagination.
1200
1201 Comments are assembled into a two-level thread tree from the current page.
1202 Top-level comments (``parent_comment_id`` is None) form the root list.
1203 Each carries a ``replies`` list with direct children that appear within
1204 the same page, sorted by ``created_at`` ascending. Grandchildren are not
1205 supported — callers should reply to the original top-level comment.
1206
1207 ``include_deleted`` surfaces soft-deleted comments (``is_deleted=True``)
1208 alongside live ones — callers must independently verify the caller has
1209 write access before setting this, matching the audit-log intent of
1210 soft-delete rather than genuine removal.
1211
1212 ``cursor`` is the ISO 8601 ``created_at`` of the last seen comment
1213 (opaque to callers — pass ``nextCursor`` from a previous response
1214 verbatim). Omit to start from the beginning. ``total`` covers all
1215 comments on the proposal regardless of the current page.
1216 """
1217 conditions = [
1218 MusehubProposalComment.proposal_id == proposal_id,
1219 MusehubProposalComment.repo_id == repo_id,
1220 ]
1221 if not include_deleted:
1222 conditions.append(MusehubProposalComment.is_deleted.is_(False))
1223
1224 count_stmt = select(func.count(MusehubProposalComment.comment_id)).where(*conditions)
1225 total: int = (await session.execute(count_stmt)).scalar_one()
1226
1227 data_conditions = list(conditions)
1228 if cursor is not None:
1229 data_conditions.append(
1230 MusehubProposalComment.created_at > datetime.fromisoformat(cursor)
1231 )
1232
1233 rows = list(
1234 (
1235 await session.execute(
1236 select(MusehubProposalComment)
1237 .where(*data_conditions)
1238 .order_by(MusehubProposalComment.created_at)
1239 .limit(limit + 1)
1240 )
1241 ).scalars()
1242 )
1243
1244 next_cursor: str | None = None
1245 if len(rows) == limit + 1:
1246 next_cursor = rows[limit - 1].created_at.isoformat()
1247 rows = rows[:limit]
1248
1249 # Build id → response map first; attach replies in a second pass.
1250 top_level: list[ProposalCommentResponse] = []
1251 by_id: _CommentMap = {}
1252 for row in rows:
1253 resp = _to_comment_response(row)
1254 by_id[row.comment_id] = resp
1255 if row.parent_comment_id is None:
1256 top_level.append(resp)
1257
1258 for row in rows:
1259 if row.parent_comment_id is not None:
1260 parent = by_id.get(row.parent_comment_id)
1261 if parent is not None:
1262 parent.replies.append(by_id[row.comment_id])
1263
1264 return ProposalCommentListResponse(comments=top_level, total=total, next_cursor=next_cursor)
1265
1266
1267 async def delete_comment(
1268 session: AsyncSession,
1269 comment_id: str,
1270 proposal_id: str,
1271 *,
1272 hard: bool = False,
1273 ) -> bool:
1274 """Delete a proposal comment. Returns True if the comment existed and was deleted.
1275
1276 Soft-delete (default) sets ``is_deleted`` -- mirrors
1277 ``musehub_issues.delete_comment``, the reference implementation for this
1278 idiom (musehub#141/#143). ``hard=True`` actually removes the row; callers
1279 must independently verify the caller has admin-level access before
1280 setting this, per #141's framework-wide "--hard gated to owner/admin"
1281 convention.
1282 """
1283 stmt = select(MusehubProposalComment).where(
1284 MusehubProposalComment.comment_id == comment_id,
1285 MusehubProposalComment.proposal_id == proposal_id,
1286 )
1287 row = (await session.execute(stmt)).scalar_one_or_none()
1288 if row is None:
1289 return False
1290 if hard:
1291 await session.delete(row)
1292 logger.info("✅ Hard-deleted proposal comment %s", comment_id)
1293 else:
1294 row.is_deleted = True
1295 await session.flush()
1296 logger.info("✅ Soft-deleted proposal comment %s", comment_id)
1297 return True
1298
1299
1300 # ---------------------------------------------------------------------------
1301 # Proposal reviews (reviewer assignment + approval workflow)
1302 # ---------------------------------------------------------------------------
1303
1304
1305 def _to_review_response(row: MusehubProposalReview) -> ProposalReviewResponse:
1306 return ProposalReviewResponse(
1307 id=row.review_id,
1308 proposal_id=row.proposal_id,
1309 reviewer_username=row.reviewer_username,
1310 state=row.state,
1311 body=row.body,
1312 submitted_at=row.submitted_at,
1313 created_at=row.created_at,
1314 )
1315
1316
1317 async def _assert_proposal_exists(session: AsyncSession, repo_id: str, proposal_id: str) -> None:
1318 """Raise ``ValueError`` if the proposal does not exist in the given repo."""
1319 stmt = select(MusehubProposal).where(
1320 MusehubProposal.proposal_id == proposal_id,
1321 MusehubProposal.repo_id == repo_id,
1322 )
1323 proposal = (await session.execute(stmt)).scalar_one_or_none()
1324 if proposal is None:
1325 raise ValueError(f"Proposal {proposal_id} not found in repo {repo_id}")
1326
1327
1328 async def request_reviewers(
1329 session: AsyncSession,
1330 *,
1331 repo_id: str,
1332 proposal_id: str,
1333 reviewers: list[str],
1334 ) -> ProposalReviewListResponse:
1335 """Add reviewer assignments to a proposal, creating a ``pending`` row for each.
1336
1337 Idempotent: if a reviewer already has a row (in any state), the existing row
1338 is left unchanged so a submitted approval is never reset by a re-request.
1339
1340 Raises ``ValueError`` if the proposal does not exist in the repo.
1341
1342 Returns the full updated review list for the proposal.
1343 """
1344 await _assert_proposal_exists(session, repo_id, proposal_id)
1345
1346 for username in reviewers:
1347 existing_stmt = select(MusehubProposalReview).where(
1348 MusehubProposalReview.proposal_id == proposal_id,
1349 MusehubProposalReview.reviewer_username == username,
1350 )
1351 existing = (await session.execute(existing_stmt)).scalar_one_or_none()
1352 if existing is None:
1353 now = _utc_now()
1354 identity_stmt = select(MusehubIdentity.identity_id).where(
1355 MusehubIdentity.handle == username
1356 )
1357 reviewer_identity_id = (await session.execute(identity_stmt)).scalar_one_or_none() or username
1358 review = MusehubProposalReview(
1359 review_id=compute_review_id(proposal_id, reviewer_identity_id, now.isoformat()),
1360 proposal_id=proposal_id,
1361 reviewer_username=username,
1362 state="pending",
1363 created_at=now,
1364 )
1365 session.add(review)
1366 logger.info("✅ Requested review from '%s' on proposal %s", username, proposal_id)
1367
1368 await session.flush()
1369 return await list_reviews(session, repo_id=repo_id, proposal_id=proposal_id)
1370
1371
1372 async def remove_reviewer(
1373 session: AsyncSession,
1374 *,
1375 repo_id: str,
1376 proposal_id: str,
1377 username: str,
1378 ) -> ProposalReviewListResponse:
1379 """Remove a pending review request for ``username`` on a proposal.
1380
1381 Only ``pending`` rows may be removed — submitted reviews are immutable to
1382 preserve the audit trail.
1383
1384 Raises ``ValueError`` if the proposal does not exist, the reviewer was never
1385 requested, or the reviewer has already submitted a non-pending review.
1386
1387 Returns the updated review list.
1388 """
1389 await _assert_proposal_exists(session, repo_id, proposal_id)
1390
1391 stmt = select(MusehubProposalReview).where(
1392 MusehubProposalReview.proposal_id == proposal_id,
1393 MusehubProposalReview.reviewer_username == username,
1394 )
1395 row = (await session.execute(stmt)).scalar_one_or_none()
1396 if row is None:
1397 raise ValueError(f"Reviewer '{username}' was not requested on proposal {proposal_id}")
1398 if row.state != "pending":
1399 raise ValueError(
1400 f"Cannot remove reviewer '{username}': review already submitted (state={row.state})"
1401 )
1402
1403 await session.delete(row)
1404 await session.flush()
1405 logger.info("✅ Removed review request for '%s' from proposal %s", username, proposal_id)
1406 return await list_reviews(session, repo_id=repo_id, proposal_id=proposal_id)
1407
1408
1409 async def list_reviews(
1410 session: AsyncSession,
1411 *,
1412 repo_id: str,
1413 proposal_id: str,
1414 state: str | None = None,
1415 cursor: str | None = None,
1416 limit: int = 20,
1417 ) -> ProposalReviewListResponse:
1418 """Return reviews for a proposal with cursor-based keyset pagination.
1419
1420 ``state`` may be one of ``pending``, ``approved``, ``changes_requested``,
1421 or ``dismissed``. When ``None``, all reviews are returned.
1422 Results are ordered by ``created_at`` ascending.
1423
1424 ``cursor`` is the ISO 8601 ``created_at`` of the last seen review
1425 (opaque to callers — pass ``nextCursor`` from a previous response
1426 verbatim). Omit to start from the beginning.
1427
1428 Raises ``ValueError`` if the proposal does not exist in the repo.
1429 """
1430 await _assert_proposal_exists(session, repo_id, proposal_id)
1431
1432 conditions = [MusehubProposalReview.proposal_id == proposal_id]
1433 if state is not None:
1434 conditions.append(MusehubProposalReview.state == state)
1435
1436 count_stmt = select(func.count(MusehubProposalReview.review_id)).where(*conditions)
1437 total: int = (await session.execute(count_stmt)).scalar_one()
1438
1439 data_conditions = list(conditions)
1440 if cursor is not None:
1441 data_conditions.append(
1442 MusehubProposalReview.created_at > datetime.fromisoformat(cursor)
1443 )
1444
1445 rows = list(
1446 (
1447 await session.execute(
1448 select(MusehubProposalReview)
1449 .where(*data_conditions)
1450 .order_by(MusehubProposalReview.created_at)
1451 .limit(limit + 1)
1452 )
1453 ).scalars()
1454 )
1455
1456 next_cursor: str | None = None
1457 if len(rows) == limit + 1:
1458 next_cursor = rows[limit - 1].created_at.isoformat()
1459 rows = rows[:limit]
1460
1461 return ProposalReviewListResponse(
1462 reviews=[_to_review_response(r) for r in rows],
1463 total=total,
1464 next_cursor=next_cursor,
1465 )
1466
1467
1468 async def submit_review(
1469 session: AsyncSession,
1470 *,
1471 repo_id: str,
1472 proposal_id: str,
1473 reviewer_username: str,
1474 reviewer_identity_id: str = "",
1475 verdict: str,
1476 body: str = "",
1477 ) -> ProposalReviewResponse:
1478 """Submit or update a formal review verdict for ``reviewer_username`` on a proposal.
1479
1480 ``verdict`` maps to a new state:
1481 - ``approve`` → ``approved``
1482 - ``request_changes`` → ``changes_requested``
1483
1484 If an existing row for this reviewer already exists, it is updated in-place,
1485 so changing from approve → request_changes (or vice versa) works by resubmitting.
1486 Ad-hoc reviews (no prior reviewer request) are also allowed.
1487
1488 Raises ``ValueError`` if the proposal does not exist in the repo.
1489 """
1490 await _assert_proposal_exists(session, repo_id, proposal_id)
1491
1492 _VERDICT_TO_STATE: StrDict = {
1493 "approve": "approved",
1494 "request_changes": "changes_requested",
1495 }
1496 if verdict not in _VERDICT_TO_STATE:
1497 raise ValueError(f"Invalid verdict '{verdict}'. Must be approve or request_changes.")
1498 new_state = _VERDICT_TO_STATE[verdict]
1499
1500 stmt = select(MusehubProposalReview).where(
1501 MusehubProposalReview.proposal_id == proposal_id,
1502 MusehubProposalReview.reviewer_username == reviewer_username,
1503 )
1504 row = (await session.execute(stmt)).scalar_one_or_none()
1505
1506 now = _utc_now()
1507 if row is None:
1508 row = MusehubProposalReview(
1509 review_id=compute_review_id(proposal_id, reviewer_identity_id, now.isoformat()),
1510 proposal_id=proposal_id,
1511 reviewer_username=reviewer_username,
1512 state=new_state,
1513 body=body or None,
1514 submitted_at=now,
1515 created_at=now,
1516 )
1517 session.add(row)
1518 else:
1519 row.state = new_state
1520 row.body = body or None
1521 row.submitted_at = now
1522
1523 await session.flush()
1524 await session.refresh(row)
1525 logger.info(
1526 "✅ Review submitted by '%s' on proposal %s: verdict=%s state=%s",
1527 reviewer_username,
1528 proposal_id,
1529 verdict,
1530 new_state,
1531 )
1532 return _to_review_response(row)
1533
1534 # ── Proposal list enrichment ──────────────────────────────────────────────────
1535
1536 _RISK_BAND_THRESHOLDS: list[tuple[float, str]] = [
1537 (0.75, "critical"),
1538 (0.50, "high"),
1539 (0.25, "medium"),
1540 (0.01, "low"),
1541 ]
1542
1543
1544 def _score_to_band(score: float) -> str:
1545 """Map a [0.0, 1.0] risk score to a human-readable band label.
1546
1547 Thresholds:
1548 ≥ 0.75 → "critical"
1549 ≥ 0.50 → "high"
1550 ≥ 0.25 → "medium"
1551 > 0.0 → "low"
1552 0.0 → "none"
1553 """
1554 for threshold, band in _RISK_BAND_THRESHOLDS:
1555 if score >= threshold:
1556 return band
1557 return "none"
1558
1559
1560 # Default required-approvals when merge_conditions is null.
1561 _DEFAULT_REQUIRED_APPROVALS = 2
1562
1563 # Domain weight map used for aggregate risk score. Unknown domains default to 1.0.
1564 _DOMAIN_WEIGHTS: dict[str, float] = {
1565 "code": 1.2,
1566 "midi": 1.0,
1567 "stems": 1.0,
1568 "pay": 1.5,
1569 }
1570
1571
1572 class _ProposalPrefetch:
1573 """Holds pre-fetched batch data for a page of proposals.
1574
1575 All DB reads for an entire page happen once in
1576 ``enrich_proposal_list_batch``; each ``enrich_proposal_list_entry`` call
1577 consults these in-memory maps — zero additional DB I/O per row.
1578 """
1579
1580 def __init__(
1581 self,
1582 *,
1583 reviews_by_proposal: dict[str, list[MusehubProposalReview]],
1584 author_types: dict[str, str],
1585 dag: ProposalDag | None = None,
1586 conflict_counts: dict[str, int | None] | None = None,
1587 ) -> None:
1588 self.reviews_by_proposal = reviews_by_proposal
1589 self.author_types = author_types
1590 self.dag: ProposalDag = dag or ProposalDag()
1591 # proposal_id → conflict_scan.result["conflict_count"]; None if not run
1592 self.conflict_counts: dict[str, int | None] = conflict_counts or {}
1593
1594
1595 async def _prefetch_for_batch(
1596 proposals: list[MusehubProposal],
1597 session: AsyncSession,
1598 ) -> _ProposalPrefetch:
1599 """Run the batch pre-fetch queries for a page of proposals.
1600
1601 Issues exactly two DB queries regardless of page size:
1602 1. All reviews for every proposal in the page.
1603 2. Identity types for every author in the page.
1604
1605 Args:
1606 proposals: ORM rows for the current page.
1607 session: Shared async session.
1608
1609 Returns:
1610 ``_ProposalPrefetch`` with maps keyed by proposal_id / author handle.
1611 """
1612 proposal_ids = [p.proposal_id for p in proposals]
1613 author_handles = list({p.author for p in proposals if p.author})
1614
1615 # Query 1 — reviews
1616 reviews_by_proposal: dict[str, list[MusehubProposalReview]] = {pid: [] for pid in proposal_ids}
1617 if proposal_ids:
1618 review_rows = list(
1619 (
1620 await session.execute(
1621 select(MusehubProposalReview).where(
1622 MusehubProposalReview.proposal_id.in_(proposal_ids)
1623 )
1624 )
1625 ).scalars()
1626 )
1627 for row in review_rows:
1628 reviews_by_proposal[row.proposal_id].append(row)
1629
1630 # Query 2 — identity types
1631 author_types: dict[str, str] = {}
1632 if author_handles:
1633 identity_rows = list(
1634 (
1635 await session.execute(
1636 select(MusehubIdentity.handle, MusehubIdentity.identity_type).where(
1637 MusehubIdentity.handle.in_(author_handles)
1638 )
1639 )
1640 ).all()
1641 )
1642 for handle, itype in identity_rows:
1643 author_types[handle] = itype
1644
1645 # Query 3 — dependency DAG (partial, scoped to this page + neighbours)
1646 dag = await load_dag_for_proposals(session, proposal_ids)
1647
1648 # Query 4 — latest conflict_scan simulation per proposal (for list summary)
1649 conflict_counts: dict[str, int | None] = {pid: None for pid in proposal_ids}
1650 if proposal_ids:
1651 sim_rows = list(
1652 (
1653 await session.execute(
1654 select(
1655 MusehubProposalSimulation.proposal_id,
1656 MusehubProposalSimulation.result,
1657 ).where(
1658 MusehubProposalSimulation.proposal_id.in_(proposal_ids),
1659 MusehubProposalSimulation.simulation_type == "conflict_scan",
1660 )
1661 )
1662 ).all()
1663 )
1664 for pid, result_json in sim_rows:
1665 if isinstance(result_json, dict):
1666 conflict_counts[pid] = result_json.get("conflict_count")
1667
1668 return _ProposalPrefetch(
1669 reviews_by_proposal=reviews_by_proposal,
1670 author_types=author_types,
1671 dag=dag,
1672 conflict_counts=conflict_counts,
1673 )
1674
1675
1676 def _enrich_one(
1677 proposal: MusehubProposal,
1678 prefetch: _ProposalPrefetch,
1679 ) -> ProposalListEntry:
1680 """Compute all display-facing fields for a single proposal list row.
1681
1682 This is the single source of truth for what the proposals list view renders
1683 per row. It does not issue any DB queries — all needed data comes from
1684 ``prefetch``, which is populated by ``_prefetch_for_batch`` before this
1685 function is called.
1686
1687 Computed fields (all server-side):
1688 - active_domains: domains with non-zero risk_score
1689 - domain_risk / domain_risk_band: derived from proposal.risk_score
1690 (currently a single code-domain score; extended as more domains land)
1691 - aggregate_risk_score: weighted mean across active domains
1692 - aggregate_risk_band: band for the aggregate score
1693 - approval_count / domains_approved / domains_pending_review:
1694 derived from pre-fetched reviews
1695 - all_merge_conditions_met: approval_count >= required_approvals
1696 and breakage_count == 0
1697 - author_type: resolved from pre-fetched MusehubIdentity rows
1698
1699 Performance contract:
1700 Zero DB I/O. All reads come from the ``prefetch`` maps. Typical
1701 wall time: < 1ms per row on warm prefetch data.
1702
1703 Args:
1704 proposal: ORM row for this proposal.
1705 prefetch: Pre-fetched batch data from ``_prefetch_for_batch``.
1706
1707 Returns:
1708 ``ProposalListEntry`` with all fields populated.
1709
1710 Raises:
1711 ValueError: If ``proposal.risk_score`` is outside ``[0.0, 1.0]``.
1712 """
1713 pid = proposal.proposal_id
1714
1715 # ── Risk ─────────────────────────────────────────────────────────────────
1716 # Use dimensional_risk dict (Phase 1 ORM columns) when present; fall back
1717 # to the scalar risk_score as code-domain risk for backwards compatibility.
1718 raw_dimensional = dict(getattr(proposal, "dimensional_risk", None) or {})
1719 if raw_dimensional:
1720 domain_risk = {d: float(v) for d, v in raw_dimensional.items() if float(v) > 0.0}
1721 else:
1722 code_risk = float(proposal.risk_score or 0.0)
1723 if not (0.0 <= code_risk <= 1.0):
1724 raise ValueError(f"proposal {pid}: risk_score {code_risk!r} out of [0, 1]")
1725 domain_risk = {"code": code_risk} if code_risk > 0.0 else {}
1726 active_domains = list(domain_risk.keys())
1727 domain_risk_band = {d: _score_to_band(v) for d, v in domain_risk.items()}
1728
1729 # Weighted aggregate
1730 if domain_risk:
1731 total_weight = sum(_DOMAIN_WEIGHTS.get(d, 1.0) for d in domain_risk)
1732 aggregate_risk_score = sum(
1733 v * _DOMAIN_WEIGHTS.get(d, 1.0) for d, v in domain_risk.items()
1734 ) / total_weight
1735 else:
1736 aggregate_risk_score = 0.0
1737 aggregate_risk_band = _score_to_band(aggregate_risk_score)
1738
1739 # ── Dependency position ───────────────────────────────────────────────────
1740 dag = prefetch.dag
1741 dep_blocked_by = blocked_by_numbers(dag, pid)
1742 dep_blocks = blocks_numbers(dag, pid)
1743 dep_is_blocked = is_blocked(dag, pid)
1744 mc_raw = proposal.merge_conditions or {}
1745 require_dep_merged: bool = mc_raw.get("require_dependency_merged", True)
1746 deps_satisfied = (not require_dep_merged) or (not dep_is_blocked)
1747
1748 # ── Reviews ───────────────────────────────────────────────────────────────
1749 reviews = prefetch.reviews_by_proposal.get(pid, [])
1750 approved_reviews = [r for r in reviews if r.state == "approved"]
1751 approval_count = len(approved_reviews)
1752 required_approvals = _DEFAULT_REQUIRED_APPROVALS
1753 domains_approved = ["code"] if approval_count > 0 and "code" in active_domains else []
1754 domains_pending_review = [d for d in active_domains if d not in domains_approved]
1755 all_merge_conditions_met = (
1756 approval_count >= required_approvals
1757 and proposal.breakage_count == 0
1758 and deps_satisfied
1759 )
1760
1761 # ── Author type ───────────────────────────────────────────────────────────
1762 author_type = prefetch.author_types.get(proposal.author, "human")
1763 agent_model: str | None = getattr(proposal, "agent_model", None)
1764 agent_spawned_by: str | None = getattr(proposal, "agent_spawned_by", None)
1765
1766 # ── Symbol preview ────────────────────────────────────────────────────────
1767 touched = list(proposal.touched_symbols or [])
1768 touched_symbols_preview = touched[:3]
1769
1770 return ProposalListEntry(
1771 proposal_id=proposal.proposal_id,
1772 proposal_number=proposal.proposal_number,
1773 title=(proposal.title[:80] + "…") if len(proposal.title) > 80 else proposal.title,
1774 state=proposal.state,
1775 proposal_type=getattr(proposal, "proposal_type", "state_merge"),
1776 from_branch=proposal.from_branch,
1777 to_branch=proposal.to_branch,
1778 author=proposal.author,
1779 author_type=author_type,
1780 created_at=proposal.created_at,
1781 merged_at=proposal.merged_at,
1782 is_draft=getattr(proposal, "is_draft", proposal.state == "drafting"),
1783 active_domains=active_domains,
1784 domain_risk=domain_risk,
1785 domain_risk_band=domain_risk_band,
1786 aggregate_risk_score=round(aggregate_risk_score, 4),
1787 aggregate_risk_band=aggregate_risk_band,
1788 approval_count=approval_count,
1789 required_approvals=required_approvals,
1790 domains_approved=domains_approved,
1791 domains_pending_review=domains_pending_review,
1792 all_merge_conditions_met=all_merge_conditions_met,
1793 blocked_by=dep_blocked_by,
1794 blocks=dep_blocks,
1795 is_blocked=dep_is_blocked,
1796 symbols_changed=proposal.symbols_changed,
1797 breakage_count=proposal.breakage_count,
1798 test_gap_count=proposal.test_gap_count,
1799 touched_symbols_preview=touched_symbols_preview,
1800 midi_tracks_changed=getattr(proposal, "midi_tracks_changed", 0),
1801 midi_notes_delta=getattr(proposal, "midi_notes_delta", 0),
1802 harmonic_tension_delta=getattr(proposal, "harmonic_tension_delta", None),
1803 payment_claim_count=getattr(proposal, "payment_claim_count", 0),
1804 payment_ledger_delta_nano=getattr(proposal, "payment_ledger_delta_nano", 0),
1805 payment_avax_address=getattr(proposal, "payment_avax_address", None),
1806 payment_settling=proposal.state == "settling" and "pay" in active_domains,
1807 agent_model=agent_model,
1808 agent_spawned_by=agent_spawned_by,
1809 merge_strategy=getattr(proposal, "merge_strategy", "overlay") or "overlay",
1810 simulation_conflict_count=prefetch.conflict_counts.get(pid),
1811 )
1812
1813
1814 async def enrich_proposal_list_entry(
1815 proposal: MusehubProposal,
1816 session: AsyncSession,
1817 ) -> ProposalListEntry:
1818 """Compute all display-facing fields for a single proposal list row.
1819
1820 Convenience wrapper around ``_enrich_one`` for callers that need to enrich
1821 a single proposal without a pre-existing batch context. Issues its own
1822 prefetch queries (2 DB round-trips).
1823
1824 For a full page (≥2 proposals) prefer ``enrich_proposal_list_batch`` which
1825 amortises the prefetch cost across all rows via a single parallel pass.
1826
1827 Args:
1828 proposal: ORM row for this proposal. Must have ``repo_id`` set.
1829 session: Async database session.
1830
1831 Returns:
1832 ``ProposalListEntry`` with all fields populated.
1833
1834 Raises:
1835 ValueError: If ``proposal.risk_score`` is outside ``[0.0, 1.0]``.
1836 """
1837 prefetch = await _prefetch_for_batch([proposal], session)
1838 return _enrich_one(proposal, prefetch)
1839
1840
1841 async def enrich_proposal_list_batch(
1842 proposals: list[MusehubProposal],
1843 session: AsyncSession,
1844 ) -> list[ProposalListEntry]:
1845 """Enrich a full page of proposal rows in a single parallel pass.
1846
1847 Pre-fetch strategy:
1848 Issues exactly 2 DB queries for the entire batch (reviews + identity
1849 types), then calls ``_enrich_one`` for each row synchronously.
1850 The synchronous per-row work is CPU-only (no I/O), so no concurrency
1851 overhead is needed.
1852
1853 Ordering:
1854 Result list is in the same order as ``proposals``.
1855
1856 Args:
1857 proposals: ORM rows for the current page (typically ≤ 20).
1858 session: Async session shared across the batch.
1859
1860 Returns:
1861 List of ``ProposalListEntry`` in the same order as ``proposals``.
1862
1863 Performance target: < 50ms for 20 proposals (dominated by the 2 DB
1864 queries; per-row computation is < 0.1ms).
1865 """
1866 if not proposals:
1867 return []
1868 prefetch = await _prefetch_for_batch(proposals, session)
1869 return [_enrich_one(p, prefetch) for p in proposals]
1870
1871
1872 async def get_domain_heat(
1873 repo_id: str,
1874 state: str,
1875 session: AsyncSession,
1876 ) -> DomainHeatResponse:
1877 """Return per-domain proposal counts and average risk for the heat bar.
1878
1879 Runs a single aggregation query against ``musehub_proposals`` filtered by
1880 ``repo_id`` and ``state``. The heat bar currently reflects the code domain
1881 only (the only domain with populated risk scores at this phase); additional
1882 domains are added as multi-domain risk rows land in Phase 2.
1883
1884 ``avg_risk`` is the arithmetic mean of non-zero ``risk_score`` values for
1885 proposals in the given state. Domains with zero matching proposals are
1886 omitted from the response dict.
1887
1888 Args:
1889 repo_id: Repository to query.
1890 state: Proposal state filter (e.g. ``"open"``). Pass ``"open"`` for
1891 the standard heat bar view. Pass ``"all"`` to skip the state
1892 filter entirely.
1893 session: Async session.
1894
1895 Returns:
1896 ``DomainHeatResponse`` with ``domains`` dict and ``total_open`` count.
1897
1898 Performance target: < 20ms (single aggregation query, no per-row work).
1899 """
1900 conditions = [MusehubProposal.repo_id == repo_id]
1901 if state != "all":
1902 conditions.append(MusehubProposal.state == state)
1903
1904 total: int = (
1905 await session.execute(
1906 select(func.count(MusehubProposal.proposal_id)).where(*conditions)
1907 )
1908 ).scalar_one()
1909
1910 # Code domain: all proposals in this repo/state (code is the only domain for now).
1911 # avg_risk computed from non-null, non-zero risk_score values only.
1912 risk_rows = list(
1913 (
1914 await session.execute(
1915 select(MusehubProposal.risk_score).where(
1916 *conditions,
1917 MusehubProposal.risk_score.isnot(None),
1918 MusehubProposal.risk_score > 0.0,
1919 )
1920 )
1921 ).scalars()
1922 )
1923 avg_risk = round(sum(risk_rows) / len(risk_rows), 4) if risk_rows else 0.0
1924
1925 # All known domains — code count = total (single-domain repos); others = 0.
1926 # Multi-domain heat will populate midi/stems/pay when those proposals land.
1927 domains: dict[str, DomainHeatEntry] = {
1928 "code": DomainHeatEntry(count=total, avg_risk=avg_risk),
1929 "midi": DomainHeatEntry(count=0, avg_risk=0.0),
1930 "stems": DomainHeatEntry(count=0, avg_risk=0.0),
1931 "pay": DomainHeatEntry(count=0, avg_risk=0.0),
1932 }
1933
1934 return DomainHeatResponse(domains=domains, total_open=total)
1935
1936
1937 async def get_merge_readiness(
1938 repo_id: str,
1939 session: AsyncSession,
1940 ) -> MergeReadinessResponse:
1941 """Bucket all non-merged proposals into readiness categories.
1942
1943 Categories:
1944 ready: approval_count >= required threshold AND breakage_count == 0
1945 settling: state == 'settling'
1946 needs_review: not settling, conditions not fully met
1947
1948 Dependency-blocked proposals (``blocked_by`` non-empty) are not yet
1949 tracked in the DB at this phase; ``blocked`` will always be empty until
1950 the dependency graph table lands in Phase 2.
1951
1952 Runs in one DB query; no per-row enrichment.
1953
1954 Args:
1955 repo_id: Repository to query.
1956 session: Async session.
1957
1958 Returns:
1959 ``MergeReadinessResponse`` with ``ready``, ``blocked``, ``settling``,
1960 and ``needs_review`` lists of proposal numbers.
1961
1962 Performance target: < 20ms.
1963 """
1964 rows = list(
1965 (
1966 await session.execute(
1967 select(
1968 MusehubProposal.proposal_number,
1969 MusehubProposal.state,
1970 MusehubProposal.breakage_count,
1971 ).where(
1972 MusehubProposal.repo_id == repo_id,
1973 MusehubProposal.state.notin_(["merged", "abandoned"]),
1974 )
1975 )
1976 ).all()
1977 )
1978
1979 # Pre-fetch approval counts in one query
1980 proposal_numbers_to_check = [r.proposal_number for r in rows]
1981 if proposal_numbers_to_check:
1982 approval_counts_rows = list(
1983 (
1984 await session.execute(
1985 select(
1986 MusehubProposal.proposal_number,
1987 func.count(MusehubProposalReview.review_id).label("approved_count"),
1988 )
1989 .join(
1990 MusehubProposalReview,
1991 (MusehubProposalReview.proposal_id == MusehubProposal.proposal_id)
1992 & (MusehubProposalReview.state == "approved"),
1993 isouter=True,
1994 )
1995 .where(
1996 MusehubProposal.repo_id == repo_id,
1997 MusehubProposal.proposal_number.in_(proposal_numbers_to_check),
1998 )
1999 .group_by(MusehubProposal.proposal_number)
2000 )
2001 ).all()
2002 )
2003 approval_by_number: dict[int, int] = {r.proposal_number: r.approved_count for r in approval_counts_rows}
2004 else:
2005 approval_by_number = {}
2006
2007 ready: list[int] = []
2008 blocked: list[int] = []
2009 settling: list[int] = []
2010 needs_review: list[int] = []
2011
2012 for row in rows:
2013 if row.state == "settling":
2014 settling.append(row.proposal_number)
2015 elif (
2016 approval_by_number.get(row.proposal_number, 0) >= _DEFAULT_REQUIRED_APPROVALS
2017 and row.breakage_count == 0
2018 ):
2019 ready.append(row.proposal_number)
2020 else:
2021 needs_review.append(row.proposal_number)
2022
2023 return MergeReadinessResponse(
2024 ready=ready,
2025 blocked=blocked,
2026 settling=settling,
2027 needs_review=needs_review,
2028 )
2029
2030
2031 # ─────────────────────────────────────────────────────────────────────────────
2032 # Phase 4 — Simulation Engine
2033 # ─────────────────────────────────────────────────────────────────────────────
2034
2035 _VALID_SIMULATION_TYPES = {"conflict_scan", "risk_projection", "dependency_order"}
2036
2037
2038 def _to_simulation_response(row: MusehubProposalSimulation, *, is_stale: bool) -> SimulationResponse:
2039 return SimulationResponse(
2040 simulation_id=row.simulation_id,
2041 proposal_id=row.proposal_id,
2042 simulation_type=row.simulation_type,
2043 result=row.result,
2044 is_stale=is_stale,
2045 from_branch_commit_id=row.from_branch_commit_id,
2046 duration_ms=row.duration_ms,
2047 created_at=row.created_at,
2048 expires_at=row.expires_at,
2049 )
2050
2051
2052 async def _current_from_commit(
2053 session: AsyncSession,
2054 repo_id: str,
2055 from_branch: str,
2056 ) -> str:
2057 """Return the current head_commit_id of from_branch, or '' if missing."""
2058 branch = await _get_branch(session, repo_id, from_branch)
2059 return (branch.head_commit_id or "") if branch else ""
2060
2061
2062 async def run_simulation(
2063 session: AsyncSession,
2064 repo_id: str,
2065 proposal_id: str,
2066 simulation_type: str,
2067 ) -> SimulationResponse:
2068 """Run a simulation for the proposal and upsert the cached result.
2069
2070 Always recomputes — use get_simulation to read the cache without re-running.
2071
2072 Raises:
2073 ValueError: Unknown simulation_type or proposal not found.
2074 """
2075 import time
2076
2077 from musehub.services.proposal_simulation import (
2078 simulate_conflict_scan,
2079 simulate_dependency_order,
2080 simulate_risk_projection,
2081 )
2082 from musehub.services.musehub_snapshot import get_snapshot_manifest
2083
2084 if simulation_type not in _VALID_SIMULATION_TYPES:
2085 raise ValueError(
2086 f"Unknown simulation_type '{simulation_type}'. "
2087 f"Valid types: {sorted(_VALID_SIMULATION_TYPES)}"
2088 )
2089
2090 stmt = select(MusehubProposal).where(
2091 MusehubProposal.repo_id == repo_id,
2092 MusehubProposal.proposal_id == proposal_id,
2093 )
2094 proposal = (await session.execute(stmt)).scalar_one_or_none()
2095 if proposal is None:
2096 raise ValueError(f"Proposal {proposal_id} not found in repo {repo_id}")
2097
2098 from_commit_id = await _current_from_commit(session, repo_id, proposal.from_branch)
2099 to_b = await _get_branch(session, repo_id, proposal.to_branch)
2100 from_b = await _get_branch(session, repo_id, proposal.from_branch)
2101
2102 to_manifest: StrDict = {}
2103 if to_b and to_b.head_commit_id:
2104 to_head = await session.get(MusehubCommit, to_b.head_commit_id)
2105 if to_head and to_head.snapshot_id:
2106 to_manifest = await get_snapshot_manifest(session, to_head.snapshot_id)
2107
2108 from_manifest: StrDict = {}
2109 if from_b and from_b.head_commit_id:
2110 from_head = await session.get(MusehubCommit, from_b.head_commit_id)
2111 if from_head and from_head.snapshot_id:
2112 from_manifest = await get_snapshot_manifest(session, from_head.snapshot_id)
2113
2114 strategy_name = getattr(proposal, "merge_strategy", "overlay") or "overlay"
2115 selective_domains: list[str] | None = getattr(proposal, "selective_domains", None)
2116 dimensional_risk: dict[str, float] | None = getattr(proposal, "dimensional_risk", None)
2117
2118 ancestor_manifest: StrDict | None = None
2119 if strategy_name in ("weave", "replay", "selective", "phased"):
2120 ancestor_manifest = await _resolve_ancestor_manifest(
2121 session, repo_id, proposal.from_branch, proposal.to_branch
2122 )
2123
2124 t0 = time.monotonic()
2125 if simulation_type == "conflict_scan":
2126 result_payload = simulate_conflict_scan(
2127 to_manifest,
2128 from_manifest,
2129 ancestor_manifest=ancestor_manifest,
2130 strategy=strategy_name,
2131 selective_domains=selective_domains,
2132 )
2133 elif simulation_type == "risk_projection":
2134 result_payload = simulate_risk_projection(
2135 to_manifest,
2136 from_manifest,
2137 ancestor_manifest=ancestor_manifest,
2138 current_dimensional_risk=dimensional_risk,
2139 strategy=strategy_name,
2140 selective_domains=selective_domains,
2141 )
2142 else: # dependency_order
2143 dag = await load_dag_for_proposals(session, [proposal_id])
2144 result_payload = simulate_dependency_order(dag)
2145
2146 duration_ms = int((time.monotonic() - t0) * 1000)
2147
2148 sim_id = compute_simulation_id(proposal_id, simulation_type, from_commit_id)
2149 now = _utc_now()
2150
2151 # Upsert: update on conflict (same proposal_id + simulation_type)
2152 existing_stmt = select(MusehubProposalSimulation).where(
2153 MusehubProposalSimulation.proposal_id == proposal_id,
2154 MusehubProposalSimulation.simulation_type == simulation_type,
2155 )
2156 existing = (await session.execute(existing_stmt)).scalar_one_or_none()
2157
2158 if existing is not None:
2159 existing.simulation_id = sim_id
2160 existing.from_branch_commit_id = from_commit_id
2161 existing.result = result_payload
2162 existing.duration_ms = duration_ms
2163 existing.created_at = now
2164 row = existing
2165 else:
2166 row = MusehubProposalSimulation(
2167 simulation_id=sim_id,
2168 proposal_id=proposal_id,
2169 simulation_type=simulation_type,
2170 from_branch_commit_id=from_commit_id,
2171 result=result_payload,
2172 duration_ms=duration_ms,
2173 created_at=now,
2174 )
2175 session.add(row)
2176
2177 await session.flush()
2178
2179 logger.info(
2180 "🔬 Simulation %s for proposal %s (%dms)",
2181 simulation_type, proposal_id, duration_ms
2182 )
2183 return _to_simulation_response(row, is_stale=False)
2184
2185
2186 async def get_simulation(
2187 session: AsyncSession,
2188 repo_id: str,
2189 proposal_id: str,
2190 simulation_type: str,
2191 ) -> SimulationResponse | None:
2192 """Return the cached simulation result, or None if never run.
2193
2194 Sets ``is_stale=True`` when the from_branch has advanced since the
2195 simulation was last run. Does NOT re-run; call run_simulation for that.
2196 """
2197 if simulation_type not in _VALID_SIMULATION_TYPES:
2198 raise ValueError(
2199 f"Unknown simulation_type '{simulation_type}'. "
2200 f"Valid types: {sorted(_VALID_SIMULATION_TYPES)}"
2201 )
2202
2203 stmt = select(MusehubProposalSimulation).where(
2204 MusehubProposalSimulation.proposal_id == proposal_id,
2205 MusehubProposalSimulation.simulation_type == simulation_type,
2206 )
2207 row = (await session.execute(stmt)).scalar_one_or_none()
2208 if row is None:
2209 return None
2210
2211 # Staleness check: compare stored commit ID with current branch tip
2212 proposal_stmt = select(MusehubProposal.from_branch).where(
2213 MusehubProposal.proposal_id == proposal_id,
2214 MusehubProposal.repo_id == repo_id,
2215 )
2216 from_branch = (await session.execute(proposal_stmt)).scalar_one_or_none()
2217 is_stale = False
2218 if from_branch:
2219 current_commit = await _current_from_commit(session, repo_id, from_branch)
2220 is_stale = bool(current_commit) and current_commit != row.from_branch_commit_id
2221
2222 return _to_simulation_response(row, is_stale=is_stale)
2223
2224
2225 async def list_simulations(
2226 session: AsyncSession,
2227 repo_id: str,
2228 proposal_id: str,
2229 ) -> SimulationListResponse:
2230 """Return all cached simulations for a proposal.
2231
2232 Includes staleness flags. Never re-runs.
2233 """
2234 # Resolve from_branch once for staleness checks
2235 proposal_stmt = select(MusehubProposal.from_branch).where(
2236 MusehubProposal.proposal_id == proposal_id,
2237 MusehubProposal.repo_id == repo_id,
2238 )
2239 from_branch = (await session.execute(proposal_stmt)).scalar_one_or_none()
2240 current_commit = ""
2241 if from_branch:
2242 current_commit = await _current_from_commit(session, repo_id, from_branch)
2243
2244 rows_stmt = select(MusehubProposalSimulation).where(
2245 MusehubProposalSimulation.proposal_id == proposal_id,
2246 )
2247 rows = list((await session.execute(rows_stmt)).scalars().all())
2248
2249 responses = [
2250 _to_simulation_response(
2251 r,
2252 is_stale=bool(current_commit) and current_commit != r.from_branch_commit_id,
2253 )
2254 for r in rows
2255 ]
2256 return SimulationListResponse(simulations=responses, total=len(responses))
File History 1 commit
sha256:52ab8032b6bf2313b627a3d858051072dfd3f21f5f2d79bee113928f12510f99 feat(#143): wire is_deleted end-to-end for proposal comments Sonnet 5 minor 10 days ago