gabriel / musehub public
proposals.py python
1,099 lines 41.2 KB
Raw
sha256:400438cf8bc700a611f1ba798aa9def68290f487dc19f7dbf317985ad17050c9 chore: delete muse/prose domain — hallucinated, never existed Sonnet 4.6 minor ⚠ breaking 35 days ago
1 """MuseHub merge proposal route handlers.
2
3 Endpoint summary:
4 POST /repos/{repo_id}/proposals — open a proposal
5 GET /repos/{repo_id}/proposals — list proposals
6 GET /repos/{repo_id}/proposals/{proposal_id} — get a proposal
7 PATCH /repos/{repo_id}/proposals/{proposal_id} — update a proposal (author only)
8 GET /repos/{repo_id}/proposals/{proposal_id}/diff — musical diff (radar data)
9 POST /repos/{repo_id}/proposals/{proposal_id}/merge — merge a proposal
10 POST /repos/{repo_id}/proposals/{proposal_id}/comments — create review comment
11 GET /repos/{repo_id}/proposals/{proposal_id}/comments — list review comments (threaded)
12 POST /repos/{repo_id}/proposals/{proposal_id}/reviewers — request review from users
13 DELETE /repos/{repo_id}/proposals/{proposal_id}/reviewers/{username} — remove review request
14 GET /repos/{repo_id}/proposals/{proposal_id}/reviews — list reviews
15 POST /repos/{repo_id}/proposals/{proposal_id}/reviews — submit a review
16
17 All endpoints require a valid MSign token (except diff which accepts anonymous reads
18 of public repos, matching the same visibility rules as get_proposal).
19 No business logic lives here — all persistence is delegated to
20 musehub.services.musehub_proposals.
21 """
22
23 import asyncio
24 import logging
25 from datetime import datetime, timezone
26
27 from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response, status
28 from sqlalchemy import select
29 from sqlalchemy.ext.asyncio import AsyncSession
30
31 from muse.core.types import split_pubkey
32 from musehub.auth.dependencies import TokenClaims, optional_token, require_scope, require_valid_token
33 from musehub.api.routes.musehub.pagination import PaginationParams, build_cursor_link_header
34 from musehub.crypto.keys import SignatureError
35 from musehub.db import get_db
36 from musehub.db.musehub_auth_models import MusehubAuthKey
37 from musehub.db.musehub_collaborator_models import MusehubCollaborator
38 from musehub.models.musehub import (
39 RepoResponse,
40 DomainHeatResponse,
41 MergeReadinessResponse,
42 ProposalCommentCreate,
43 ProposalCommentListResponse,
44 ProposalCreate,
45 ProposalUpdate,
46 ProposalDiffResponse,
47 ProposalListFilters,
48 ProposalListResponse,
49 ProposalMergeRequest,
50 ProposalMergeResponse,
51 ProposalResponse,
52 ProposalReviewCreate,
53 ProposalReviewListResponse,
54 ProposalReviewResponse,
55 ProposalReviewerRequest,
56 ProposalEventPayload,
57 SimulationListResponse,
58 SimulationResponse,
59 )
60 from musehub.services import musehub_divergence, musehub_proposals, musehub_repository
61 from musehub.services.musehub_proposals import BranchNotFoundError
62 from musehub.services.proposal_dag import CycleError
63 from musehub.services.musehub_webhook_dispatcher import dispatch_event_background
64
65 logger = logging.getLogger(__name__)
66
67 router = APIRouter()
68
69
70 def _guard_write_access(repo: RepoResponse, caller_handle: str) -> None:
71 """Raise 403 if the caller is not the owner of a private repo."""
72 if repo.visibility != "public" and repo.owner != caller_handle:
73 raise HTTPException(
74 status_code=status.HTTP_403_FORBIDDEN,
75 detail="Only the repo owner may write to a private repo.",
76 )
77
78
79 async def _guard_repo_owner(
80 repo: RepoResponse, caller_handle: str, db: AsyncSession
81 ) -> None:
82 """Raise 403 if the caller is not the owner or an accepted write/admin collaborator.
83
84 State-changing operations (merge, reviewer assignment) require at least
85 write-level access -- not literal ownership. A write collaborator must be
86 able to request reviewers on their own proposal, for example.
87 """
88 if repo.owner == caller_handle:
89 return
90 collab = (await db.execute(
91 select(MusehubCollaborator).where(
92 MusehubCollaborator.repo_id == repo.repo_id,
93 MusehubCollaborator.identity_handle == caller_handle,
94 MusehubCollaborator.accepted_at.isnot(None),
95 MusehubCollaborator.permission.in_(["write", "admin"]),
96 )
97 )).scalar_one_or_none()
98 if collab is None:
99 raise HTTPException(
100 status_code=status.HTTP_403_FORBIDDEN,
101 detail="Only the repo owner or a write/admin collaborator may perform this action.",
102 )
103
104
105 @router.post(
106 "/repos/{repo_id}/proposals",
107 response_model=ProposalResponse,
108 status_code=status.HTTP_201_CREATED,
109 operation_id="createProposal",
110 summary="Open a merge proposal against a MuseHub repo",
111 )
112 async def create_proposal(
113 request: Request,
114 repo_id: str,
115 body: ProposalCreate,
116 background_tasks: BackgroundTasks,
117 db: AsyncSession = Depends(get_db),
118 token: TokenClaims = Depends(require_scope("proposal:write")),
119 ) -> ProposalResponse:
120 """Open a new merge proposal proposing to merge from_branch into to_branch.
121
122 Returns 422 if from_branch == to_branch.
123 Returns 404 if from_branch does not exist in the repo.
124 """
125 if body.from_branch == body.to_branch:
126 raise HTTPException(
127 status_code=422,
128 detail="from_branch and to_branch must be different",
129 )
130
131 repo = await musehub_repository.get_repo(db, repo_id)
132 if repo is None:
133 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
134 _guard_write_access(repo, token.handle)
135
136 # ── Proposer signature verification ──────────────────────────────────────
137 if body.proposer_public_key is not None or body.proposer_signature is not None:
138 if not body.proposer_public_key or not body.proposer_signature or not body.proposer_timestamp:
139 raise HTTPException(
140 status_code=422,
141 detail="proposerPublicKey, proposerSignature, and proposerTimestamp must all be provided together",
142 )
143
144 # T4 — verify the supplied key is registered to this identity
145 try:
146 _, key_b64 = split_pubkey(body.proposer_public_key)
147 except ValueError:
148 raise HTTPException(status_code=422, detail="proposerPublicKey must be 'ed25519:<base64url>'")
149
150 key_row = (
151 await db.execute(
152 select(MusehubAuthKey).where(
153 MusehubAuthKey.identity_id == token.identity_id,
154 MusehubAuthKey.public_key_b64 == key_b64,
155 )
156 )
157 ).scalar_one_or_none()
158 if key_row is None:
159 raise HTTPException(
160 status_code=status.HTTP_403_FORBIDDEN,
161 detail="proposerPublicKey is not registered to your identity",
162 )
163
164 # T3 — verify signature over canonical pre-image
165 try:
166 client_ts = datetime.fromisoformat(body.proposer_timestamp)
167 except ValueError:
168 raise HTTPException(status_code=422, detail="proposerTimestamp must be ISO-8601")
169
170 from musehub.proposals.signing import (
171 canonical_propose_message,
172 check_timestamp_skew,
173 verify_proposer_signature,
174 )
175 try:
176 check_timestamp_skew(client_ts)
177 except ValueError as exc:
178 raise HTTPException(status_code=422, detail=str(exc))
179
180 pre_image = canonical_propose_message(
181 repo_id=repo_id,
182 from_branch=body.from_branch,
183 to_branch=body.to_branch,
184 author=token.handle,
185 created_at=client_ts,
186 )
187 try:
188 verify_proposer_signature(
189 message=pre_image,
190 signature=body.proposer_signature,
191 public_key=body.proposer_public_key,
192 )
193 except SignatureError as exc:
194 raise HTTPException(status_code=422, detail=f"Invalid proposer signature: {exc}")
195
196 url_prefix = f"{str(request.base_url).rstrip('/')}/{repo.owner}/{repo.slug}"
197 try:
198 proposal = await musehub_proposals.create_proposal(
199 db,
200 repo_id=repo_id,
201 title=body.title,
202 from_branch=body.from_branch,
203 to_branch=body.to_branch,
204 body=body.body,
205 author=token.handle,
206 author_identity_id=token.identity_id,
207 proposal_type=body.proposal_type.value,
208 is_draft=body.is_draft,
209 merge_strategy=body.merge_strategy.value,
210 merge_conditions=body.merge_conditions.model_dump() if body.merge_conditions else None,
211 selective_domains=body.selective_domains,
212 depends_on=body.depends_on or [],
213 proposer_signature=body.proposer_signature,
214 proposer_public_key=body.proposer_public_key,
215 url_prefix=url_prefix,
216 )
217 except BranchNotFoundError as exc:
218 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
219 except (ValueError, CycleError) as exc:
220 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc))
221
222 head_commit_id = await musehub_repository.get_branch_head_commit_id(
223 db, repo_id, body.from_branch
224 )
225
226 await db.commit()
227
228 open_proposal_payload: ProposalEventPayload = {
229 "repoId": repo_id,
230 "action": "opened",
231 "proposalId": proposal.proposal_id,
232 "title": proposal.title,
233 "fromBranch": proposal.from_branch,
234 "toBranch": proposal.to_branch,
235 "state": proposal.state,
236 }
237 background_tasks.add_task(
238 dispatch_event_background,
239 repo_id,
240 "proposal",
241 open_proposal_payload,
242 )
243
244 return proposal
245
246
247 @router.get(
248 "/repos/{repo_id}/proposals",
249 response_model=ProposalListResponse,
250 operation_id="listProposals",
251 summary="List merge proposals for a MuseHub repo",
252 )
253 async def list_proposals(
254 repo_id: str,
255 request: Request,
256 response: Response,
257 state: str = Query(
258 "all",
259 pattern="^(open|in_review|approved|drafting|settling|merged|closed|abandoned|all)$",
260 description="Filter by proposal state",
261 ),
262 sort: str = Query(
263 "newest",
264 pattern="^(newest|oldest|risk_desc|risk_asc|merge_ready_first)$",
265 description="Sort order",
266 ),
267 risk_band: list[str] | None = Query(None, description="Filter by risk band(s)"),
268 domain: list[str] | None = Query(None, description="Filter by active domain(s)"),
269 author_type: str = Query(
270 "all",
271 pattern="^(human|agent|org|all)$",
272 description="Filter by author identity type",
273 ),
274 assigned_reviewer: str | None = Query(
275 None,
276 pattern=r"^[a-zA-Z0-9_-]{1,64}$",
277 description="Filter proposals where this handle has a pending review",
278 ),
279 pagination: PaginationParams = Depends(PaginationParams),
280 db: AsyncSession = Depends(get_db),
281 claims: TokenClaims | None = Depends(optional_token),
282 ) -> ProposalListResponse:
283 """Return merge proposals for a repo with cursor-based pagination and filters.
284
285 Cursor-based keyset pagination anchors each page to a stable position in the
286 ``created_at`` sequence, so callers always see a consistent, non-duplicating
287 stream regardless of concurrent mutations.
288
289 Pass ``nextCursor`` from a previous response as ``?cursor=`` to advance.
290 A null ``nextCursor`` means this is the last page.
291
292 Supported filters:
293 ``state`` — proposal state; defaults to "open"
294 ``sort`` — ``newest``, ``oldest``, ``risk_desc``, ``risk_asc``,
295 ``merge_ready_first``
296 ``risk_band`` — one or more of ``critical``, ``high``, ``medium``,
297 ``low``, ``none`` (OR semantics)
298 ``domain`` — one or more of ``code``, ``midi``, ``stems``,
299 ``pay`` (OR semantics)
300 ``author_type`` — ``human``, ``agent``, ``org``, or ``all``
301 ``assigned_reviewer`` — handle of a reviewer with a pending review
302
303 Returns 401 when the repo is private and no auth token is present.
304 Returns 404 when the repo is not found.
305 """
306 repo = await musehub_repository.get_repo(db, repo_id)
307 if repo is None:
308 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
309 if repo.visibility != "public" and claims is None:
310 raise HTTPException(
311 status_code=status.HTTP_401_UNAUTHORIZED,
312 detail="Authentication required to access private repos.",
313 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
314 )
315
316 filters = ProposalListFilters(
317 state=state,
318 sort=sort,
319 risk_band=risk_band,
320 domain=domain,
321 author_type=author_type,
322 assigned_reviewer=assigned_reviewer,
323 limit=pagination.limit,
324 cursor=pagination.cursor,
325 )
326 url_prefix = f"{str(request.base_url).rstrip('/')}/{repo.owner}/{repo.slug}"
327 result = await musehub_proposals.list_proposals(db, repo_id, filters=filters, url_prefix=url_prefix)
328 if result.next_cursor is not None:
329 response.headers["Link"] = build_cursor_link_header(
330 request, result.next_cursor, pagination.limit
331 )
332 return result
333
334
335 @router.get(
336 "/repos/{repo_id}/proposals/heat",
337 response_model=DomainHeatResponse,
338 operation_id="getProposalDomainHeat",
339 summary="Get per-domain proposal heat for a repo",
340 )
341 async def get_proposal_domain_heat(
342 repo_id: str,
343 state: str = Query(
344 "open",
345 pattern="^(open|in_review|approved|drafting|settling|merged|abandoned|all)$",
346 description="Proposal state to aggregate over; defaults to open",
347 ),
348 db: AsyncSession = Depends(get_db),
349 claims: TokenClaims | None = Depends(optional_token),
350 ) -> DomainHeatResponse:
351 """Return per-domain activity counts and average risk scores for a repo.
352
353 Aggregates all proposals matching ``state`` and returns one entry per active
354 domain with the count of proposals touching that domain and their average
355 risk score.
356
357 Intended for the domain heat bar rendered at the top of the proposals list
358 page — a fast summary of which dimensions of the codebase are in motion.
359
360 Returns 401 when the repo is private and no auth token is present.
361 Returns 404 when the repo is not found.
362 """
363 repo = await musehub_repository.get_repo(db, repo_id)
364 if repo is None:
365 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
366 if repo.visibility != "public" and claims is None:
367 raise HTTPException(
368 status_code=status.HTTP_401_UNAUTHORIZED,
369 detail="Authentication required to access private repos.",
370 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
371 )
372 return await musehub_proposals.get_domain_heat(repo_id, state, db)
373
374
375 @router.get(
376 "/repos/{repo_id}/proposals/readiness",
377 response_model=MergeReadinessResponse,
378 operation_id="getProposalMergeReadiness",
379 summary="Get merge-readiness buckets for open proposals in a repo",
380 )
381 async def get_proposal_merge_readiness(
382 repo_id: str,
383 db: AsyncSession = Depends(get_db),
384 claims: TokenClaims | None = Depends(optional_token),
385 ) -> MergeReadinessResponse:
386 """Return proposal numbers bucketed by merge readiness.
387
388 Buckets:
389 ``ready`` — approval_count ≥ required_approvals AND breakage_count == 0
390 ``blocked`` — reserved for dependency-blocked proposals (Phase 3+)
391 ``settling`` — proposals in the ``settling`` state (payment in flight)
392 ``needs_review`` — all others (open, in_review)
393
394 Scans all non-merged, non-abandoned proposals in the repo. Intended for the
395 Merge Readiness widget on the proposals list page.
396
397 Returns 401 when the repo is private and no auth token is present.
398 Returns 404 when the repo is not found.
399 """
400 repo = await musehub_repository.get_repo(db, repo_id)
401 if repo is None:
402 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
403 if repo.visibility != "public" and claims is None:
404 raise HTTPException(
405 status_code=status.HTTP_401_UNAUTHORIZED,
406 detail="Authentication required to access private repos.",
407 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
408 )
409 return await musehub_proposals.get_merge_readiness(repo_id, db)
410
411
412 @router.get(
413 "/repos/{repo_id}/proposals/{proposal_id}",
414 response_model=ProposalResponse,
415 operation_id="getProposal",
416 summary="Get a single merge proposal by ID",
417 )
418 async def get_proposal(
419 repo_id: str,
420 proposal_id: str,
421 request: Request,
422 db: AsyncSession = Depends(get_db),
423 claims: TokenClaims | None = Depends(optional_token),
424 ) -> ProposalResponse:
425 """Return a single proposal. Returns 404 if the repo or proposal is not found."""
426 repo = await musehub_repository.get_repo(db, repo_id)
427 if repo is None:
428 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
429 if repo.visibility != "public" and claims is None:
430 raise HTTPException(
431 status_code=status.HTTP_401_UNAUTHORIZED,
432 detail="Authentication required to access private repos.",
433 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
434 )
435 url_prefix = f"{str(request.base_url).rstrip('/')}/{repo.owner}/{repo.slug}"
436 proposal = await musehub_proposals.get_proposal(db, repo_id, proposal_id, url_prefix=url_prefix)
437 if proposal is None:
438 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Merge proposal not found")
439 return proposal
440
441
442 @router.patch(
443 "/repos/{repo_id}/proposals/{proposal_id}",
444 response_model=ProposalResponse,
445 operation_id="updateProposal",
446 summary="Partially update a merge proposal",
447 )
448 async def update_proposal(
449 repo_id: str,
450 proposal_id: str,
451 body: ProposalUpdate,
452 db: AsyncSession = Depends(get_db),
453 claims: TokenClaims = Depends(require_valid_token),
454 ) -> ProposalResponse:
455 """Update title, body, proposal_type, or merge_strategy. Only the proposal author may update."""
456 proposal = await musehub_proposals.get_proposal(db, repo_id, proposal_id)
457 if proposal is None:
458 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Merge proposal not found")
459 if proposal.author != claims.handle:
460 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the proposal author may update it")
461 updated = await musehub_proposals.update_proposal(
462 db, repo_id, proposal_id,
463 title=body.title,
464 body=body.body,
465 proposal_type=body.proposal_type.value if body.proposal_type else None,
466 merge_strategy=body.merge_strategy.value if body.merge_strategy else None,
467 )
468 if updated is None:
469 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Merge proposal not found")
470 return updated
471
472
473 @router.get(
474 "/repos/{repo_id}/proposals/{proposal_id}/diff",
475 response_model=ProposalDiffResponse,
476 operation_id="getProposalDiff",
477 summary="Compute domain diff between the proposal branches",
478 )
479 async def get_proposal_diff(
480 repo_id: str,
481 proposal_id: str,
482 db: AsyncSession = Depends(get_db),
483 claims: TokenClaims | None = Depends(optional_token),
484 ) -> ProposalDiffResponse:
485 """Return a five-dimension musical diff between from_branch and to_branch of a proposal.
486
487 Uses the Jaccard divergence engine to score harmonic, rhythmic, melodic,
488 structural, and dynamic change magnitude between the two branches.
489
490 This endpoint is consumed by the proposal detail page to render the radar chart,
491 piano roll diff, audio A/B toggle, and dimension badges. AI agents use it
492 to reason about musical impact before approving a merge.
493
494 Returns:
495 ProposalDiffResponse with per-dimension scores and overall divergence score.
496
497 Raises:
498 404: If the repo or proposal is not found.
499 401: If the repo is private and no token is provided.
500 """
501 repo = await musehub_repository.get_repo(db, repo_id)
502 if repo is None:
503 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
504 if repo.visibility != "public" and claims is None:
505 raise HTTPException(
506 status_code=status.HTTP_401_UNAUTHORIZED,
507 detail="Authentication required to access private repos.",
508 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
509 )
510 proposal = await musehub_proposals.get_proposal(db, repo_id, proposal_id)
511 if proposal is None:
512 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Merge proposal not found")
513
514 try:
515 result = await musehub_divergence.compute_hub_divergence(
516 db,
517 repo_id=repo_id,
518 branch_a=proposal.to_branch,
519 branch_b=proposal.from_branch,
520 )
521 except ValueError:
522 return musehub_divergence.build_zero_diff_response(
523 proposal_id=proposal_id,
524 repo_id=repo_id,
525 from_branch=proposal.from_branch,
526 to_branch=proposal.to_branch,
527 )
528
529 return musehub_divergence.build_proposal_diff_response(
530 proposal_id=proposal_id,
531 from_branch=proposal.from_branch,
532 to_branch=proposal.to_branch,
533 result=result,
534 )
535
536
537 @router.post(
538 "/repos/{repo_id}/proposals/{proposal_id}/merge",
539 response_model=ProposalMergeResponse,
540 operation_id="mergeProposal",
541 summary="Merge an open merge proposal",
542 )
543 async def merge_proposal(
544 repo_id: str,
545 proposal_id: str,
546 body: ProposalMergeRequest,
547 background_tasks: BackgroundTasks,
548 db: AsyncSession = Depends(get_db),
549 token: TokenClaims = Depends(require_scope("proposal:write")),
550 ) -> ProposalMergeResponse:
551 """Merge an open proposal using the requested strategy.
552
553 Creates a merge commit on to_branch with parent_ids from both
554 branch heads, advances the branch head pointer, and marks the proposal as merged.
555
556 Returns 404 if the proposal or repo is not found.
557 Returns 409 if the proposal is already merged or closed.
558 """
559 repo = await musehub_repository.get_repo(db, repo_id)
560 if repo is None:
561 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
562 await _guard_repo_owner(repo, token.handle, db)
563
564 from musehub.services.musehub_governance import load_governance, check_quorum
565 governance = await load_governance(db, repo_id)
566 if governance is not None:
567 met, found, threshold = await check_quorum(db, repo_id, proposal_id, governance)
568 if not met:
569 raise HTTPException(
570 status_code=status.HTTP_403_FORBIDDEN,
571 detail=f"Quorum not met: {found}/{threshold} member approvals (need {threshold}).",
572 )
573
574 try:
575 proposal = await musehub_proposals.merge_proposal(
576 db,
577 repo_id,
578 proposal_id,
579 merge_strategy=body.merge_strategy,
580 merger_handle=token.handle,
581 commit_history=body.commit_history,
582 )
583 except ValueError as exc:
584 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
585 except RuntimeError as exc:
586 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
587
588 await db.commit()
589
590 if proposal.merge_commit_id is None:
591 raise HTTPException(
592 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
593 detail="Merge completed but merge_commit_id is missing",
594 )
595
596 merge_proposal_payload: ProposalEventPayload = {
597 "repoId": repo_id,
598 "action": "merged",
599 "proposalId": proposal.proposal_id,
600 "title": proposal.title,
601 "fromBranch": proposal.from_branch,
602 "toBranch": proposal.to_branch,
603 "state": proposal.state,
604 "mergeCommitId": proposal.merge_commit_id,
605 }
606 background_tasks.add_task(
607 dispatch_event_background,
608 repo_id,
609 "proposal",
610 merge_proposal_payload,
611 )
612 return ProposalMergeResponse(merged=True, merge_commit_id=proposal.merge_commit_id)
613
614
615 @router.post(
616 "/repos/{repo_id}/proposals/{proposal_id}/close",
617 response_model=ProposalResponse,
618 operation_id="closeProposal",
619 summary="Close an open proposal without merging",
620 )
621 async def close_proposal(
622 repo_id: str,
623 proposal_id: str,
624 db: AsyncSession = Depends(get_db),
625 token: TokenClaims = Depends(require_scope("proposal:write")),
626 ) -> ProposalResponse:
627 """Set a proposal to ``closed`` state without merging.
628
629 Returns 404 if the proposal is not found.
630 Returns 409 if the proposal is already closed or merged.
631 """
632 try:
633 proposal = await musehub_proposals.close_proposal(db, repo_id, proposal_id)
634 except KeyError as exc:
635 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
636 except RuntimeError as exc:
637 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
638 await db.commit()
639 return proposal
640
641
642 @router.post(
643 "/repos/{repo_id}/proposals/{proposal_id}/reopen",
644 response_model=ProposalResponse,
645 operation_id="reopenProposal",
646 summary="Reopen a merged or closed proposal",
647 )
648 async def reopen_proposal(
649 repo_id: str,
650 proposal_id: str,
651 db: AsyncSession = Depends(get_db),
652 token: TokenClaims = Depends(require_scope("proposal:write")),
653 ) -> ProposalResponse:
654 """Reset a merged or closed proposal back to ``open`` state.
655
656 Clears ``merge_commit_id`` and ``merged_at``. Used to recover from a
657 corrupt merge commit (e.g. bug #36) so the author can re-trigger the merge
658 after the server-side fix is deployed.
659
660 Returns 404 if the proposal is not found.
661 Returns 409 if the proposal is already open.
662 """
663 try:
664 proposal = await musehub_proposals.reopen_proposal(db, repo_id, proposal_id)
665 except KeyError as exc:
666 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
667 except RuntimeError as exc:
668 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
669 await db.commit()
670 return proposal
671
672
673 @router.post(
674 "/repos/{repo_id}/proposals/{proposal_id}/comments",
675 response_model=ProposalCommentListResponse,
676 status_code=status.HTTP_201_CREATED,
677 operation_id="createProposalComment",
678 summary="Leave a review comment on a merge proposal diff",
679 )
680 async def create_proposal_comment(
681 repo_id: str,
682 proposal_id: str,
683 body: ProposalCommentCreate,
684 db: AsyncSession = Depends(get_db),
685 token: TokenClaims = Depends(require_scope("proposal:write")),
686 ) -> ProposalCommentListResponse:
687 """Create a review comment on a proposal and return the updated thread list.
688
689 Comments can target the whole proposal (general), a named track, a beat region,
690 or a single note event. Replies attach via ``parent_comment_id``.
691
692 Returns the full threaded comment list after insertion so the UI can
693 refresh in a single round-trip.
694
695 Returns 404 if the repo or proposal does not exist.
696 """
697 repo = await musehub_repository.get_repo(db, repo_id)
698 if repo is None:
699 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
700 _guard_write_access(repo, token.handle)
701
702 try:
703 await musehub_proposals.create_proposal_comment(
704 db,
705 proposal_id=proposal_id,
706 repo_id=repo_id,
707 author=token.handle,
708 author_identity_id=token.identity_id,
709 body=body.body,
710 target_type=body.target_type,
711 target_track=body.target_track,
712 target_beat_start=body.target_beat_start,
713 target_beat_end=body.target_beat_end,
714 target_note_pitch=body.target_note_pitch,
715 parent_comment_id=body.parent_comment_id,
716 symbol_address=body.symbol_address,
717 )
718 except ValueError as exc:
719 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
720
721 await db.commit()
722 return await musehub_proposals.list_proposal_comments(db, proposal_id=proposal_id, repo_id=repo_id)
723
724
725 @router.get(
726 "/repos/{repo_id}/proposals/{proposal_id}/comments",
727 response_model=ProposalCommentListResponse,
728 operation_id="listProposalComments",
729 summary="List review comments for a proposal, assembled into threaded discussions",
730 )
731 async def list_proposal_comments(
732 repo_id: str,
733 proposal_id: str,
734 request: Request,
735 response: Response,
736 pagination: PaginationParams = Depends(PaginationParams),
737 db: AsyncSession = Depends(get_db),
738 claims: TokenClaims | None = Depends(optional_token),
739 ) -> ProposalCommentListResponse:
740 """Return review comments for a proposal in a two-level thread structure.
741
742 Top-level comments carry a ``replies`` list with their direct children.
743 Public repo comments are readable without authentication; private repos
744 require a valid MSign Authorization header.
745
746 Pass ``nextCursor`` from a previous response as ``?cursor=`` to advance.
747 A null ``nextCursor`` means this is the last page.
748
749 Returns 404 if the repo or proposal does not exist.
750 """
751 repo = await musehub_repository.get_repo(db, repo_id)
752 if repo is None:
753 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
754 if repo.visibility != "public" and claims is None:
755 raise HTTPException(
756 status_code=status.HTTP_401_UNAUTHORIZED,
757 detail="Authentication required to access private repos.",
758 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
759 )
760 proposal = await musehub_proposals.get_proposal(db, repo_id, proposal_id)
761 if proposal is None:
762 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Merge proposal not found")
763 result = await musehub_proposals.list_proposal_comments(
764 db, proposal_id=proposal_id, repo_id=repo_id,
765 cursor=pagination.cursor, limit=pagination.limit,
766 )
767 if result.next_cursor is not None:
768 response.headers["Link"] = build_cursor_link_header(
769 request, result.next_cursor, pagination.limit
770 )
771 return result
772
773
774 # ---------------------------------------------------------------------------
775 # Reviewer assignment endpoints
776 # ---------------------------------------------------------------------------
777
778
779 @router.post(
780 "/repos/{repo_id}/proposals/{proposal_id}/reviewers",
781 response_model=ProposalReviewListResponse,
782 status_code=status.HTTP_201_CREATED,
783 operation_id="requestProposalReviewers",
784 summary="Request a review from one or more users",
785 )
786 async def request_proposal_reviewers(
787 repo_id: str,
788 proposal_id: str,
789 body: ProposalReviewerRequest,
790 db: AsyncSession = Depends(get_db),
791 token: TokenClaims = Depends(require_scope("proposal:write")),
792 ) -> ProposalReviewListResponse:
793 """Add one or more users as requested reviewers on a proposal.
794
795 Creates a ``pending`` review row for each username that does not already
796 have one. Existing rows (any state) are left unchanged so submitted
797 approvals are never silently reset.
798
799 Returns the full updated review list for the proposal.
800
801 Returns 404 if the repo or proposal is not found.
802 """
803 repo = await musehub_repository.get_repo(db, repo_id)
804 if repo is None:
805 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
806 await _guard_repo_owner(repo, token.handle, db)
807
808 try:
809 result = await musehub_proposals.request_reviewers(
810 db,
811 repo_id=repo_id,
812 proposal_id=proposal_id,
813 reviewers=body.reviewers,
814 )
815 except ValueError as exc:
816 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
817
818 await db.commit()
819 return result
820
821
822 @router.delete(
823 "/repos/{repo_id}/proposals/{proposal_id}/reviewers/{username}",
824 response_model=ProposalReviewListResponse,
825 operation_id="removeProposalReviewer",
826 summary="Remove a pending review request for a user",
827 )
828 async def remove_proposal_reviewer(
829 repo_id: str,
830 proposal_id: str,
831 username: str,
832 db: AsyncSession = Depends(get_db),
833 token: TokenClaims = Depends(require_scope("proposal:write")),
834 ) -> ProposalReviewListResponse:
835 """Remove a pending review request for ``username`` from a proposal.
836
837 Only ``pending`` assignments may be removed. Submitted reviews (approved,
838 changes_requested, dismissed) are immutable to preserve the audit trail.
839
840 Returns the updated review list after deletion.
841
842 Returns 404 if the repo, proposal, or reviewer assignment is not found.
843 Returns 409 if the reviewer has already submitted a review.
844 """
845 repo = await musehub_repository.get_repo(db, repo_id)
846 if repo is None:
847 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
848 await _guard_repo_owner(repo, token.handle, db)
849
850 try:
851 result = await musehub_proposals.remove_reviewer(
852 db,
853 repo_id=repo_id,
854 proposal_id=proposal_id,
855 username=username,
856 )
857 except ValueError as exc:
858 msg = str(exc)
859 if "already submitted" in msg:
860 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=msg)
861 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=msg)
862
863 await db.commit()
864 return result
865
866
867 # ---------------------------------------------------------------------------
868 # Review submission endpoints
869 # ---------------------------------------------------------------------------
870
871
872 @router.get(
873 "/repos/{repo_id}/proposals/{proposal_id}/reviews",
874 response_model=ProposalReviewListResponse,
875 operation_id="listProposalReviews",
876 summary="List reviews for a merge proposal",
877 )
878 async def list_proposal_reviews(
879 repo_id: str,
880 proposal_id: str,
881 request: Request,
882 response: Response,
883 state: str | None = Query(
884 None,
885 pattern="^(pending|approved|changes_requested|dismissed)$",
886 description="Filter by review state (pending, approved, changes_requested, dismissed)",
887 ),
888 pagination: PaginationParams = Depends(PaginationParams),
889 db: AsyncSession = Depends(get_db),
890 claims: TokenClaims | None = Depends(optional_token),
891 ) -> ProposalReviewListResponse:
892 """Return reviews for a proposal with cursor-based pagination.
893
894 Includes both pending reviewer assignments and submitted reviews.
895 Public repo reviews are readable without authentication; private repos
896 require a valid MSign Authorization header.
897
898 Pass ``nextCursor`` from a previous response as ``?cursor=`` to advance.
899 A null ``nextCursor`` means this is the last page.
900
901 Returns 404 if the repo or proposal is not found.
902 """
903 repo = await musehub_repository.get_repo(db, repo_id)
904 if repo is None:
905 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
906 if repo.visibility != "public" and claims is None:
907 raise HTTPException(
908 status_code=status.HTTP_401_UNAUTHORIZED,
909 detail="Authentication required to access private repos.",
910 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
911 )
912
913 try:
914 result = await musehub_proposals.list_reviews(
915 db,
916 repo_id=repo_id,
917 proposal_id=proposal_id,
918 state=state,
919 cursor=pagination.cursor,
920 limit=pagination.limit,
921 )
922 except ValueError as exc:
923 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
924
925 if result.next_cursor is not None:
926 response.headers["Link"] = build_cursor_link_header(
927 request, result.next_cursor, pagination.limit
928 )
929 return result
930
931
932 @router.post(
933 "/repos/{repo_id}/proposals/{proposal_id}/reviews",
934 response_model=ProposalReviewResponse,
935 status_code=status.HTTP_201_CREATED,
936 operation_id="submitProposalReview",
937 summary="Submit a formal review on a merge proposal",
938 )
939 async def submit_proposal_review(
940 repo_id: str,
941 proposal_id: str,
942 body: ProposalReviewCreate,
943 db: AsyncSession = Depends(get_db),
944 token: TokenClaims = Depends(require_scope("proposal:write")),
945 ) -> ProposalReviewResponse:
946 """Submit a formal review for the authenticated user.
947
948 ``event`` governs the resulting review state:
949 - ``approve`` → sets state to ``approved``
950 - ``request_changes`` → sets state to ``changes_requested``
951 - ``comment`` → leaves state as ``pending`` (body-only feedback)
952
953 If the user already has a review row on this proposal it is updated in-place;
954 otherwise a new row is created. This allows reviewers to revise their
955 verdict after seeing author responses.
956
957 Returns 404 if the repo or proposal is not found.
958 """
959 repo = await musehub_repository.get_repo(db, repo_id)
960 if repo is None:
961 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
962 _guard_write_access(repo, token.handle)
963
964 reviewer = token.handle
965
966 try:
967 result = await musehub_proposals.submit_review(
968 db,
969 repo_id=repo_id,
970 proposal_id=proposal_id,
971 reviewer_username=reviewer,
972 reviewer_identity_id=token.identity_id,
973 verdict=body.verdict,
974 body=body.body,
975 )
976 except ValueError as exc:
977 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
978
979 await db.commit()
980 return result
981
982
983 # ── Simulations ───────────────────────────────────────────────────────────────
984
985
986 @router.post(
987 "/repos/{repo_id}/proposals/{proposal_id}/simulations/{simulation_type}",
988 response_model=SimulationResponse,
989 status_code=status.HTTP_200_OK,
990 operation_id="runProposalSimulation",
991 summary="Run (or re-run) a simulation for a merge proposal",
992 )
993 async def run_proposal_simulation(
994 repo_id: str,
995 proposal_id: str,
996 simulation_type: str,
997 db: AsyncSession = Depends(get_db),
998 token: TokenClaims = Depends(require_scope("proposal:read")),
999 ) -> SimulationResponse:
1000 """Run a simulation and cache the result.
1001
1002 Always recomputes — calling this endpoint twice refreshes the cache.
1003
1004 ``simulation_type`` must be one of:
1005 - ``conflict_scan`` — files and domains that will conflict at merge time
1006 - ``risk_projection`` — projected post-merge dimensional risk scores
1007 - ``dependency_order`` — topological order and parallel phases for the DAG
1008
1009 Returns 404 if the repo or proposal is not found.
1010 Returns 422 if simulation_type is unknown.
1011 """
1012 repo = await musehub_repository.get_repo(db, repo_id)
1013 if repo is None:
1014 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
1015
1016 try:
1017 result = await musehub_proposals.run_simulation(
1018 db,
1019 repo_id=repo_id,
1020 proposal_id=proposal_id,
1021 simulation_type=simulation_type,
1022 )
1023 except ValueError as exc:
1024 detail = str(exc)
1025 code = (
1026 status.HTTP_422_UNPROCESSABLE_CONTENT
1027 if "Unknown simulation_type" in detail
1028 else status.HTTP_404_NOT_FOUND
1029 )
1030 raise HTTPException(status_code=code, detail=detail)
1031
1032 await db.commit()
1033 return result
1034
1035
1036 @router.get(
1037 "/repos/{repo_id}/proposals/{proposal_id}/simulations/{simulation_type}",
1038 response_model=SimulationResponse,
1039 status_code=status.HTTP_200_OK,
1040 operation_id="getProposalSimulation",
1041 summary="Get the cached simulation result for a merge proposal",
1042 )
1043 async def get_proposal_simulation(
1044 repo_id: str,
1045 proposal_id: str,
1046 simulation_type: str,
1047 db: AsyncSession = Depends(get_db),
1048 token: TokenClaims = Depends(require_scope("proposal:read")),
1049 ) -> SimulationResponse:
1050 """Return the cached simulation result.
1051
1052 Returns 404 if the repo, proposal, or simulation has not been run yet.
1053 ``is_stale`` is True when the from_branch has advanced since the simulation ran.
1054 """
1055 repo = await musehub_repository.get_repo(db, repo_id)
1056 if repo is None:
1057 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
1058
1059 try:
1060 result = await musehub_proposals.get_simulation(
1061 db,
1062 repo_id=repo_id,
1063 proposal_id=proposal_id,
1064 simulation_type=simulation_type,
1065 )
1066 except ValueError as exc:
1067 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc))
1068
1069 if result is None:
1070 raise HTTPException(
1071 status_code=status.HTTP_404_NOT_FOUND,
1072 detail=f"No '{simulation_type}' simulation found for this proposal. Run it first.",
1073 )
1074 return result
1075
1076
1077 @router.get(
1078 "/repos/{repo_id}/proposals/{proposal_id}/simulations",
1079 response_model=SimulationListResponse,
1080 status_code=status.HTTP_200_OK,
1081 operation_id="listProposalSimulations",
1082 summary="List all cached simulations for a merge proposal",
1083 )
1084 async def list_proposal_simulations(
1085 repo_id: str,
1086 proposal_id: str,
1087 db: AsyncSession = Depends(get_db),
1088 token: TokenClaims = Depends(require_scope("proposal:read")),
1089 ) -> SimulationListResponse:
1090 """Return all cached simulations for this proposal.
1091
1092 Returns an empty list (not 404) if no simulations have been run yet.
1093 Each entry includes ``is_stale`` reflecting whether the branch has advanced.
1094 """
1095 repo = await musehub_repository.get_repo(db, repo_id)
1096 if repo is None:
1097 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
1098
1099 return await musehub_proposals.list_simulations(db, repo_id=repo_id, proposal_id=proposal_id)
File History 5 commits
sha256:400438cf8bc700a611f1ba798aa9def68290f487dc19f7dbf317985ad17050c9 chore: delete muse/prose domain — hallucinated, never existed Sonnet 4.6 minor 35 days ago
sha256:4d42a346263e7cbbd152c147f3e6f24576f4b4440df9249ffb9fbcf9db699fcb feat: populate url in create_issue and create_proposal responses Sonnet 4.6 minor 35 days ago
sha256:3707eba7ad42cadedf18c8b9c534d839b88cfd1c30924c3c5a3edc74e1d809de feat: add url field to mist, issue, and proposal list/read … Sonnet 4.6 minor 35 days ago
sha256:af9422a68cbd2db7c88f664388e11134b0ae0057ee5ad14465d82208548a9d7d changing --event to --verdict. displaying changes requested… Human minor 42 days ago
sha256:a909058d727faac4d77f6e659cc0b1f9315efcb6aabfd870d08763525a67093d dialing in --strategy and --history on merge proposal Human minor 43 days ago