gabriel / musehub public
musehub_issues.py python
1,182 lines 40.6 KB Cold
Raw
sha256:4d42a346263e7cbbd152c147f3e6f24576f4b4440df9249ffb9fbcf9db699fcb feat: populate url in create_issue and create_proposal responses Sonnet 4.6 minor ⚠ breaking 93 days ago
1 """MuseHub issue persistence adapter — single point of DB access for issues.
2
3 This module is the ONLY place that touches the ``musehub_issues``,
4 ``musehub_issue_comments``, and ``musehub_issue_events`` tables.
5 Route handlers delegate here; no business logic lives in routes.
6
7 Boundary rules:
8 - Must NOT import state stores, SSE queues, or LLM clients.
9 - May import ORM models from musehub.db domain-specific modules.
10 - May import Pydantic response models from musehub.models.musehub.
11 - May import musehub.core.genesis for genesis ID computation.
12 """
13
14 import logging
15 from datetime import datetime, timezone
16
17 from sqlalchemy import any_, func, literal, select, text, union_all
18 from sqlalchemy.ext.asyncio import AsyncSession
19
20 from typing import TypedDict
21
22 from musehub.core.genesis import compute_comment_id, compute_issue_event_id, compute_issue_id
23 from musehub.db.musehub_release_models import MusehubRelease
24 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef
25 from musehub.db.musehub_social_models import MusehubIssue, MusehubIssueComment, MusehubIssueEvent, MusehubProposal
26 from musehub.types.json_types import JSONObject
27
28
29 class _ProposalMatch(TypedDict):
30 proposal_id: str
31 proposal_number: int
32 title: str
33 state: str
34 from_branch: str
35 to_branch: str
36 match_reason: str
37
38
39 type _SeenMap = dict[str, _ProposalMatch]
40 type _CommitMap = dict[str, MusehubCommit]
41
42
43 class IssueReleaseContext(TypedDict):
44 no_commits: bool
45 anchor_commits: list[JSONObject]
46 all_landed: bool
47 any_landed: bool
48 pending_count: int
49 next_tag: str | None
50 latest_release_tag: str | None
51 from musehub.models.musehub import (
52 IssueCommentListResponse,
53 IssueCommentResponse,
54 IssueListResponse,
55 IssueResponse,
56 )
57
58 logger = logging.getLogger(__name__)
59
60
61 def _to_issue_response(row: MusehubIssue, comment_count: int = 0, *, url_prefix: str = "") -> IssueResponse:
62 """Convert a DB row to an IssueResponse wire model.
63
64 ``comment_count`` must be passed in by the caller — it requires a separate
65 aggregate query so we avoid N+1 loads on list endpoints.
66
67 ``url_prefix`` should be ``"{base_url}/{owner}/{repo_slug}"`` — the canonical
68 URL for the issue is then ``{url_prefix}/issues/{number}``.
69 """
70 return IssueResponse(
71 issue_id=row.issue_id,
72 number=row.number,
73 url=f"{url_prefix}/issues/{row.number}" if url_prefix else "",
74 title=row.title,
75 body=row.body,
76 state=row.state,
77 labels=list(row.labels or []),
78 symbol_anchors=list(row.symbol_anchors or []),
79 commit_anchors=list(row.commit_anchors or []),
80 author=row.author,
81 assignee=row.assignee,
82 agent_id=row.agent_id or "",
83 model_id=row.model_id or "",
84 created_at=row.created_at,
85 updated_at=row.updated_at,
86 comment_count=comment_count,
87 )
88
89
90 def _to_comment_response(row: MusehubIssueComment) -> IssueCommentResponse:
91 """Convert a DB comment row to the wire representation."""
92 return IssueCommentResponse(
93 comment_id=row.comment_id,
94 issue_id=row.issue_id,
95 author=row.author,
96 body=row.body,
97 parent_id=row.parent_id,
98 is_deleted=row.is_deleted,
99 created_at=row.created_at,
100 updated_at=row.updated_at,
101 )
102
103
104 async def _next_issue_number(session: AsyncSession, repo_id: str) -> int:
105 """Return the next sequential issue number for the given repo (1-based).
106
107 Locks the repo row (``SELECT ... FOR UPDATE``) for the remainder of the
108 caller's transaction before computing the max, serializing concurrent
109 number allocation for the same repo. Without this, concurrent creates
110 race on SELECT MAX(number) + INSERT max+1 with no synchronisation --
111 verified directly (musehub#184): 10 concurrent `create_issue` calls
112 against the same repo produced genuine duplicate numbers (two rows at
113 one number, three rows at another; nothing was lost, just duplicated).
114 A UNIQUE constraint on (repo_id, number) also exists as a backstop
115 (migration 0076) in case any other code path allocates numbers without
116 going through this lock.
117 """
118 await session.execute(
119 text("SELECT 1 FROM musehub_repos WHERE repo_id = :repo_id FOR UPDATE"),
120 {"repo_id": repo_id},
121 )
122 stmt = select(func.max(MusehubIssue.number)).where(
123 MusehubIssue.repo_id == repo_id
124 )
125 current_max: int | None = (await session.execute(stmt)).scalar_one_or_none()
126 return (current_max or 0) + 1
127
128
129 async def _count_comments(session: AsyncSession, issue_id: str) -> int:
130 """Return the non-deleted comment count for a single issue."""
131 stmt = select(func.count(MusehubIssueComment.comment_id)).where(
132 MusehubIssueComment.issue_id == issue_id,
133 MusehubIssueComment.is_deleted.is_(False),
134 )
135 count: int = (await session.execute(stmt)).scalar_one()
136 return count
137
138
139 async def create_issue(
140 session: AsyncSession,
141 *,
142 repo_id: str,
143 title: str,
144 body: str,
145 labels: list[str],
146 author: str = "",
147 author_identity_id: str = "",
148 symbol_anchors: list[str] | None = None,
149 commit_anchors: list[str] | None = None,
150 agent_id: str = "",
151 model_id: str = "",
152 url_prefix: str = "",
153 ) -> IssueResponse:
154 """Persist a new issue in ``open`` state and return its wire representation.
155
156 ``author`` is the MSign handle for display; ``author_identity_id`` is the
157 genesis-addressed identity ID used to compute the canonical ``issue_id``.
158 Automatically emits an ``opened`` event for the activity timeline.
159 """
160 number = await _next_issue_number(session, repo_id)
161 created_at = datetime.now(timezone.utc)
162 issue_id = compute_issue_id(repo_id, author_identity_id, created_at.isoformat())
163 issue = MusehubIssue(
164 issue_id=issue_id,
165 repo_id=repo_id,
166 number=number,
167 title=title,
168 body=body,
169 state="open",
170 labels=labels,
171 symbol_anchors=symbol_anchors or [],
172 commit_anchors=commit_anchors or [],
173 author=author,
174 agent_id=agent_id,
175 model_id=model_id,
176 created_at=created_at,
177 )
178 session.add(issue)
179 await session.flush()
180 await session.refresh(issue)
181 # Emit the "opened" event for the activity timeline.
182 session.add(MusehubIssueEvent(
183 event_id=compute_issue_event_id(issue.issue_id, "opened", author, issue.created_at.isoformat()),
184 issue_id=issue.issue_id,
185 repo_id=repo_id,
186 actor=author,
187 event_type="opened",
188 payload={},
189 created_at=issue.created_at,
190 ))
191 await session.flush()
192 logger.info("✅ Created issue #%d for repo %s: %s", number, repo_id, title)
193 return _to_issue_response(issue, url_prefix=url_prefix)
194
195
196 async def list_issues(
197 session: AsyncSession,
198 repo_id: str,
199 *,
200 state: str = "open",
201 label: str | None = None,
202 cursor: str | None = None,
203 limit: int = 20,
204 url_prefix: str = "",
205 ) -> IssueListResponse:
206 """Return issues for a repo with cursor-based keyset pagination.
207
208 ``state`` may be ``"open"``, ``"closed"``, or ``"all"``.
209 ``label`` filters to issues whose labels list contains the given string.
210 Results are ordered by issue number ascending.
211
212 ``cursor`` is the last seen issue number (opaque to callers — pass
213 ``nextCursor`` from a previous response verbatim). Omit to start from
214 the beginning. ``total`` reflects all matching issues regardless of
215 the current page so UIs can show "showing N of M" without paginating
216 through everything.
217 """
218 conditions = [MusehubIssue.repo_id == repo_id]
219 if state != "all":
220 conditions.append(MusehubIssue.state == state)
221 if label is not None:
222 conditions.append(literal(label) == any_(MusehubIssue.labels))
223
224 count_stmt = select(func.count(MusehubIssue.issue_id)).where(*conditions)
225 total: int = (await session.execute(count_stmt)).scalar_one()
226
227 data_conditions = list(conditions)
228 if cursor is not None:
229 data_conditions.append(MusehubIssue.number > int(cursor))
230
231 rows = list(
232 (
233 await session.execute(
234 select(MusehubIssue)
235 .where(*data_conditions)
236 .order_by(MusehubIssue.number)
237 .limit(limit + 1)
238 )
239 ).scalars()
240 )
241
242 next_cursor: str | None = None
243 if len(rows) == limit + 1:
244 next_cursor = str(rows[limit - 1].number)
245 rows = rows[:limit]
246
247 results: list[IssueResponse] = []
248 for r in rows:
249 comment_count = await _count_comments(session, r.issue_id)
250 results.append(_to_issue_response(r, comment_count, url_prefix=url_prefix))
251
252 return IssueListResponse(issues=results, total=total, next_cursor=next_cursor)
253
254
255 async def get_issue(
256 session: AsyncSession,
257 repo_id: str,
258 issue_number: int,
259 *,
260 url_prefix: str = "",
261 ) -> IssueResponse | None:
262 """Return a single issue by its per-repo number, or None if not found."""
263 stmt = (
264 select(MusehubIssue)
265 .where(
266 MusehubIssue.repo_id == repo_id,
267 MusehubIssue.number == issue_number,
268 )
269 )
270 row = (await session.execute(stmt)).scalar_one_or_none()
271 if row is None:
272 return None
273 count = await _count_comments(session, row.issue_id)
274 return _to_issue_response(row, count, url_prefix=url_prefix)
275
276
277 async def close_issue(
278 session: AsyncSession,
279 repo_id: str,
280 issue_number: int,
281 actor: str = "",
282 ) -> IssueResponse | None:
283 """Set the issue state to ``closed``. Returns None if the issue does not exist.
284
285 Idempotent: if the issue is already closed no event is emitted and no
286 webhook is fired — the current state is returned unchanged. This prevents
287 duplicate timeline entries and spurious notifications when the caller
288 retries or the issue was already closed by another actor.
289 """
290 stmt = (
291 select(MusehubIssue)
292 .where(
293 MusehubIssue.repo_id == repo_id,
294 MusehubIssue.number == issue_number,
295 )
296 )
297 row = (await session.execute(stmt)).scalar_one_or_none()
298 if row is None:
299 return None
300 count = await _count_comments(session, row.issue_id)
301 if row.state == "closed":
302 return _to_issue_response(row, count)
303 row.state = "closed"
304 await session.flush()
305 await session.refresh(row)
306 _closed_at = row.updated_at or _utc_now()
307 session.add(MusehubIssueEvent(
308 event_id=compute_issue_event_id(row.issue_id, "closed", actor, _closed_at.isoformat()),
309 issue_id=row.issue_id,
310 repo_id=repo_id,
311 actor=actor,
312 event_type="closed",
313 payload={},
314 created_at=_closed_at,
315 ))
316 await session.flush()
317 logger.info("✅ Closed issue #%d for repo %s", issue_number, repo_id)
318 return _to_issue_response(row, count)
319
320
321 async def reopen_issue(
322 session: AsyncSession,
323 repo_id: str,
324 issue_number: int,
325 actor: str = "",
326 ) -> IssueResponse | None:
327 """Set the issue state back to ``open``. Returns None if the issue does not exist.
328
329 Idempotent: if the issue is already open no event is emitted and no
330 webhook is fired — the current state is returned unchanged. This prevents
331 duplicate timeline entries and spurious notifications when the caller
332 retries or the issue was already reopened by another actor.
333 """
334 stmt = (
335 select(MusehubIssue)
336 .where(
337 MusehubIssue.repo_id == repo_id,
338 MusehubIssue.number == issue_number,
339 )
340 )
341 row = (await session.execute(stmt)).scalar_one_or_none()
342 if row is None:
343 return None
344 count = await _count_comments(session, row.issue_id)
345 if row.state == "open":
346 return _to_issue_response(row, count)
347 row.state = "open"
348 await session.flush()
349 await session.refresh(row)
350 _reopened_at = row.updated_at or _utc_now()
351 session.add(MusehubIssueEvent(
352 event_id=compute_issue_event_id(row.issue_id, "reopened", actor, _reopened_at.isoformat()),
353 issue_id=row.issue_id,
354 repo_id=repo_id,
355 actor=actor,
356 event_type="reopened",
357 payload={},
358 created_at=_reopened_at,
359 ))
360 await session.flush()
361 logger.info("✅ Reopened issue #%d for repo %s", issue_number, repo_id)
362 return _to_issue_response(row, count)
363
364
365 async def update_issue(
366 session: AsyncSession,
367 repo_id: str,
368 issue_number: int,
369 *,
370 title: str | None = None,
371 body: str | None = None,
372 labels: list[str] | None = None,
373 symbol_anchors: list[str] | None = None,
374 commit_anchors: list[str] | None = None,
375 agent_id: str | None = None,
376 model_id: str | None = None,
377 ) -> IssueResponse | None:
378 """Partially update an issue's fields.
379
380 Only non-None arguments are applied. Returns None if the issue is not found.
381 """
382 stmt = (
383 select(MusehubIssue)
384 .where(
385 MusehubIssue.repo_id == repo_id,
386 MusehubIssue.number == issue_number,
387 )
388 )
389 row = (await session.execute(stmt)).scalar_one_or_none()
390 if row is None:
391 return None
392 if title is not None:
393 row.title = title
394 if body is not None:
395 row.body = body
396 if labels is not None:
397 row.labels = labels
398 if symbol_anchors is not None:
399 row.symbol_anchors = symbol_anchors
400 if commit_anchors is not None:
401 row.commit_anchors = commit_anchors
402 if agent_id is not None:
403 row.agent_id = agent_id
404 if model_id is not None:
405 row.model_id = model_id
406 await session.flush()
407 await session.refresh(row)
408 count = await _count_comments(session, row.issue_id)
409 return _to_issue_response(row, count)
410
411
412 async def assign_issue(
413 session: AsyncSession,
414 repo_id: str,
415 issue_number: int,
416 *,
417 assignee: str | None,
418 ) -> IssueResponse | None:
419 """Set or clear the assignee on an issue.
420
421 Pass ``assignee=None`` to unassign. Returns None if the issue is not found.
422 """
423 stmt = (
424 select(MusehubIssue)
425 .where(
426 MusehubIssue.repo_id == repo_id,
427 MusehubIssue.number == issue_number,
428 )
429 )
430 row = (await session.execute(stmt)).scalar_one_or_none()
431 if row is None:
432 return None
433 row.assignee = assignee
434 await session.flush()
435 await session.refresh(row)
436 logger.info(
437 "✅ %s issue #%d for repo %s",
438 f"Assigned {assignee} to" if assignee else "Unassigned",
439 issue_number,
440 repo_id,
441 )
442 count = await _count_comments(session, row.issue_id)
443 return _to_issue_response(row, count)
444
445
446 async def assign_labels(
447 session: AsyncSession,
448 repo_id: str,
449 issue_number: int,
450 *,
451 labels: list[str],
452 ) -> IssueResponse | None:
453 """Replace the label list on an issue with the provided labels.
454
455 Returns None if the issue is not found.
456 The replacement is total — callers must merge old and new labels themselves
457 when they only want to append.
458 """
459 stmt = (
460 select(MusehubIssue)
461 .where(
462 MusehubIssue.repo_id == repo_id,
463 MusehubIssue.number == issue_number,
464 )
465 )
466 row = (await session.execute(stmt)).scalar_one_or_none()
467 if row is None:
468 return None
469 row.labels = labels
470 await session.flush()
471 await session.refresh(row)
472 logger.info("✅ Assigned labels %r to issue #%d for repo %s", labels, issue_number, repo_id)
473 count = await _count_comments(session, row.issue_id)
474 return _to_issue_response(row, count)
475
476
477 async def remove_label(
478 session: AsyncSession,
479 repo_id: str,
480 issue_number: int,
481 *,
482 label: str,
483 ) -> IssueResponse | None:
484 """Remove a single label from an issue's label list.
485
486 Silently no-ops when the label is not present (idempotent).
487 Returns None if the issue is not found.
488 """
489 stmt = (
490 select(MusehubIssue)
491 .where(
492 MusehubIssue.repo_id == repo_id,
493 MusehubIssue.number == issue_number,
494 )
495 )
496 row = (await session.execute(stmt)).scalar_one_or_none()
497 if row is None:
498 return None
499 current: list[str] = list(row.labels or [])
500 row.labels = [lbl for lbl in current if lbl != label]
501 await session.flush()
502 await session.refresh(row)
503 logger.info("✅ Removed label %r from issue #%d for repo %s", label, issue_number, repo_id)
504 count = await _count_comments(session, row.issue_id)
505 return _to_issue_response(row, count)
506
507
508 # ── Issue comment operations ───────────────────────────────────────────────────
509
510
511 async def create_comment(
512 session: AsyncSession,
513 *,
514 issue_id: str,
515 repo_id: str,
516 body: str,
517 author: str,
518 author_identity_id: str = "",
519 parent_id: str | None = None,
520 ) -> IssueCommentResponse:
521 """Create a new comment on an issue."""
522 if parent_id is not None:
523 parent_stmt = select(MusehubIssueComment).where(
524 MusehubIssueComment.comment_id == parent_id,
525 MusehubIssueComment.issue_id == issue_id,
526 )
527 parent_row = (await session.execute(parent_stmt)).scalar_one_or_none()
528 if parent_row is None:
529 raise ValueError(f"Parent comment {parent_id!r} not found on issue {issue_id!r}")
530
531 created_at = datetime.now(timezone.utc)
532 comment_id = compute_comment_id(issue_id, author_identity_id, created_at.isoformat())
533 comment = MusehubIssueComment(
534 comment_id=comment_id,
535 issue_id=issue_id,
536 repo_id=repo_id,
537 author=author,
538 body=body,
539 parent_id=parent_id,
540 created_at=created_at,
541 )
542 session.add(comment)
543 await session.flush()
544 await session.refresh(comment)
545 logger.info("✅ Created comment %s on issue %s by %s", comment.comment_id, issue_id, author)
546 return _to_comment_response(comment)
547
548
549 async def list_comments(
550 session: AsyncSession,
551 issue_id: str,
552 *,
553 include_deleted: bool = False,
554 cursor: str | None = None,
555 limit: int = 20,
556 ) -> IssueCommentListResponse:
557 """Return comments on an issue with cursor-based keyset pagination.
558
559 Comments are returned in chronological order (oldest first).
560 Deleted comments are omitted by default; pass ``include_deleted=True``
561 to include them (e.g. for moderation views).
562
563 ``cursor`` is the ISO 8601 ``created_at`` of the last seen comment
564 (opaque to callers — pass ``nextCursor`` from a previous response
565 verbatim). Omit to start from the beginning.
566 """
567 conditions = [MusehubIssueComment.issue_id == issue_id]
568 if not include_deleted:
569 conditions.append(MusehubIssueComment.is_deleted.is_(False))
570
571 count_stmt = select(func.count(MusehubIssueComment.comment_id)).where(*conditions)
572 total: int = (await session.execute(count_stmt)).scalar_one()
573
574 data_conditions = list(conditions)
575 if cursor is not None:
576 data_conditions.append(
577 MusehubIssueComment.created_at > datetime.fromisoformat(cursor)
578 )
579
580 rows = list(
581 (
582 await session.execute(
583 select(MusehubIssueComment)
584 .where(*data_conditions)
585 .order_by(MusehubIssueComment.created_at)
586 .limit(limit + 1)
587 )
588 ).scalars()
589 )
590
591 next_cursor: str | None = None
592 if len(rows) == limit + 1:
593 next_cursor = rows[limit - 1].created_at.isoformat()
594 rows = rows[:limit]
595
596 comments = [_to_comment_response(r) for r in rows]
597 return IssueCommentListResponse(comments=comments, total=total, next_cursor=next_cursor)
598
599
600 async def get_timeline(
601 session: AsyncSession,
602 issue_id: str,
603 ) -> list[JSONObject]:
604 """Return a unified chronological activity timeline for an issue.
605
606 Unions ``musehub_issue_events`` (typed events: opened, closed, labeled, …)
607 with ``musehub_issue_comments`` (backwards-compatible comment rows), sorted
608 by ``created_at`` ascending. Each entry carries a ``kind`` field so the
609 template can render the appropriate icon and layout.
610
611 ``kind`` values: ``"event"`` | ``"comment"``
612 """
613 events_stmt = (
614 select(MusehubIssueEvent)
615 .where(MusehubIssueEvent.issue_id == issue_id)
616 .order_by(MusehubIssueEvent.created_at)
617 )
618 comments_stmt = (
619 select(MusehubIssueComment)
620 .where(
621 MusehubIssueComment.issue_id == issue_id,
622 MusehubIssueComment.is_deleted.is_(False),
623 )
624 .order_by(MusehubIssueComment.created_at)
625 )
626
627 events = (await session.execute(events_stmt)).scalars().all()
628 comments = (await session.execute(comments_stmt)).scalars().all()
629
630 timeline: list[JSONObject] = []
631 for ev in events:
632 timeline.append({
633 "kind": "event",
634 "event_id": ev.event_id,
635 "event_type": ev.event_type,
636 "actor": ev.actor,
637 "payload": ev.payload or {},
638 "created_at": ev.created_at.isoformat(),
639 })
640 for c in comments:
641 timeline.append({
642 "kind": "comment",
643 "comment_id": c.comment_id,
644 "author": c.author,
645 "body": c.body,
646 "parent_id": c.parent_id,
647 "created_at": c.created_at.isoformat(),
648 })
649
650 timeline.sort(key=lambda x: str(x.get("created_at") or ""))
651 return timeline
652
653
654 async def delete_comment(
655 session: AsyncSession,
656 comment_id: str,
657 issue_id: str,
658 ) -> bool:
659 """Soft-delete a comment. Returns True if the comment existed and was deleted."""
660 stmt = select(MusehubIssueComment).where(
661 MusehubIssueComment.comment_id == comment_id,
662 MusehubIssueComment.issue_id == issue_id,
663 )
664 row = (await session.execute(stmt)).scalar_one_or_none()
665 if row is None:
666 return False
667 row.is_deleted = True
668 await session.flush()
669 logger.info("✅ Soft-deleted comment %s", comment_id)
670 return True
671
672
673
674
675 # ── Signal 1: commit graph containment — Muse-native proposal linking ─────────
676
677
678 async def find_proposals_by_commit_graph(
679 session: AsyncSession,
680 repo_id: str,
681 commit_anchors: list[str],
682 max_depth: int = 20,
683 ) -> list[_ProposalMatch]:
684 """Find proposals linked to an issue via the VCS commit graph.
685
686 Signal 1: commit graph containment.
687
688 **Merged proposals**: BFS ancestor walk from ``merge_commit_id`` through
689 ``parent_ids`` (JSON array on each commit row). If any ancestor commit ID
690 matches a commit anchor on the issue, the proposal is linked.
691
692 **Open proposals**: Any commit stored under ``from_branch`` whose ID matches
693 a commit anchor indicates the fix is in flight and not yet merged.
694
695 Short-form anchors (fewer than 64 hex chars) are resolved via prefix match
696 so that ``8b624581`` matches ``8b624581c3f2...`` in the DB.
697
698 Returns a list of proposal dicts, each containing:
699 proposal_id, proposal_number, title, state,
700 from_branch, to_branch, match_reason="commit_graph"
701 """
702 if not commit_anchors:
703 return []
704
705 # ── Step 1: resolve short anchors to full commit IDs ──────────────────────
706 where_clauses: list[str] = []
707 resolve_params: JSONObject = {"repo_id": repo_id}
708 for i, anchor in enumerate(commit_anchors):
709 key = f"anc_{i}"
710 if len(anchor) < 64:
711 where_clauses.append(f"c.commit_id LIKE :{key} || '%'")
712 else:
713 where_clauses.append(f"c.commit_id = :{key}")
714 resolve_params[key] = anchor
715
716 resolve_sql = text(
717 f"SELECT c.commit_id FROM musehub_commits c"
718 f" JOIN musehub_commit_refs r ON r.commit_id = c.commit_id"
719 f" WHERE r.repo_id = :repo_id AND ({' OR '.join(where_clauses)})"
720 )
721 resolved_rows = (await session.execute(resolve_sql, resolve_params)).fetchall()
722 if not resolved_rows:
723 return []
724 resolved_ids: list[str] = [row[0] for row in resolved_rows]
725
726 seen: _SeenMap = {}
727
728 # ── Step 2: merged proposals — recursive ancestor walk ────────────────────
729 #
730 # The CTE starts from the parents of each merge_commit_id and walks up the
731 # ancestor graph up to max_depth hops, tracking which proposal each walk
732 # originated from. ``COALESCE(..., ARRAY[]::text[])`` guards against NULL
733 # when the merge commit is not stored in musehub_commits yet.
734 merged_sql = text("""
735 WITH RECURSIVE ancestors(proposal_id, commit_id, depth) AS (
736 SELECT
737 p.proposal_id,
738 elem.v AS commit_id,
739 1 AS depth
740 FROM musehub_proposals p
741 CROSS JOIN LATERAL unnest(
742 COALESCE(
743 (SELECT mc.parent_ids
744 FROM musehub_commits mc
745 WHERE mc.commit_id = p.merge_commit_id
746 LIMIT 1),
747 ARRAY[]::text[]
748 )
749 ) AS elem(v)
750 WHERE p.repo_id = :repo_id
751 AND p.state = 'merged'
752 AND p.merge_commit_id IS NOT NULL
753
754 UNION ALL
755
756 SELECT
757 a.proposal_id,
758 elem.v AS commit_id,
759 a.depth + 1
760 FROM ancestors a
761 JOIN musehub_commits mc
762 ON mc.commit_id = a.commit_id
763 CROSS JOIN LATERAL unnest(mc.parent_ids) AS elem(v)
764 WHERE a.depth < :max_depth
765 )
766 SELECT DISTINCT
767 p.proposal_id,
768 p.proposal_number,
769 p.title,
770 p.state,
771 p.from_branch,
772 p.to_branch
773 FROM ancestors anc
774 JOIN musehub_proposals p ON p.proposal_id = anc.proposal_id
775 WHERE anc.commit_id = ANY(:resolved_ids)
776 LIMIT 10
777 """)
778 merged_rows = (await session.execute(merged_sql, {
779 "repo_id": repo_id,
780 "max_depth": max_depth,
781 "resolved_ids": resolved_ids,
782 })).fetchall()
783 for row in merged_rows:
784 pid = str(row[0])
785 seen[pid] = {
786 "proposal_id": pid,
787 "proposal_number": row[1],
788 "title": row[2],
789 "state": row[3],
790 "from_branch": row[4],
791 "to_branch": row[5],
792 "match_reason": "commit_graph",
793 }
794
795 # ── Step 3: open proposals — branch membership check ──────────────────────
796 #
797 # If a commit anchor lives on the proposal's from_branch, the fix is in
798 # flight and the proposal is linked even though it hasn't merged yet.
799 open_sql = text("""
800 SELECT DISTINCT
801 p.proposal_id,
802 p.proposal_number,
803 p.title,
804 p.state,
805 p.from_branch,
806 p.to_branch
807 FROM musehub_proposals p
808 JOIN musehub_commit_refs cr
809 ON cr.repo_id = :repo_id
810 AND cr.commit_id = ANY(:resolved_ids)
811 JOIN musehub_commits mc
812 ON mc.commit_id = cr.commit_id
813 AND mc.branch = p.from_branch
814 WHERE p.repo_id = :repo_id
815 AND p.state = 'open'
816 LIMIT 10
817 """)
818 open_rows = (await session.execute(open_sql, {
819 "repo_id": repo_id,
820 "resolved_ids": resolved_ids,
821 })).fetchall()
822 for row in open_rows:
823 pid = str(row[0])
824 if pid not in seen:
825 seen[pid] = {
826 "proposal_id": pid,
827 "proposal_number": row[1],
828 "title": row[2],
829 "state": row[3],
830 "from_branch": row[4],
831 "to_branch": row[5],
832 "match_reason": "commit_graph",
833 }
834
835 return list(seen.values())[:10]
836
837
838 # ── Signal 3: branch-to-issue commit reachability ─────────────────────────────
839
840
841 async def find_proposals_by_branch_reachability(
842 session: AsyncSession,
843 repo_id: str,
844 commit_anchors: list[str],
845 max_depth: int = 20,
846 ) -> list[_ProposalMatch]:
847 """Find open proposals whose from_branch contains anchor commits not yet in to_branch.
848
849 Signal 3: branch-to-issue commit reachability.
850
851 A proposal's ``from_branch`` and ``to_branch`` define a merge path. We walk
852 the commit graph reachable from the ``from_branch`` HEAD and subtract all
853 commits reachable from the ``to_branch`` HEAD. The remaining "exclusive" set
854 represents work in flight — not yet integrated. If any of the issue's commit
855 anchors appears in that exclusive set, the proposal is actively working toward
856 resolving this issue.
857
858 Only open proposals are checked: merged proposals have their ``from_branch``
859 deleted at merge time, so the branch HEAD pointer no longer exists.
860
861 Short-form anchors (< 64 chars) are resolved to full commit IDs via prefix
862 match before the graph walk.
863
864 Returns a list of proposal dicts with ``match_reason="branch_reachability"``.
865 """
866 if not commit_anchors:
867 return []
868
869 # ── Step 1: resolve short anchors to full commit IDs ──────────────────────
870 where_clauses: list[str] = []
871 resolve_params: JSONObject = {"repo_id": repo_id}
872 for i, anchor in enumerate(commit_anchors):
873 key = f"anc_{i}"
874 if len(anchor) < 64:
875 where_clauses.append(f"c.commit_id LIKE :{key} || '%'")
876 else:
877 where_clauses.append(f"c.commit_id = :{key}")
878 resolve_params[key] = anchor
879
880 resolve_sql = text(
881 f"SELECT c.commit_id FROM musehub_commits c"
882 f" JOIN musehub_commit_refs r ON r.commit_id = c.commit_id"
883 f" WHERE r.repo_id = :repo_id AND ({' OR '.join(where_clauses)})"
884 )
885 resolved_rows = (await session.execute(resolve_sql, resolve_params)).fetchall()
886 if not resolved_rows:
887 return []
888 resolved_ids: list[str] = [row[0] for row in resolved_rows]
889
890 # ── Step 2: batched graph walk for all open proposals ─────────────────────
891 #
892 # Two parallel recursive CTEs share the same WITH RECURSIVE block:
893 #
894 # from_walk — ancestors reachable from each proposal's from_branch HEAD
895 # to_walk — ancestors reachable from each proposal's to_branch HEAD
896 #
897 # The final SELECT finds proposals where an anchor appears in from_walk but
898 # NOT in to_walk (i.e. the commit is on the feature branch but hasn't been
899 # integrated into the target branch yet).
900 reachability_sql = text("""
901 WITH RECURSIVE
902 from_walk(proposal_id, commit_id, depth) AS (
903 SELECT p.proposal_id, CAST(b.head_commit_id AS TEXT), 0
904 FROM musehub_proposals p
905 JOIN musehub_branches b
906 ON b.repo_id = p.repo_id
907 AND b.name = p.from_branch
908 WHERE p.repo_id = :repo_id
909 AND p.state = 'open'
910 AND b.head_commit_id IS NOT NULL
911
912 UNION ALL
913
914 SELECT fw.proposal_id, elem.v, fw.depth + 1
915 FROM from_walk fw
916 JOIN musehub_commits mc
917 ON mc.commit_id = fw.commit_id
918 CROSS JOIN LATERAL unnest(mc.parent_ids) AS elem(v)
919 WHERE fw.depth < :max_depth
920 ),
921 to_walk(proposal_id, commit_id, depth) AS (
922 SELECT p.proposal_id, CAST(b.head_commit_id AS TEXT), 0
923 FROM musehub_proposals p
924 JOIN musehub_branches b
925 ON b.repo_id = p.repo_id
926 AND b.name = p.to_branch
927 WHERE p.repo_id = :repo_id
928 AND p.state = 'open'
929 AND b.head_commit_id IS NOT NULL
930
931 UNION ALL
932
933 SELECT tw.proposal_id, elem.v, tw.depth + 1
934 FROM to_walk tw
935 JOIN musehub_commits mc
936 ON mc.commit_id = tw.commit_id
937 CROSS JOIN LATERAL unnest(mc.parent_ids) AS elem(v)
938 WHERE tw.depth < :max_depth
939 )
940 SELECT DISTINCT
941 p.proposal_id,
942 p.proposal_number,
943 p.title,
944 p.state,
945 p.from_branch,
946 p.to_branch
947 FROM from_walk fw
948 JOIN musehub_proposals p ON p.proposal_id = fw.proposal_id
949 WHERE fw.commit_id = ANY(:resolved_ids)
950 AND NOT EXISTS (
951 SELECT 1 FROM to_walk tw
952 WHERE tw.proposal_id = fw.proposal_id
953 AND tw.commit_id = fw.commit_id
954 )
955 LIMIT 10
956 """)
957 rows = (await session.execute(reachability_sql, {
958 "repo_id": repo_id,
959 "max_depth": max_depth,
960 "resolved_ids": resolved_ids,
961 })).fetchall()
962
963 return [
964 {
965 "proposal_id": str(row[0]),
966 "proposal_number": row[1],
967 "title": row[2],
968 "state": row[3],
969 "from_branch": row[4],
970 "to_branch": row[5],
971 "match_reason": "branch_reachability",
972 }
973 for row in rows
974 ]
975
976
977 # ── Signal 2: symbol anchor overlap ───────────────────────────────────────────
978
979
980 async def find_proposals_by_symbol_overlap(
981 session: AsyncSession,
982 repo_id: str,
983 symbol_anchors: list[str],
984 ) -> list[_ProposalMatch]:
985 """Find proposals whose ``touched_symbols`` intersect the issue's ``symbol_anchors``.
986
987 Signal 2: symbol anchor overlap.
988
989 When a proposal is created or merged, ``touched_symbols`` is populated from
990 the ``structured_delta`` column of every commit on ``from_branch``. A proposal is
991 linked to an issue when at least one of the issue's ``symbol_anchors`` appears
992 in the proposal's ``touched_symbols`` — meaning that proposal's commits
993 literally touched the exact symbol the issue is about.
994
995 Returns a list of proposal dicts, each containing:
996 proposal_id, proposal_number, title, state,
997 from_branch, to_branch, match_reason="symbol_overlap"
998 """
999 if not symbol_anchors:
1000 return []
1001
1002 # Fetch all proposals for this repo that have a non-empty touched_symbols.
1003 # We filter in Python because JSON array containment queries are dialect-
1004 # specific and the anchor list is small enough that Python intersection is fast.
1005 stmt = (
1006 select(MusehubProposal)
1007 .where(
1008 MusehubProposal.repo_id == repo_id,
1009 )
1010 .order_by(MusehubProposal.created_at.desc())
1011 .limit(200) # cap: repos with thousands of proposals are an edge case
1012 )
1013 rows = (await session.execute(stmt)).scalars().all()
1014
1015 anchor_set = set(symbol_anchors)
1016 results: list[_ProposalMatch] = []
1017 for row in rows:
1018 touched: list[str] = list(row.touched_symbols or [])
1019 if anchor_set.intersection(touched):
1020 results.append({
1021 "proposal_id": row.proposal_id,
1022 "proposal_number": row.proposal_number,
1023 "title": row.title,
1024 "state": row.state,
1025 "from_branch": row.from_branch,
1026 "to_branch": row.to_branch,
1027 "match_reason": "symbol_overlap",
1028 })
1029 if len(results) >= 10:
1030 break
1031
1032 return results
1033
1034
1035 # ── Release context (Muse-native issue → release tracking) ────────────────────
1036
1037
1038 async def get_issue_release_context(
1039 session: AsyncSession,
1040 repo_id: str,
1041 commit_anchors: list[str],
1042 ) -> IssueReleaseContext:
1043 """Derive Muse-native release context for an issue from the VCS graph.
1044
1045 Returns rich per-commit data so the UI can show real commit hashes,
1046 messages, authors, and which release each commit landed in — not a
1047 manually-entered field, but a live read of the VCS graph.
1048
1049 A commit anchor is "in" a release when the anchor commit's timestamp is at
1050 or before the release's pinned commit timestamp (linear history ordering).
1051
1052 Returns a dict with keys:
1053 no_commits – True when the issue has no commit_anchors at all
1054 anchor_commits – list of {short, full, message, author, date, branch,
1055 landed_in: [{tag, release_id, channel}]}
1056 One entry per resolved anchor commit, in timestamp order.
1057 all_landed – True when every anchor commit is in at least one release
1058 any_landed – True when at least one anchor commit is in a release
1059 pending_count – count of anchor commits not yet in any release
1060 next_tag – proposed next release tag (bump patch on latest semver)
1061 e.g. "v0.3.1" — derived from VCS history, never typed
1062 latest_release_tag – tag of the most recent release, or None
1063 """
1064 if not commit_anchors:
1065 return {
1066 "no_commits": True,
1067 "anchor_commits": [],
1068 "all_landed": False,
1069 "any_landed": False,
1070 "pending_count": 0,
1071 "next_tag": None,
1072 "latest_release_tag": None,
1073 }
1074
1075 from sqlalchemy import or_
1076 conditions = [
1077 MusehubCommit.commit_id.startswith(cid) if len(cid) < 64
1078 else MusehubCommit.commit_id == cid
1079 for cid in commit_anchors
1080 ]
1081 anchor_rows = (await session.execute(
1082 select(MusehubCommit)
1083 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
1084 .where(
1085 MusehubCommitRef.repo_id == repo_id,
1086 or_(*conditions),
1087 )
1088 )).scalars().all()
1089
1090 # Fetch all releases ordered oldest → newest.
1091 release_rows = (await session.execute(
1092 select(MusehubRelease)
1093 .where(MusehubRelease.repo_id == repo_id)
1094 .order_by(
1095 MusehubRelease.semver_major,
1096 MusehubRelease.semver_minor,
1097 MusehubRelease.semver_patch,
1098 )
1099 )).scalars().all()
1100
1101 # Build a map: release commit_id → release timestamp for containment check.
1102 rel_commit_ids = [r.commit_id for r in release_rows if r.commit_id]
1103 rel_commit_rows: _CommitMap = {}
1104 if rel_commit_ids:
1105 rows = (await session.execute(
1106 select(MusehubCommit)
1107 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
1108 .where(
1109 MusehubCommitRef.repo_id == repo_id,
1110 MusehubCommit.commit_id.in_(rel_commit_ids),
1111 )
1112 )).scalars().all()
1113 rel_commit_rows = {r.commit_id: r for r in rows}
1114
1115 # Determine next proposed tag from latest release semver.
1116 # If no releases exist yet, propose v0.1.0 as the first release.
1117 latest_release: "MusehubRelease | None" = release_rows[-1] if release_rows else None
1118 latest_release_tag: str | None = latest_release.tag if latest_release else None
1119 if latest_release and latest_release.semver_major is not None:
1120 patch = (latest_release.semver_patch or 0) + 1
1121 next_tag: str = f"v{latest_release.semver_major}.{latest_release.semver_minor or 0}.{patch}"
1122 else:
1123 next_tag = "v0.1.0"
1124
1125 # Build per-anchor-commit entries.
1126 anchor_commits: list[JSONObject] = []
1127 for row in sorted(anchor_rows, key=lambda r: (r.timestamp or "")):
1128 # Determine which releases contain this commit.
1129 landed_in: list[JSONObject] = []
1130 for rel in release_rows:
1131 if not rel.commit_id:
1132 continue
1133 rel_commit = rel_commit_rows.get(rel.commit_id)
1134 if (rel_commit and rel_commit.timestamp and row.timestamp
1135 and rel_commit.timestamp >= row.timestamp):
1136 landed_in.append({
1137 "tag": rel.tag,
1138 "release_id": rel.release_id,
1139 "channel": rel.channel or "stable",
1140 })
1141
1142 # First line of commit message, capped at 72 chars.
1143 first_line = (row.message or "").split("\n")[0].strip()
1144 if len(first_line) > 72:
1145 first_line = f"{first_line[:69]}…"
1146
1147 anchor_commits.append({
1148 "short": row.commit_id,
1149 "full": row.commit_id,
1150 "message": first_line,
1151 "author": row.author or "unknown",
1152 "date": row.timestamp.strftime("%Y-%m-%d") if row.timestamp else "",
1153 "branch": row.branch or "",
1154 "landed_in": landed_in,
1155 })
1156
1157 if not anchor_commits:
1158 # Anchor refs recorded but commits not in DB yet — still show the refs.
1159 for ref in commit_anchors:
1160 anchor_commits.append({
1161 "short": ref,
1162 "full": ref,
1163 "message": "",
1164 "author": "",
1165 "date": "",
1166 "branch": "",
1167 "landed_in": [],
1168 })
1169
1170 pending_count = sum(1 for ac in anchor_commits if not ac["landed_in"])
1171 all_landed = pending_count == 0
1172 any_landed = any(ac["landed_in"] for ac in anchor_commits)
1173
1174 return {
1175 "no_commits": False,
1176 "anchor_commits": anchor_commits,
1177 "all_landed": all_landed,
1178 "any_landed": any_landed,
1179 "pending_count": pending_count,
1180 "next_tag": next_tag,
1181 "latest_release_tag": latest_release_tag,
1182 }
File History 2 commits
sha256:4d42a346263e7cbbd152c147f3e6f24576f4b4440df9249ffb9fbcf9db699fcb feat: populate url in create_issue and create_proposal responses Sonnet 4.6 minor 93 days ago
sha256:3707eba7ad42cadedf18c8b9c534d839b88cfd1c30924c3c5a3edc74e1d809de feat: add url field to mist, issue, and proposal list/read … Sonnet 4.6 minor 93 days ago