gabriel / musehub public
proposals.py python
916 lines 35.5 KB
Raw
sha256:50b52eda7afb2f122863aef47d684d1a9e4684b48f5f95367fc956e28ceb7d42 refactor: rename merge strategy aliases to canonical names Sonnet 4.6 minor ⚠ breaking 8 days ago
1 """Write executors: create_proposal, merge_proposal, create_proposal_comment, submit_proposal_review,
2 list_proposal_comments, request_proposal_reviewers, remove_proposal_reviewer, list_proposal_reviews,
3 get_proposal, run_simulation, get_simulation, list_simulations."""
4
5 import logging
6
7 from musehub.types.json_types import JSONObject, JSONValue, ReadOnlyJSONObject, json_list
8 from musehub.db.database import AsyncSessionLocal
9 from musehub.models.musehub import ProposalCommentResponse, ProposalReviewResponse, SimulationResponse
10 from musehub.services import musehub_proposals, musehub_repository
11 from musehub.services.musehub_mcp_executor import MusehubToolResult, _check_db_available
12 from musehub.mcp.write_tools.issues import _require_write_access, _require_public_or_write_access
13
14 logger = logging.getLogger(__name__)
15
16
17 def _comment_data(c: ProposalCommentResponse) -> JSONObject:
18 """Serialise a ProposalCommentResponse (with nested replies) to a ``JSONObject``."""
19 return {
20 "comment_id": c.comment_id,
21 "proposal_id": c.proposal_id,
22 "author": c.author,
23 "body": c.body,
24 "target_type": c.target_type,
25 "target_track": c.target_track,
26 "symbol_address": c.symbol_address,
27 "parent_comment_id": c.parent_comment_id,
28 "created_at": c.created_at.isoformat() if c.created_at else None,
29 "replies": [_comment_data(r) for r in c.replies],
30 }
31
32
33 def _review_data(r: ProposalReviewResponse) -> JSONObject:
34 """Serialise a ProposalReviewResponse to a ``JSONObject``."""
35 return {
36 "review_id": r.id,
37 "proposal_id": r.proposal_id,
38 "reviewer": r.reviewer_username,
39 "state": r.state,
40 "body": r.body,
41 "submitted_at": r.submitted_at.isoformat() if r.submitted_at else None,
42 "created_at": r.created_at.isoformat() if r.created_at else None,
43 }
44
45
46 def _simulation_data(sim: SimulationResponse) -> JSONObject:
47 """Serialise a SimulationResponse to a ``JSONObject``."""
48 return {
49 "simulation_id": sim.simulation_id,
50 "proposal_id": sim.proposal_id,
51 "simulation_type": sim.simulation_type,
52 "result": sim.result,
53 "is_stale": sim.is_stale,
54 "from_branch_commit_id": sim.from_branch_commit_id,
55 "duration_ms": sim.duration_ms,
56 "created_at": sim.created_at.isoformat() if sim.created_at else None,
57 "expires_at": sim.expires_at.isoformat() if sim.expires_at else None,
58 }
59
60
61 def _proposal_data(proposal: "ProposalResponse") -> JSONObject:
62 """Serialise a ProposalResponse to a ``JSONObject``."""
63 from musehub.models.musehub import ProposalResponse # local import for forward ref
64 data: JSONObject = {
65 "proposal_id": proposal.proposal_id,
66 "title": proposal.title,
67 "body": proposal.body,
68 "state": proposal.state,
69 "from_branch": proposal.from_branch,
70 "to_branch": proposal.to_branch,
71 "author": proposal.author,
72 "merge_commit_id": proposal.merge_commit_id,
73 "created_at": proposal.created_at.isoformat() if proposal.created_at else None,
74 "merged_at": proposal.merged_at.isoformat() if proposal.merged_at else None,
75 }
76 # Phase 1-5 enrichment fields — present on full ProposalResponse objects
77 for attr, key in [
78 ("proposal_type", "proposal_type"),
79 ("is_draft", "is_draft"),
80 ("merge_strategy", "merge_strategy"),
81 ("merge_conditions", "merge_conditions"),
82 ("selective_domains", "selective_domains"),
83 ("blocked_by", "blocked_by"),
84 ("blocks", "blocks"),
85 ("is_blocked", "is_blocked"),
86 ("latest_simulations", "latest_simulations"),
87 ]:
88 if hasattr(proposal, attr):
89 data[key] = getattr(proposal, attr)
90 return data
91
92
93 async def execute_create_proposal(
94 *,
95 repo_id: str,
96 title: str,
97 from_branch: str,
98 to_branch: str,
99 body: str = "",
100 proposal_type: str = "state_merge",
101 is_draft: bool = False,
102 merge_strategy: str = "overlay",
103 merge_conditions: ReadOnlyJSONObject | None = None,
104 selective_domains: list[str] | None = None,
105 depends_on: list[str] | None = None,
106 actor: str = "",
107 ) -> MusehubToolResult:
108 """Open a new merge proposal proposing to merge ``from_branch`` into ``to_branch``.
109
110 The caller must be authenticated and have write access to the repository.
111 An empty ``actor`` is rejected immediately.
112
113 Args:
114 repo_id: sha256 genesis ID of the repository.
115 title: Proposal title.
116 from_branch: Source branch name.
117 to_branch: Target branch name.
118 body: Optional markdown description.
119 proposal_type: One of 'code_review', 'domain_merge', 'dependency_update', 'release_merge'.
120 is_draft: Whether to open as a draft (not ready for review).
121 merge_strategy: One of 'overlay', 'weave', 'replay', 'selective', 'phased', 'cherry_pick'.
122 merge_conditions: Optional JSON object with additional merge conditions.
123 selective_domains: List of domain names for 'selective' strategy.
124 depends_on: List of proposal IDs this proposal depends on (must be merged first).
125 actor: Authenticated user ID (MSign handle).
126
127 Returns:
128 ``MusehubToolResult`` with ``data.proposal_id`` on success.
129 """
130 if (err := _check_db_available()) is not None:
131 return err
132
133 if from_branch == to_branch:
134 return MusehubToolResult(
135 ok=False,
136 error_code="invalid_args",
137 error_message="from_branch and to_branch must be different.",
138 )
139
140 try:
141 async with AsyncSessionLocal() as session:
142 repo = await musehub_repository.get_repo(session, repo_id)
143 if repo is None:
144 return MusehubToolResult(
145 ok=False,
146 error_code="repo_not_found",
147 error_message=f"Repository '{repo_id}' not found.",
148 hint="Call musehub_search_repos() to find available repositories, or musehub_set_context(owner, slug) to focus on a repo.",
149 )
150 if (err := await _require_public_or_write_access(
151 session, repo_id, actor, repo.owner, repo.visibility
152 )) is not None:
153 return err
154 actor_identity_id = await musehub_repository.get_identity_id_for_handle(session, actor)
155 proposal = await musehub_proposals.create_proposal(
156 session,
157 repo_id=repo_id,
158 title=title,
159 from_branch=from_branch,
160 to_branch=to_branch,
161 body=body,
162 author=actor,
163 author_identity_id=actor_identity_id,
164 proposal_type=proposal_type,
165 is_draft=is_draft,
166 merge_strategy=merge_strategy,
167 merge_conditions=merge_conditions,
168 selective_domains=selective_domains,
169 depends_on=depends_on,
170 )
171 await session.commit()
172 logger.info("MCP create_proposal '%s' (%s→%s) in %s", title, from_branch, to_branch, repo_id)
173 return MusehubToolResult(ok=True, data=_proposal_data(proposal))
174 except ValueError as exc:
175 return MusehubToolResult(
176 ok=False,
177 error_code="invalid_args",
178 error_message=str(exc),
179 )
180 except Exception as exc:
181 logger.exception("MCP create_proposal failed: %s", exc)
182 return MusehubToolResult(
183 ok=False,
184 error_code="invalid_args",
185 error_message=str(exc),
186 )
187
188
189 async def execute_merge_proposal(
190 *,
191 repo_id: str,
192 proposal_id: str,
193 merge_strategy: str = "merge_commit",
194 actor: str = "",
195 ) -> MusehubToolResult:
196 """Merge an open merge proposal using the specified merge strategy.
197
198 Only the repo owner or an accepted write/admin collaborator may merge a
199 proposal. Attempting to merge without authentication or sufficient
200 permission returns ``error_code="forbidden"``.
201
202 Args:
203 repo_id: sha256 genesis ID of the repository.
204 proposal_id: sha256 genesis ID of the merge proposal.
205 merge_strategy: ``"merge_commit"`` (default), ``"squash"``, or ``"rebase"``.
206 actor: Authenticated user ID (MSign handle).
207
208 Returns:
209 ``MusehubToolResult`` with merged proposal data and ``data.merge_commit_id`` on success.
210 """
211 if (err := _check_db_available()) is not None:
212 return err
213
214 try:
215 async with AsyncSessionLocal() as session:
216 repo = await musehub_repository.get_repo(session, repo_id)
217 if repo is None:
218 return MusehubToolResult(
219 ok=False,
220 error_code="repo_not_found",
221 error_message=f"Repository '{repo_id}' not found.",
222 hint="Call musehub_search_repos() to find available repositories.",
223 )
224 if (err := await _require_write_access(session, repo_id, actor, repo.owner)) is not None:
225 return err
226 proposal = await musehub_proposals.merge_proposal(
227 session,
228 repo_id,
229 proposal_id,
230 merge_strategy=merge_strategy,
231 )
232 await session.commit()
233 logger.info("MCP merge_proposal %s in %s", proposal_id, repo_id)
234 return MusehubToolResult(ok=True, data=_proposal_data(proposal))
235 except ValueError as exc:
236 return MusehubToolResult(
237 ok=False,
238 error_code="merge_conflict",
239 error_message=str(exc),
240 )
241 except RuntimeError as exc:
242 return MusehubToolResult(
243 ok=False,
244 error_code="merge_conflict",
245 error_message=str(exc),
246 )
247 except Exception as exc:
248 logger.exception("MCP merge_proposal failed: %s", exc)
249 return MusehubToolResult(
250 ok=False,
251 error_code="merge_conflict",
252 error_message=str(exc),
253 )
254
255
256 async def execute_create_proposal_comment(
257 *,
258 repo_id: str,
259 proposal_id: str,
260 body: str,
261 actor: str = "",
262 target_type: str = "general",
263 target_track: str | None = None,
264 target_beat_start: float | None = None,
265 target_beat_end: float | None = None,
266 symbol_address: str | None = None,
267 parent_comment_id: str | None = None,
268 ) -> MusehubToolResult:
269 """Post a comment on a merge proposal, optionally anchored to a symbol address.
270
271 The caller must be authenticated and have write access to the repository.
272 An empty ``actor`` is rejected immediately with a forbidden error.
273
274 For code-domain proposals, pass ``symbol_address`` to bind the comment to a
275 named symbol in the Symbol Delta (e.g. ``'core/engine.py::Engine.process'``).
276 For non-code domains, use ``target_type``/``target_track``/``target_beat_*``.
277 Omit both for a general proposal-level comment.
278
279 Args:
280 repo_id: sha256 genesis ID of the repository.
281 proposal_id: sha256 genesis ID of the merge proposal.
282 body: Markdown comment body.
283 actor: Authenticated user ID (MSign handle).
284 symbol_address: Symbol address anchor for code-domain proposals.
285 parent_comment_id: Parent comment ID for threaded replies.
286 target_type: One of ``"general"``, ``"track"``, or ``"region"``.
287 target_track: Track name for track/region comments.
288 target_beat_start: Start beat for region comments.
289 target_beat_end: End beat for region comments.
290
291 Returns:
292 ``MusehubToolResult`` with ``data.comment_id`` on success.
293 """
294 if (err := _check_db_available()) is not None:
295 return err
296
297 try:
298 async with AsyncSessionLocal() as session:
299 repo = await musehub_repository.get_repo(session, repo_id)
300 if repo is None:
301 return MusehubToolResult(
302 ok=False,
303 error_code="repo_not_found",
304 error_message=f"Repository '{repo_id}' not found.",
305 )
306 if (err := await _require_public_or_write_access(
307 session, repo_id, actor, repo.owner, repo.visibility
308 )) is not None:
309 return err
310 actor_identity_id = await musehub_repository.get_identity_id_for_handle(session, actor)
311 comment = await musehub_proposals.create_proposal_comment(
312 session,
313 proposal_id=proposal_id,
314 repo_id=repo_id,
315 author=actor,
316 author_identity_id=actor_identity_id,
317 body=body,
318 target_type=target_type,
319 target_track=target_track,
320 target_beat_start=target_beat_start,
321 target_beat_end=target_beat_end,
322 symbol_address=symbol_address,
323 parent_comment_id=parent_comment_id,
324 )
325 await session.commit()
326 data: JSONObject = {
327 "comment_id": comment.comment_id,
328 "proposal_id": proposal_id,
329 "author": comment.author,
330 "body": comment.body,
331 "symbol_address": comment.symbol_address,
332 "target_type": comment.target_type,
333 "target_track": comment.target_track,
334 "target_beat_start": comment.target_beat_start,
335 "target_beat_end": comment.target_beat_end,
336 "created_at": comment.created_at.isoformat() if comment.created_at else None,
337 }
338 return MusehubToolResult(ok=True, data=data)
339 except Exception as exc:
340 logger.exception("MCP create_proposal_comment failed: %s", exc)
341 return MusehubToolResult(
342 ok=False,
343 error_code="invalid_args",
344 error_message=str(exc),
345 )
346
347
348 async def execute_create_proposal_review(
349 *,
350 repo_id: str,
351 proposal_id: str,
352 verdict: str,
353 body: str = "",
354 reviewer: str = "",
355 ) -> MusehubToolResult:
356 """Submit or update a formal review verdict on a merge proposal.
357
358 Resubmitting with a different verdict updates the existing row — you can
359 change approve → request_changes at any time.
360
361 Args:
362 repo_id: sha256 genesis ID of the repository.
363 proposal_id: sha256 genesis ID of the merge proposal.
364 verdict: ``"approve"`` or ``"request_changes"``.
365 body: Optional review body.
366 reviewer: Authenticated user ID (MSign handle).
367
368 Returns:
369 ``MusehubToolResult`` with ``data.state`` on success.
370 """
371 resolved = verdict
372 if (err := _check_db_available()) is not None:
373 return err
374
375 if not reviewer:
376 return MusehubToolResult(
377 ok=False,
378 error_code="forbidden",
379 error_message="Authentication required to submit a proposal review.",
380 hint="Provide a valid MSign Authorization header.",
381 )
382
383 valid_verdicts = {"approve", "request_changes"}
384 if resolved not in valid_verdicts:
385 return MusehubToolResult(
386 ok=False,
387 error_code="invalid_args",
388 error_message=f"verdict must be one of {sorted(valid_verdicts)}.",
389 )
390
391 try:
392 async with AsyncSessionLocal() as session:
393 repo = await musehub_repository.get_repo(session, repo_id)
394 if repo is None:
395 return MusehubToolResult(
396 ok=False,
397 error_code="not_found",
398 error_message=f"Repo {repo_id!r} not found.",
399 )
400 if err := await _require_public_or_write_access(
401 session, repo_id, reviewer, repo.owner, repo.visibility
402 ):
403 return err
404
405 reviewer_identity_id = await musehub_repository.get_identity_id_for_handle(session, reviewer)
406 review = await musehub_proposals.submit_review(
407 session,
408 repo_id=repo_id,
409 proposal_id=proposal_id,
410 reviewer_username=reviewer,
411 reviewer_identity_id=reviewer_identity_id,
412 verdict=resolved,
413 body=body,
414 )
415 await session.commit()
416 data: JSONObject = {
417 "review_id": review.id,
418 "proposal_id": proposal_id,
419 "reviewer": review.reviewer_username,
420 "state": review.state,
421 "body": review.body,
422 "submitted_at": review.submitted_at.isoformat() if review.submitted_at else None,
423 }
424 logger.info("MCP submit_proposal_review %s on %s by %s", resolved, proposal_id, reviewer)
425 return MusehubToolResult(ok=True, data=data)
426 except ValueError as exc:
427 return MusehubToolResult(
428 ok=False,
429 error_code="invalid_args",
430 error_message=str(exc),
431 )
432 except Exception as exc:
433 logger.exception("MCP submit_proposal_review failed: %s", exc)
434 return MusehubToolResult(
435 ok=False,
436 error_code="invalid_args",
437 error_message=str(exc),
438 )
439
440
441 async def execute_list_proposal_comments(
442 *,
443 repo_id: str,
444 proposal_id: str,
445 actor: str = "",
446 ) -> MusehubToolResult:
447 """Return all review comments for a proposal, threaded by parent.
448
449 Authentication is required. Any authenticated user may read proposal comments.
450
451 Args:
452 repo_id: sha256 genesis ID of the repository.
453 proposal_id: sha256 genesis ID of the merge proposal.
454 actor: Authenticated user ID (MSign handle).
455
456 Returns:
457 ``MusehubToolResult`` with ``data.comments`` list and ``data.total`` on success.
458 Each comment entry carries a ``replies`` list for threaded children.
459 """
460 if (err := _check_db_available()) is not None:
461 return err
462
463 if not actor:
464 return MusehubToolResult(
465 ok=False,
466 error_code="forbidden",
467 error_message="Authentication required to list proposal comments.",
468 hint="Provide a valid MSign Authorization header.",
469 )
470
471 try:
472 async with AsyncSessionLocal() as session:
473 repo = await musehub_repository.get_repo(session, repo_id)
474 if repo is None:
475 return MusehubToolResult(
476 ok=False,
477 error_code="repo_not_found",
478 error_message=f"Repository '{repo_id}' not found.",
479 hint="Call musehub_search_repos() to find available repositories.",
480 )
481 response = await musehub_proposals.list_proposal_comments(
482 session,
483 proposal_id=proposal_id,
484 repo_id=repo_id,
485 )
486 return MusehubToolResult(
487 ok=True,
488 data={"comments": json_list([_comment_data(c) for c in response.comments]), "total": response.total},
489 )
490 except ValueError as exc:
491 return MusehubToolResult(ok=False, error_code="proposal_not_found", error_message=str(exc))
492 except Exception as exc:
493 logger.exception("MCP list_proposal_comments failed: %s", exc)
494 return MusehubToolResult(ok=False, error_code="invalid_args", error_message=str(exc))
495
496
497 async def execute_request_proposal_reviewers(
498 *,
499 repo_id: str,
500 proposal_id: str,
501 reviewers: list[str],
502 actor: str = "",
503 ) -> MusehubToolResult:
504 """Request one or more reviewers on a merge proposal.
505
506 The caller must be the repo owner or a write/admin collaborator. Requesting
507 a reviewer who already has a row is idempotent — their existing state is
508 preserved (a submitted approval is never reset).
509
510 Args:
511 repo_id: sha256 genesis ID of the repository.
512 proposal_id: sha256 genesis ID of the merge proposal.
513 reviewers: List of MSign handles to request as reviewers.
514 actor: Authenticated user ID (MSign handle).
515
516 Returns:
517 ``MusehubToolResult`` with ``data.reviews`` list on success.
518 """
519 if (err := _check_db_available()) is not None:
520 return err
521
522 if not reviewers:
523 return MusehubToolResult(
524 ok=False,
525 error_code="invalid_args",
526 error_message="reviewers must be a non-empty list.",
527 )
528
529 try:
530 async with AsyncSessionLocal() as session:
531 repo = await musehub_repository.get_repo(session, repo_id)
532 if repo is None:
533 return MusehubToolResult(
534 ok=False,
535 error_code="repo_not_found",
536 error_message=f"Repository '{repo_id}' not found.",
537 hint="Call musehub_search_repos() to find available repositories.",
538 )
539 if (err := await _require_write_access(session, repo_id, actor, repo.owner)) is not None:
540 return err
541 response = await musehub_proposals.request_reviewers(
542 session,
543 repo_id=repo_id,
544 proposal_id=proposal_id,
545 reviewers=reviewers,
546 )
547 await session.commit()
548 logger.info("MCP request_proposal_reviewers %s on %s by %s", reviewers, proposal_id, actor)
549 return MusehubToolResult(ok=True, data={"reviews": json_list([_review_data(r) for r in response.reviews]), "total": response.total})
550 except ValueError as exc:
551 return MusehubToolResult(ok=False, error_code="proposal_not_found", error_message=str(exc))
552 except Exception as exc:
553 logger.exception("MCP request_proposal_reviewers failed: %s", exc)
554 return MusehubToolResult(ok=False, error_code="invalid_args", error_message=str(exc))
555
556
557 async def execute_remove_proposal_reviewer(
558 *,
559 repo_id: str,
560 proposal_id: str,
561 reviewer: str,
562 actor: str = "",
563 ) -> MusehubToolResult:
564 """Remove a pending review request from a merge proposal.
565
566 Only ``pending`` rows may be removed — submitted reviews are immutable to
567 preserve the audit trail. The caller must be the repo owner or a
568 write/admin collaborator.
569
570 Args:
571 repo_id: sha256 genesis ID of the repository.
572 proposal_id: sha256 genesis ID of the merge proposal.
573 reviewer: MSign handle of the reviewer to remove.
574 actor: Authenticated user ID (MSign handle).
575
576 Returns:
577 ``MusehubToolResult`` with ``data.reviews`` list (remaining reviewers) on success.
578 """
579 if (err := _check_db_available()) is not None:
580 return err
581
582 try:
583 async with AsyncSessionLocal() as session:
584 repo = await musehub_repository.get_repo(session, repo_id)
585 if repo is None:
586 return MusehubToolResult(
587 ok=False,
588 error_code="repo_not_found",
589 error_message=f"Repository '{repo_id}' not found.",
590 hint="Call musehub_search_repos() to find available repositories.",
591 )
592 if (err := await _require_write_access(session, repo_id, actor, repo.owner)) is not None:
593 return err
594 response = await musehub_proposals.remove_reviewer(
595 session,
596 repo_id=repo_id,
597 proposal_id=proposal_id,
598 username=reviewer,
599 )
600 await session.commit()
601 logger.info("MCP remove_proposal_reviewer %s from %s by %s", reviewer, proposal_id, actor)
602 return MusehubToolResult(ok=True, data={"reviews": json_list([_review_data(r) for r in response.reviews]), "total": response.total})
603 except ValueError as exc:
604 return MusehubToolResult(ok=False, error_code="invalid_args", error_message=str(exc))
605 except Exception as exc:
606 logger.exception("MCP remove_proposal_reviewer failed: %s", exc)
607 return MusehubToolResult(ok=False, error_code="invalid_args", error_message=str(exc))
608
609
610 async def execute_list_proposal_reviews(
611 *,
612 repo_id: str,
613 proposal_id: str,
614 state: str | None = None,
615 actor: str = "",
616 ) -> MusehubToolResult:
617 """Return all reviews for a merge proposal, optionally filtered by state.
618
619 Authentication is required. Any authenticated user may read proposal reviews.
620
621 Args:
622 repo_id: sha256 genesis ID of the repository.
623 proposal_id: sha256 genesis ID of the merge proposal.
624 state: Optional filter: ``"pending"``, ``"approved"``, ``"changes_requested"``,
625 or ``"dismissed"``. When omitted, all reviews are returned.
626 actor: Authenticated user ID (MSign handle).
627
628 Returns:
629 ``MusehubToolResult`` with ``data.reviews`` list and ``data.total`` on success.
630 """
631 if (err := _check_db_available()) is not None:
632 return err
633
634 if not actor:
635 return MusehubToolResult(
636 ok=False,
637 error_code="forbidden",
638 error_message="Authentication required to list proposal reviews.",
639 hint="Provide a valid MSign Authorization header.",
640 )
641
642 valid_states = {"pending", "approved", "changes_requested", "dismissed"}
643 if state is not None and state not in valid_states:
644 return MusehubToolResult(
645 ok=False,
646 error_code="invalid_args",
647 error_message=f"state must be one of {sorted(valid_states)} or omitted.",
648 )
649
650 try:
651 async with AsyncSessionLocal() as session:
652 repo = await musehub_repository.get_repo(session, repo_id)
653 if repo is None:
654 return MusehubToolResult(
655 ok=False,
656 error_code="repo_not_found",
657 error_message=f"Repository '{repo_id}' not found.",
658 hint="Call musehub_search_repos() to find available repositories.",
659 )
660 response = await musehub_proposals.list_reviews(
661 session,
662 repo_id=repo_id,
663 proposal_id=proposal_id,
664 state=state,
665 )
666 return MusehubToolResult(ok=True, data={"reviews": json_list([_review_data(r) for r in response.reviews]), "total": response.total})
667 except ValueError as exc:
668 return MusehubToolResult(ok=False, error_code="proposal_not_found", error_message=str(exc))
669 except Exception as exc:
670 logger.exception("MCP list_proposal_reviews failed: %s", exc)
671 return MusehubToolResult(ok=False, error_code="invalid_args", error_message=str(exc))
672
673
674 async def execute_get_proposal(
675 *,
676 repo_id: str,
677 proposal_id: str,
678 actor: str = "",
679 ) -> MusehubToolResult:
680 """Read a single merge proposal with full Phase 5 enrichment.
681
682 Returns the proposal with ``blocked_by``, ``blocks``, ``is_blocked``, and
683 ``latest_simulations`` populated. Authentication required.
684
685 Args:
686 repo_id: sha256 genesis ID of the repository.
687 proposal_id: sha256 genesis ID of the merge proposal.
688 actor: Authenticated user ID (MSign handle).
689
690 Returns:
691 ``MusehubToolResult`` with full enriched proposal data on success.
692 """
693 if (err := _check_db_available()) is not None:
694 return err
695
696 if not actor:
697 return MusehubToolResult(
698 ok=False,
699 error_code="forbidden",
700 error_message="Authentication required to read a proposal.",
701 hint="Provide a valid MSign Authorization header.",
702 )
703
704 try:
705 async with AsyncSessionLocal() as session:
706 repo = await musehub_repository.get_repo(session, repo_id)
707 if repo is None:
708 return MusehubToolResult(
709 ok=False,
710 error_code="repo_not_found",
711 error_message=f"Repository '{repo_id}' not found.",
712 hint="Call musehub_search_repos() to find available repositories.",
713 )
714 proposal = await musehub_proposals.get_proposal(session, repo_id, proposal_id)
715 if proposal is None:
716 return MusehubToolResult(
717 ok=False,
718 error_code="proposal_not_found",
719 error_message=f"Proposal '{proposal_id}' not found in repo '{repo_id}'.",
720 )
721 return MusehubToolResult(ok=True, data=_proposal_data(proposal))
722 except Exception as exc:
723 logger.exception("MCP get_proposal failed: %s", exc)
724 return MusehubToolResult(ok=False, error_code="invalid_args", error_message=str(exc))
725
726
727 async def execute_run_simulation(
728 *,
729 repo_id: str,
730 proposal_id: str,
731 simulation_type: str,
732 actor: str = "",
733 ) -> MusehubToolResult:
734 """Run a simulation for a merge proposal and return the result.
735
736 Always recomputes — bypasses the cache. Stores the result so subsequent
737 ``execute_get_simulation`` calls return it without recomputing.
738
739 Valid ``simulation_type`` values:
740 - ``"conflict_scan"`` — detect file-level conflicts between branches
741 - ``"risk_projection"`` — project merge risk across domains
742 - ``"dependency_order"`` — compute topological order of proposal DAG
743
744 Args:
745 repo_id: sha256 genesis ID of the repository.
746 proposal_id: sha256 genesis ID of the merge proposal.
747 simulation_type: One of 'conflict_scan', 'risk_projection', 'dependency_order'.
748 actor: Authenticated user ID (MSign handle).
749
750 Returns:
751 ``MusehubToolResult`` with simulation result and staleness flag on success.
752 """
753 if (err := _check_db_available()) is not None:
754 return err
755
756 if not actor:
757 return MusehubToolResult(
758 ok=False,
759 error_code="forbidden",
760 error_message="Authentication required to run a simulation.",
761 hint="Provide a valid MSign Authorization header.",
762 )
763
764 valid_types = {"conflict_scan", "risk_projection", "dependency_order"}
765 if simulation_type not in valid_types:
766 return MusehubToolResult(
767 ok=False,
768 error_code="invalid_args",
769 error_message=f"simulation_type must be one of {sorted(valid_types)}.",
770 )
771
772 try:
773 async with AsyncSessionLocal() as session:
774 repo = await musehub_repository.get_repo(session, repo_id)
775 if repo is None:
776 return MusehubToolResult(
777 ok=False,
778 error_code="repo_not_found",
779 error_message=f"Repository '{repo_id}' not found.",
780 hint="Call musehub_search_repos() to find available repositories.",
781 )
782 result = await musehub_proposals.run_simulation(
783 session, repo_id, proposal_id, simulation_type
784 )
785 await session.commit()
786 logger.info("MCP run_simulation %s on %s in %s", simulation_type, proposal_id, repo_id)
787 return MusehubToolResult(ok=True, data=_simulation_data(result))
788 except ValueError as exc:
789 return MusehubToolResult(ok=False, error_code="proposal_not_found", error_message=str(exc))
790 except Exception as exc:
791 logger.exception("MCP run_simulation failed: %s", exc)
792 return MusehubToolResult(ok=False, error_code="invalid_args", error_message=str(exc))
793
794
795 async def execute_get_simulation(
796 *,
797 repo_id: str,
798 proposal_id: str,
799 simulation_type: str,
800 actor: str = "",
801 ) -> MusehubToolResult:
802 """Read the cached simulation result for a merge proposal.
803
804 Returns the stored result without recomputing. Check ``is_stale`` in the
805 response — when ``True`` the from-branch has advanced and the result may be
806 outdated. Call ``execute_run_simulation`` to refresh.
807
808 Returns ``error_code="not_found"`` when no simulation has been run yet.
809
810 Args:
811 repo_id: sha256 genesis ID of the repository.
812 proposal_id: sha256 genesis ID of the merge proposal.
813 simulation_type: One of 'conflict_scan', 'risk_projection', 'dependency_order'.
814 actor: Authenticated user ID (MSign handle).
815
816 Returns:
817 ``MusehubToolResult`` with cached simulation data and ``is_stale`` flag on success.
818 """
819 if (err := _check_db_available()) is not None:
820 return err
821
822 if not actor:
823 return MusehubToolResult(
824 ok=False,
825 error_code="forbidden",
826 error_message="Authentication required to read a simulation.",
827 hint="Provide a valid MSign Authorization header.",
828 )
829
830 valid_types = {"conflict_scan", "risk_projection", "dependency_order"}
831 if simulation_type not in valid_types:
832 return MusehubToolResult(
833 ok=False,
834 error_code="invalid_args",
835 error_message=f"simulation_type must be one of {sorted(valid_types)}.",
836 )
837
838 try:
839 async with AsyncSessionLocal() as session:
840 repo = await musehub_repository.get_repo(session, repo_id)
841 if repo is None:
842 return MusehubToolResult(
843 ok=False,
844 error_code="repo_not_found",
845 error_message=f"Repository '{repo_id}' not found.",
846 hint="Call musehub_search_repos() to find available repositories.",
847 )
848 result = await musehub_proposals.get_simulation(
849 session, repo_id, proposal_id, simulation_type
850 )
851 if result is None:
852 return MusehubToolResult(
853 ok=False,
854 error_code="not_found",
855 error_message=f"No '{simulation_type}' simulation found for proposal '{proposal_id}'. "
856 "Call musehub_run_proposal_simulation to compute one.",
857 )
858 return MusehubToolResult(ok=True, data=_simulation_data(result))
859 except Exception as exc:
860 logger.exception("MCP get_simulation failed: %s", exc)
861 return MusehubToolResult(ok=False, error_code="invalid_args", error_message=str(exc))
862
863
864 async def execute_list_simulations(
865 *,
866 repo_id: str,
867 proposal_id: str,
868 actor: str = "",
869 ) -> MusehubToolResult:
870 """List all cached simulations for a merge proposal.
871
872 Returns up to three entries (one per simulation type). Each entry includes
873 ``is_stale`` so you can identify which simulations need refreshing.
874
875 Args:
876 repo_id: sha256 genesis ID of the repository.
877 proposal_id: sha256 genesis ID of the merge proposal.
878 actor: Authenticated user ID (MSign handle).
879
880 Returns:
881 ``MusehubToolResult`` with ``data.simulations`` list and ``data.total`` on success.
882 """
883 if (err := _check_db_available()) is not None:
884 return err
885
886 if not actor:
887 return MusehubToolResult(
888 ok=False,
889 error_code="forbidden",
890 error_message="Authentication required to list simulations.",
891 hint="Provide a valid MSign Authorization header.",
892 )
893
894 try:
895 async with AsyncSessionLocal() as session:
896 repo = await musehub_repository.get_repo(session, repo_id)
897 if repo is None:
898 return MusehubToolResult(
899 ok=False,
900 error_code="repo_not_found",
901 error_message=f"Repository '{repo_id}' not found.",
902 hint="Call musehub_search_repos() to find available repositories.",
903 )
904 response = await musehub_proposals.list_simulations(session, repo_id, proposal_id)
905 return MusehubToolResult(
906 ok=True,
907 data={
908 "simulations": json_list([_simulation_data(s) for s in response.simulations]),
909 "total": response.total,
910 },
911 )
912 except ValueError as exc:
913 return MusehubToolResult(ok=False, error_code="proposal_not_found", error_message=str(exc))
914 except Exception as exc:
915 logger.exception("MCP list_simulations failed: %s", exc)
916 return MusehubToolResult(ok=False, error_code="invalid_args", error_message=str(exc))
File History 2 commits
sha256:50b52eda7afb2f122863aef47d684d1a9e4684b48f5f95367fc956e28ceb7d42 refactor: rename merge strategy aliases to canonical names Sonnet 4.6 minor 8 days ago
sha256:af9422a68cbd2db7c88f664388e11134b0ae0057ee5ad14465d82208548a9d7d changing --event to --verdict. displaying changes requested… Human minor 10 days ago