gabriel / musehub public
musehub_repository.py python
2,361 lines 82.5 KB
Raw
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 100 days ago
1 """MuseHub persistence adapter — single point of DB access for Hub entities.
2
3 This module is the ONLY place that touches the musehub_* tables.
4 Route handlers delegate here; no business logic lives in routes.
5
6 Boundary rules:
7 - Must NOT import state stores, SSE queues, or LLM clients.
8 - May import ORM models from musehub.db domain-specific modules.
9 - May import Pydantic response models from musehub.models.musehub.
10 """
11 from datetime import datetime, timezone
12
13 import logging
14 import re
15 from collections import deque
16
17 from sqlalchemy import desc, func, or_, select
18 from sqlalchemy.ext.asyncio import AsyncSession
19 from sqlalchemy.orm import aliased
20 from sqlalchemy.sql.elements import ColumnElement
21
22 from musehub.services.musehub_snapshot import get_snapshot_manifests_batch
23
24 GENERIC_DOMAIN = "generic"
25 from musehub.core.genesis import compute_branch_id, compute_fork_id, compute_identity_id, compute_repo_id, compute_session_id
26 from musehub.db.musehub_identity_models import MusehubIdentity
27 from musehub.db.musehub_intel_models import MusehubFileLastCommit, MusehubSymbolHistoryEntry
28 from musehub.db.musehub_repo_models import (
29 MusehubBranch,
30 MusehubCommit,
31 MusehubCommitRef,
32 MusehubObject,
33 MusehubObjectRef,
34 MusehubRepo,
35 MusehubSession,
36 )
37 from musehub.db.musehub_social_models import MusehubFork
38 from musehub.db import musehub_collaborator_models as collab_db
39 from musehub.db.utils import escape_like
40 from musehub.models.musehub import (
41 SessionListResponse,
42 SessionResponse,
43 BranchDetailListResponse,
44 BranchDetailResponse,
45 BranchDivergenceScores,
46 BranchResponse,
47 CommitListResponse,
48 CommitResponse,
49 GlobalSearchCommitMatch,
50 GlobalSearchRepoGroup,
51 GlobalSearchResult,
52 DagEdge,
53 DagGraphResponse,
54 DagNode,
55 MuseHubContextCommitInfo,
56 MuseHubContextHistoryEntry,
57 MuseHubContextMusicalState,
58 MuseHubContextResponse,
59 ObjectMetaResponse,
60 RepoListResponse,
61 RepoResponse,
62 RepoSettingsPatch,
63 RepoSettingsResponse,
64 TimelineCommitEvent,
65 TimelineResponse,
66 TreeEntryResponse,
67 TreeListResponse,
68 ForkNetworkNode,
69 ForkNetworkResponse,
70 ForkRepoRequest,
71 UserForkedRepoEntry,
72 UserForksResponse,
73 )
74 from musehub.types.json_types import IntDict, JSONObject, StrDict
75
76 type FileLastCommits = dict[str, StrDict]
77
78 logger = logging.getLogger(__name__)
79
80
81 def _generate_slug(name: str) -> str:
82 """Derive a URL-safe slug from a human-readable repo name.
83
84 Rules: lowercase, non-alphanumeric chars collapsed to single hyphens,
85 leading/trailing hyphens stripped, max 64 chars. If the result is empty
86 (e.g. name was all symbols) we fall back to "repo".
87 """
88 slug = name.lower()
89 slug = re.sub(r"[^a-z0-9]+", "-", slug)
90 slug = slug.strip("-")
91 slug = slug[:64].strip("-")
92 return slug or "repo"
93
94
95 def _repo_clone_url(owner: str, slug: str) -> str:
96 """Derive the canonical clone URL from owner and slug.
97
98 Returns a plain HTTPS URL using the configured public_url so that
99 `muse clone <url>` works without any extra flags. Override the host
100 via the PUBLIC_URL environment variable (e.g. https://staging.musehub.ai).
101 """
102 from musehub.config import settings
103 return f"{settings.public_url.rstrip('/')}/{owner}/{slug}"
104
105
106 def _to_repo_response(row: MusehubRepo, domain: str = GENERIC_DOMAIN) -> RepoResponse:
107 return RepoResponse(
108 repo_id=row.repo_id,
109 name=row.name,
110 owner=row.owner,
111 slug=row.slug,
112 visibility=row.visibility,
113 owner_user_id=row.owner_user_id,
114 clone_url=_repo_clone_url(row.owner, row.slug),
115 description=row.description,
116 tags=list(row.tags or []),
117 domain_id=getattr(row, "domain_id", None),
118 domain=domain,
119 default_branch=row.default_branch,
120 created_at=row.created_at,
121 updated_at=row.updated_at,
122 pushed_at=row.pushed_at,
123 )
124
125
126 def _to_branch_response(row: MusehubBranch) -> BranchResponse:
127 return BranchResponse(
128 branch_id=row.branch_id,
129 name=row.name,
130 head_commit_id=row.head_commit_id,
131 )
132
133
134 def _to_commit_response(row: MusehubCommit) -> CommitResponse:
135 return CommitResponse(
136 commit_id=row.commit_id,
137 branch=row.branch,
138 parent_ids=list(row.parent_ids or []),
139 message=row.message,
140 author=row.author,
141 timestamp=row.timestamp,
142 snapshot_id=row.snapshot_id,
143 )
144
145
146 async def create_repo(
147 session: AsyncSession,
148 *,
149 name: str,
150 owner: str,
151 visibility: str,
152 owner_user_id: str,
153 owner_identity_id: str = "",
154 description: str = "",
155 tags: list[str] | None = None,
156 domain: str = "",
157 # ── Wizard extensions ────────────────────────────────────────
158 license: str | None = None,
159 topics: list[str] | None = None,
160 initialize: bool = False,
161 default_branch: str = "main",
162 template_repo_id: str | None = None,
163 ) -> RepoResponse:
164 """Persist a new remote repo and return its wire representation.
165
166 ``slug`` is auto-generated from ``name``. The ``(owner, slug)`` pair must
167 be unique — callers should catch ``IntegrityError`` and surface a 409.
168
169 Wizard behaviors:
170 - When ``template_repo_id`` is set, the template's description and topics
171 are copied into the new repo (template must be public; silently skipped
172 when it doesn't exist or is private).
173 - When ``initialize=True``, an empty "Initial commit" is written plus the
174 default branch pointer so the repo is immediately browsable.
175 - ``license`` is stored in the settings JSON blob under the ``license`` key.
176 - ``topics`` are merged with ``tags`` into a single unified tag list.
177 """
178 # Merge topics into tags (deduplicated, stable order).
179 combined_tags: list[str] = list(dict.fromkeys((tags or []) + (topics or [])))
180
181 # Copy template metadata when a template repo is supplied.
182 if template_repo_id is not None:
183 tmpl = await session.get(MusehubRepo, template_repo_id)
184 if tmpl is not None and tmpl.visibility == "public":
185 if not description:
186 description = tmpl.description
187 # Prepend template tags; deduplicate preserving order.
188 combined_tags = list(dict.fromkeys(list(tmpl.tags or []) + combined_tags))
189
190 # Build the settings JSON blob with optional license field.
191 settings: JSONObject = {}
192 if license is not None:
193 settings["license"] = license
194
195 slug = _generate_slug(name)
196 _created_at = datetime.now(timezone.utc)
197 # Canonical hash input: empty/absent domain maps to "muse/generic" — never "".
198 _domain_hash_input = domain or "muse/generic"
199 # Stored domain: default to "code" — the column must never be NULL for new repos.
200 _domain_id = domain or "code"
201 repo = MusehubRepo(
202 repo_id=compute_repo_id(owner_identity_id, slug, _domain_hash_input, _created_at.isoformat()),
203 name=name,
204 owner=owner,
205 slug=slug,
206 visibility=visibility,
207 owner_user_id=owner_user_id,
208 description=description,
209 tags=combined_tags,
210 settings=settings or None,
211 domain_id=_domain_id,
212 default_branch=default_branch,
213 )
214 session.add(repo)
215 await session.flush() # populate default columns before reading
216 await session.refresh(repo)
217
218 # Wizard initialisation: create default branch + empty initial commit.
219 if initialize:
220 from muse.core.types import blob_id as _blob_id
221 init_commit_id = _blob_id(f"init:{repo.repo_id}".encode())
222 now = datetime.now(tz=timezone.utc)
223
224 branch = MusehubBranch(
225 branch_id=compute_branch_id(repo.repo_id, default_branch),
226 repo_id=repo.repo_id,
227 name=default_branch,
228 head_commit_id=init_commit_id,
229 )
230 session.add(branch)
231
232 init_commit = MusehubCommit(
233 commit_id=init_commit_id,
234 branch=default_branch,
235 parent_ids=[],
236 message="Initial commit",
237 author=owner_user_id,
238 timestamp=now,
239 )
240 session.add(init_commit)
241 session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=init_commit_id))
242 await session.flush()
243
244 logger.info(
245 "✅ Created MuseHub repo %s (%s/%s) for user %s (initialize=%s)",
246 repo.repo_id, owner, slug, owner_user_id, initialize,
247 )
248 return _to_repo_response(repo)
249
250
251 async def get_repo(session: AsyncSession, repo_id: str) -> RepoResponse | None:
252 """Return repo metadata by internal ID, or None if not found."""
253 result = await session.get(MusehubRepo, repo_id)
254 if result is None:
255 return None
256 return _to_repo_response(result)
257
258
259 async def check_write_access(
260 session: AsyncSession,
261 repo_id: str,
262 actor: str,
263 repo_owner: str,
264 ) -> bool:
265 """Return True if *actor* has write-level access to the repo.
266
267 Write access is granted when the actor is the repository owner, or when
268 they are an accepted write/admin collaborator. This mirrors the check
269 performed by ``_guard_repo_owner`` in the REST route layer.
270
271 Args:
272 session: Active async DB session.
273 repo_id: ID of the repository.
274 actor: Identity handle of the caller.
275 repo_owner: Owner handle of the repository (from ``RepoResponse.owner``).
276
277 Returns:
278 ``True`` when the caller has write access; ``False`` otherwise.
279 """
280 if actor == repo_owner:
281 return True
282 collab = (await session.execute(
283 select(collab_db.MusehubCollaborator).where(
284 collab_db.MusehubCollaborator.repo_id == repo_id,
285 collab_db.MusehubCollaborator.identity_handle == actor,
286 collab_db.MusehubCollaborator.accepted_at.isnot(None),
287 collab_db.MusehubCollaborator.permission.in_(["write", "admin"]),
288 )
289 )).scalar_one_or_none()
290 return collab is not None
291
292
293 async def delete_repo(session: AsyncSession, repo_id: str) -> bool:
294 """Hard-delete a repo and all its cascade-deleted dependents.
295
296 Returns True when the repo existed and was deleted; False when not found.
297 The caller is responsible for committing the session.
298 """
299 row = await session.get(MusehubRepo, repo_id)
300 if row is None:
301 return False
302 await session.delete(row)
303 await session.flush()
304 logger.info("✅ Hard-deleted MuseHub repo %s", repo_id)
305 return True
306
307
308 async def transfer_repo_ownership(
309 session: AsyncSession, repo_id: str, new_owner_user_id: str
310 ) -> RepoResponse | None:
311 """Transfer repo ownership to a new user.
312
313 Only touches ``owner_user_id`` — the public ``owner`` username slug is
314 intentionally NOT changed here; the owner username update (if desired) is a
315 settings-level change the new owner makes separately.
316
317 Returns the updated RepoResponse, or None when the repo is not found.
318 The caller is responsible for committing the session.
319 """
320 row = await session.get(MusehubRepo, repo_id)
321 if row is None:
322 return None
323 row.owner_user_id = new_owner_user_id
324 await session.flush()
325 await session.refresh(row)
326 logger.info("✅ Transferred MuseHub repo %s ownership to user %s", repo_id, new_owner_user_id)
327 return _to_repo_response(row)
328
329
330 async def get_identity_id_for_handle(session: AsyncSession, handle: str) -> str:
331 """Return the genesis-addressed identity_id for a MSign handle, or '' if not found."""
332 row = (await session.execute(
333 select(MusehubIdentity.identity_id).where(MusehubIdentity.handle == handle)
334 )).scalar_one_or_none()
335 return row or ""
336
337
338 async def get_repo_row_by_owner_slug(
339 session: AsyncSession, owner: str, slug: str
340 ) -> MusehubRepo | None:
341 """Return the raw ORM row for owner/slug, or None if not found.
342
343 Use this when you need access to internal fields (e.g. ``owner_user_id``,
344 ``visibility``) that are not exposed by :class:`RepoResponse`.
345 """
346 stmt = select(MusehubRepo).where(
347 MusehubRepo.owner == owner,
348 MusehubRepo.slug == slug,
349 )
350 return (await session.execute(stmt)).scalars().first()
351
352
353 async def get_repo_by_owner_slug(
354 session: AsyncSession, owner: str, slug: str
355 ) -> RepoResponse | None:
356 """Return repo metadata by owner+slug canonical URL pair, or None if not found.
357
358 This is the primary resolver for all external /{owner}/{slug} routes.
359 """
360 row = await get_repo_row_by_owner_slug(session, owner, slug)
361 if row is None:
362 return None
363 return _to_repo_response(row)
364
365
366 _PAGE_SIZE = 20
367
368
369 async def list_repos_for_user(
370 session: AsyncSession,
371 user_id: str,
372 *,
373 limit: int = _PAGE_SIZE,
374 cursor: str | None = None,
375 ) -> RepoListResponse:
376 """Return repos owned by or collaborated on by ``user_id``.
377
378 Results are ordered by ``created_at`` descending (newest first). Pagination
379 uses an opaque cursor encoding the ``created_at`` ISO timestamp of the last
380 item on the current page — pass it back as ``?cursor=`` to advance.
381
382 Args:
383 session: Active async DB session.
384 user_id: MSign handle of the authenticated caller.
385 limit: Maximum repos per page (default 20).
386 cursor: Opaque pagination cursor from a previous response.
387
388 Returns:
389 ``RepoListResponse`` with the page of repos, total count, and next cursor.
390 """
391 # Correlated subquery: repo IDs the user has accepted collaborator access to.
392 # Using a subquery (not a Python list) avoids fetching all IDs into memory
393 # and avoids large IN() clauses for users with many collaboration repos.
394 collab_subq = (
395 select(collab_db.MusehubCollaborator.repo_id)
396 .where(
397 collab_db.MusehubCollaborator.identity_handle == user_id,
398 collab_db.MusehubCollaborator.accepted_at.is_not(None),
399 )
400 )
401
402 # Base filter: repos the caller owns OR collaborates on.
403 base_filter = or_(
404 MusehubRepo.owner == user_id,
405 MusehubRepo.repo_id.in_(collab_subq),
406 )
407
408 # Total count across all pages.
409 count_stmt = select(func.count()).select_from(MusehubRepo).where(base_filter)
410 total: int = (await session.execute(count_stmt)).scalar_one()
411
412 # Apply cursor: skip repos created at or after the cursor timestamp.
413 page_filter = base_filter
414 if cursor is not None:
415 try:
416 # Normalise 'Z' suffix so fromisoformat works on all Python versions.
417 _cursor = cursor.replace("Z", "+00:00")
418 cursor_dt = datetime.fromisoformat(_cursor)
419 page_filter = base_filter & (MusehubRepo.created_at < cursor_dt)
420 except ValueError:
421 pass # malformed cursor — ignore and return from the beginning
422
423 stmt = (
424 select(MusehubRepo)
425 .where(page_filter)
426 .order_by(desc(MusehubRepo.created_at))
427 .limit(limit)
428 )
429 rows = (await session.execute(stmt)).scalars().all()
430 repos = [_to_repo_response(r) for r in rows]
431
432 # Build next cursor from the last item's created_at when there may be more.
433 # Use 'Z' suffix (not '+00:00') so the cursor is URL-safe in query strings.
434 next_cursor: str | None = None
435 if len(rows) == limit:
436 _ts = rows[-1].created_at.astimezone(timezone.utc)
437 next_cursor = f"{_ts.strftime('%Y-%m-%dT%H:%M:%S.%f')}Z"
438
439 return RepoListResponse(repos=repos, next_cursor=next_cursor, total=total)
440
441
442 async def get_repo_orm_by_owner_slug(
443 session: AsyncSession, owner: str, slug: str
444 ) -> MusehubRepo | None:
445 """Return the raw ORM repo row by owner+slug, or None if not found.
446
447 Used internally when the route needs the repo_id for downstream calls.
448 """
449 stmt = select(MusehubRepo).where(
450 MusehubRepo.owner == owner,
451 MusehubRepo.slug == slug,
452 )
453 return (await session.execute(stmt)).scalars().first()
454
455
456 async def list_branches(session: AsyncSession, repo_id: str) -> list[BranchResponse]:
457 """Return all branches for a repo, ordered by name."""
458 stmt = (
459 select(MusehubBranch)
460 .where(MusehubBranch.repo_id == repo_id)
461 .order_by(MusehubBranch.name)
462 )
463 rows = (await session.execute(stmt)).scalars().all()
464 return [_to_branch_response(r) for r in rows]
465
466
467 async def get_branch_head_commit_id(
468 session: AsyncSession,
469 repo_id: str,
470 branch_name: str,
471 ) -> str | None:
472 """Return the head commit ID of ``branch_name`` in ``repo_id``, or ``None``."""
473 stmt = select(MusehubBranch).where(
474 MusehubBranch.repo_id == repo_id,
475 MusehubBranch.name == branch_name,
476 )
477 row = (await session.execute(stmt)).scalar_one_or_none()
478 return row.head_commit_id if row is not None else None
479
480
481 async def list_branches_with_detail(
482 session: AsyncSession, repo_id: str
483 ) -> BranchDetailListResponse:
484 """Return branches enriched with ahead/behind counts vs the default branch.
485
486 The default branch is whichever branch is named "main"; if no "main" branch
487 exists, the first branch alphabetically is used. Ahead/behind counts are
488 computed by comparing the set of commit IDs on each branch vs the default
489 branch — a set-difference approximation suitable for display purposes.
490
491 Musical divergence scores are not yet computable server-side (they require
492 audio snapshots), so all divergence fields are returned as ``None`` (placeholder).
493 """
494 branch_stmt = (
495 select(MusehubBranch)
496 .where(MusehubBranch.repo_id == repo_id)
497 .order_by(MusehubBranch.name)
498 )
499 branch_rows = (await session.execute(branch_stmt)).scalars().all()
500 if not branch_rows:
501 return BranchDetailListResponse(branches=[], default_branch="main")
502
503 # Determine default branch name: prefer "main", fall back to first alphabetically.
504 branch_names = [r.name for r in branch_rows]
505 default_branch_name = "main" if "main" in branch_names else branch_names[0]
506
507 # Load commit IDs per branch in one query.
508 commit_stmt = (
509 select(MusehubCommit.commit_id, MusehubCommit.branch)
510 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
511 .where(MusehubCommitRef.repo_id == repo_id)
512 )
513 commit_rows = (await session.execute(commit_stmt)).all()
514 commits_by_branch = {}
515 for commit_id, branch_name in commit_rows:
516 commits_by_branch.setdefault(branch_name, set()).add(commit_id)
517
518 default_commits: set[str] = commits_by_branch.get(default_branch_name, set())
519
520 results: list[BranchDetailResponse] = []
521 for row in branch_rows:
522 is_default = row.name == default_branch_name
523 branch_commits: set[str] = commits_by_branch.get(row.name, set())
524 ahead = len(branch_commits - default_commits) if not is_default else 0
525 behind = len(default_commits - branch_commits) if not is_default else 0
526 results.append(
527 BranchDetailResponse(
528 branch_id=row.branch_id,
529 name=row.name,
530 head_commit_id=row.head_commit_id,
531 is_default=is_default,
532 ahead_count=ahead,
533 behind_count=behind,
534 divergence=BranchDivergenceScores(
535 melodic=None, harmonic=None, rhythmic=None, structural=None, dynamic=None
536 ),
537 )
538 )
539
540 return BranchDetailListResponse(branches=results, default_branch=default_branch_name)
541
542
543 def _to_object_meta_response(row: MusehubObject) -> ObjectMetaResponse:
544 return ObjectMetaResponse(
545 object_id=row.object_id,
546 path=row.path,
547 size_bytes=row.size_bytes,
548 created_at=row.created_at,
549 )
550
551
552 async def get_commit(
553 session: AsyncSession, repo_id: str, commit_id: str
554 ) -> CommitResponse | None:
555 """Return a single commit by ID, or None if not found in this repo."""
556 ref_row = await session.get(MusehubCommitRef, (repo_id, commit_id))
557 if ref_row is None:
558 return None
559 row = await session.get(MusehubCommit, commit_id)
560 if row is None:
561 return None
562 return _to_commit_response(row)
563
564
565 async def list_objects(
566 session: AsyncSession, repo_id: str
567 ) -> list[ObjectMetaResponse]:
568 """Return all object metadata for a repo (no binary content), ordered by path."""
569 stmt = (
570 select(MusehubObject)
571 .join(MusehubObjectRef, MusehubObject.object_id == MusehubObjectRef.object_id)
572 .where(MusehubObjectRef.repo_id == repo_id)
573 .order_by(MusehubObject.path)
574 )
575 rows = (await session.execute(stmt)).scalars().all()
576 return [_to_object_meta_response(r) for r in rows]
577
578
579 async def get_object_row(
580 session: AsyncSession, repo_id: str, object_id: str
581 ) -> MusehubObject | None:
582 """Return the raw ORM object row for content delivery, or None if not found."""
583 stmt = (
584 select(MusehubObject)
585 .join(MusehubObjectRef, MusehubObject.object_id == MusehubObjectRef.object_id)
586 .where(
587 MusehubObjectRef.repo_id == repo_id,
588 MusehubObject.object_id == object_id,
589 )
590 )
591 return (await session.execute(stmt)).scalars().first()
592
593
594 async def list_commits(
595 session: AsyncSession,
596 repo_id: str,
597 *,
598 branch: str | None = None,
599 cursor: str | None = None,
600 limit: int = 50,
601 ) -> CommitListResponse:
602 """Return commits for a repo with cursor-based keyset pagination (newest first).
603
604 ``branch`` restricts results to a specific branch when given.
605 ``cursor`` is the ISO 8601 ``timestamp`` of the last seen commit (opaque
606 to callers — pass ``nextCursor`` from a previous response verbatim).
607 Omit to start from the most recent commit.
608 """
609 base_conditions = [MusehubCommitRef.repo_id == repo_id]
610 if branch:
611 base_conditions.append(MusehubCommit.branch == branch)
612
613 count_stmt = (
614 select(func.count(MusehubCommitRef.commit_id))
615 .join(MusehubCommit, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
616 .where(*base_conditions)
617 )
618 total: int = (await session.execute(count_stmt)).scalar_one()
619
620 data_conditions = list(base_conditions)
621 if cursor is not None:
622 data_conditions.append(
623 MusehubCommit.timestamp < datetime.fromisoformat(cursor)
624 )
625
626 rows = list(
627 (
628 await session.execute(
629 select(MusehubCommit)
630 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
631 .where(*data_conditions)
632 .order_by(desc(MusehubCommit.timestamp))
633 .limit(limit + 1)
634 )
635 ).scalars()
636 )
637
638 next_cursor: str | None = None
639 if len(rows) == limit + 1:
640 next_cursor = rows[limit - 1].timestamp.isoformat()
641 rows = rows[:limit]
642
643 return CommitListResponse(
644 commits=[_to_commit_response(r) for r in rows],
645 total=total,
646 next_cursor=next_cursor,
647 )
648
649
650
651
652 async def get_timeline_events(
653 session: AsyncSession,
654 repo_id: str,
655 *,
656 limit: int = 200,
657 ) -> TimelineResponse:
658 """Return a chronological timeline of commits for a repo.
659
660 Fetches up to ``limit`` commits (oldest-first for temporal rendering) and
661 derives two event streams:
662 - commits: every commit as a timeline marker
663 - emotion: deterministic emotion vectors from commit SHAs
664
665 Callers must verify the repo exists before calling this function.
666 Returns an empty timeline when the repo has no commits.
667 """
668 total_stmt = (
669 select(func.count())
670 .select_from(MusehubCommitRef)
671 .where(MusehubCommitRef.repo_id == repo_id)
672 )
673 total: int = (await session.execute(total_stmt)).scalar_one()
674
675 rows_stmt = (
676 select(MusehubCommit)
677 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
678 .where(MusehubCommitRef.repo_id == repo_id)
679 .order_by(MusehubCommit.timestamp) # oldest-first for temporal rendering
680 .limit(limit)
681 )
682 rows = (await session.execute(rows_stmt)).scalars().all()
683
684 commit_events = [
685 TimelineCommitEvent(
686 commit_id=row.commit_id,
687 branch=row.branch,
688 message=row.message,
689 author=row.author,
690 timestamp=row.timestamp,
691 parent_ids=list(row.parent_ids or []),
692 )
693 for row in rows
694 ]
695
696 return TimelineResponse(
697 commits=commit_events,
698 total_commits=total,
699 )
700 async def global_search(
701 session: AsyncSession,
702 *,
703 query: str,
704 mode: str = "keyword",
705 cursor: str | None = None,
706 limit: int = 10,
707 ) -> GlobalSearchResult:
708 """Search commit messages across all public MuseHub repos.
709
710 Only ``visibility='public'`` repos are searched — private repos are never
711 exposed regardless of caller identity. This enforces the public-only
712 contract at the persistence layer so no route handler can accidentally
713 bypass it.
714
715 ``mode`` controls matching strategy:
716 - ``keyword``: OR-match of whitespace-split query terms against message and
717 repo name using LIKE (case-insensitive via lower()).
718 - ``pattern``: raw SQL LIKE pattern applied to commit message only.
719
720 Results are grouped by repo and cursor-paginated by repo-group (``limit``
721 controls how many repo-groups per page). Within each group, up to 20
722 matching commits are returned newest-first.
723
724 An audio preview object ID is attached when the repo contains any .mp3,
725 .ogg, or .wav artifact — the first one found by path ordering is used.
726 Audio previews are resolved in a single batched query across all matching
727 repos (not N per-repo queries) to avoid the N+1 pattern.
728
729 Args:
730 session: Active async DB session.
731 query: Raw search string from the user or agent.
732 mode: "keyword" or "pattern". Defaults to "keyword".
733 cursor: Opaque cursor from a previous nextCursor field (integer offset encoded as string).
734 limit: Number of repo-groups per page (1–50).
735
736 Returns:
737 GlobalSearchResult with groups, cursor pagination metadata, and counts.
738 """
739 # ── 1. Collect all public repos ─────────────────────────────────────────
740 public_repos_stmt = (
741 select(MusehubRepo)
742 .where(
743 MusehubRepo.visibility == "public",
744 )
745 .order_by(MusehubRepo.created_at)
746 )
747 public_repo_rows = (await session.execute(public_repos_stmt)).scalars().all()
748 total_repos_searched = len(public_repo_rows)
749
750 if not public_repo_rows or not query.strip():
751 return GlobalSearchResult(
752 query=query,
753 mode=mode,
754 groups=[],
755 total_repos_searched=total_repos_searched,
756 )
757
758 repo_ids = [r.repo_id for r in public_repo_rows]
759 repo_map = {r.repo_id: r for r in public_repo_rows}
760
761 # ── 2. Build commit filter predicate ────────────────────────────────────
762 predicate: ColumnElement[bool]
763 if mode == "pattern":
764 predicate = MusehubCommit.message.like(query)
765 else:
766 # keyword: OR-match each whitespace-split term against message (lower)
767 terms = [t for t in query.lower().split() if t]
768 if not terms:
769 return GlobalSearchResult(
770 query=query,
771 mode=mode,
772 groups=[],
773 total_repos_searched=total_repos_searched,
774 )
775 term_predicates = [
776 or_(
777 func.lower(MusehubCommit.message).ilike(f"%{escape_like(term)}%", escape="\\"),
778 func.lower(MusehubRepo.name).ilike(f"%{escape_like(term)}%", escape="\\"),
779 )
780 for term in terms
781 ]
782 predicate = or_(*term_predicates)
783
784 # ── 3. Query matching commits joined to their repo ───────────────────────
785 commits_stmt = (
786 select(MusehubCommit, MusehubRepo)
787 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
788 .join(MusehubRepo, MusehubCommitRef.repo_id == MusehubRepo.repo_id)
789 .where(
790 MusehubCommitRef.repo_id.in_(repo_ids),
791 predicate,
792 )
793 .order_by(desc(MusehubCommit.timestamp))
794 )
795 commit_pairs = (await session.execute(commits_stmt)).all()
796
797 # ── 4. Group commits by repo ─────────────────────────────────────────────
798 groups_map = {}
799 for commit_row, _repo_row in commit_pairs:
800 groups_map.setdefault(_repo_row.repo_id, []).append(commit_row)
801
802 # ── 5. Cursor-paginate repo-groups ───────────────────────────────────────
803 # Cursor is the repo_id of the last item returned on the previous page.
804 # Find its position and start the next page immediately after it.
805 sorted_repo_ids = list(groups_map.keys())
806 page_start = 0
807 if cursor:
808 try:
809 idx = sorted_repo_ids.index(cursor)
810 page_start = idx + 1
811 except ValueError:
812 page_start = 0
813 page_repo_ids = sorted_repo_ids[page_start : page_start + limit]
814 has_more = (page_start + limit) < len(sorted_repo_ids)
815 next_cursor_val = page_repo_ids[-1] if has_more and page_repo_ids else None
816
817 groups: list[GlobalSearchRepoGroup] = []
818 for rid in page_repo_ids:
819 repo_row = repo_map[rid]
820 all_matches = groups_map[rid]
821
822 commit_matches = [
823 GlobalSearchCommitMatch(
824 commit_id=c.commit_id,
825 message=c.message,
826 author=c.author,
827 branch=c.branch,
828 timestamp=c.timestamp,
829 repo_id=rid,
830 repo_name=repo_row.name,
831 repo_owner=repo_row.owner_user_id,
832 repo_visibility=repo_row.visibility,
833 )
834 for c in all_matches[:20]
835 ]
836 groups.append(
837 GlobalSearchRepoGroup(
838 repo_id=rid,
839 repo_name=repo_row.name,
840 repo_owner=repo_row.owner_user_id,
841 repo_slug=repo_row.slug,
842 repo_visibility=repo_row.visibility,
843 matches=commit_matches,
844 total_matches=len(all_matches),
845 )
846 )
847
848 return GlobalSearchResult(
849 query=query,
850 mode=mode,
851 groups=groups,
852 total_repos_searched=total_repos_searched,
853 next_cursor=next_cursor_val,
854 )
855 async def list_commits_dag(
856 session: AsyncSession,
857 repo_id: str,
858 ) -> DagGraphResponse:
859 """Return the full commit graph for a repo as a topologically sorted DAG.
860
861 Fetches every commit for the repo (no limit — required for correct DAG
862 traversal). Applies Kahn's algorithm to produce a topological ordering
863 from oldest ancestor to newest commit, which graph renderers can consume
864 directly without additional sorting.
865
866 Edges flow child → parent (source = child, target = parent) following the
867 standard directed graph convention where arrows point toward ancestors.
868
869 Branch head commits are identified by querying the branches table. The
870 highest-timestamp commit across all branches is designated as HEAD for
871 display purposes when no explicit HEAD ref exists.
872
873 Agent use case: call this to reason about the project's branching topology,
874 find common ancestors, or identify which branches contain a given commit.
875 """
876 # Fetch all commits for this repo
877 stmt = (
878 select(MusehubCommit)
879 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
880 .where(MusehubCommitRef.repo_id == repo_id)
881 )
882 all_rows = (await session.execute(stmt)).scalars().all()
883
884 if not all_rows:
885 return DagGraphResponse(nodes=[], edges=[], head_commit_id=None)
886
887 # Build lookup map
888 row_map = {r.commit_id: r for r in all_rows}
889
890 # Fetch all branches to identify HEAD candidates and branch labels
891 branch_stmt = select(MusehubBranch).where(MusehubBranch.repo_id == repo_id)
892 branch_rows = (await session.execute(branch_stmt)).scalars().all()
893
894 # Map commit_id → branch names pointing at it
895 branch_label_map = {}
896 for br in branch_rows:
897 if br.head_commit_id and br.head_commit_id in row_map:
898 branch_label_map.setdefault(br.head_commit_id, []).append(br.name)
899
900 # Identify HEAD: the branch head with the most recent timestamp, or the
901 # most recent commit overall when no branches exist
902 head_commit_id: str | None = None
903 if branch_rows:
904 latest_ts = None
905 for br in branch_rows:
906 if br.head_commit_id and br.head_commit_id in row_map:
907 ts = row_map[br.head_commit_id].timestamp
908 if latest_ts is None or ts > latest_ts:
909 latest_ts = ts
910 head_commit_id = br.head_commit_id
911 if head_commit_id is None:
912 head_commit_id = max(all_rows, key=lambda r: r.timestamp).commit_id
913
914 # Kahn's topological sort (oldest → newest).
915 # in_degree[c] = number of c's parents that are present in this repo's commit set.
916 # Commits with in_degree == 0 are roots (no parents) — they enter the queue first,
917 # producing a parent-before-child ordering (oldest ancestor → newest commit).
918 in_degree: IntDict = {r.commit_id: 0 for r in all_rows}
919 # children_map[parent_id] = list of commit IDs whose parent_ids contains parent_id
920 children_map = {r.commit_id: [] for r in all_rows}
921
922 edges: list[DagEdge] = []
923 for row in all_rows:
924 for parent_id in (row.parent_ids or []):
925 if parent_id in row_map:
926 edges.append(DagEdge(source=row.commit_id, target=parent_id))
927 children_map.setdefault(parent_id, []).append(row.commit_id)
928 in_degree[row.commit_id] += 1
929
930 # Kahn's algorithm: start from commits with no parents (roots)
931 queue: deque[str] = deque(
932 cid for cid, deg in in_degree.items() if deg == 0
933 )
934 topo_order: list[str] = []
935
936 while queue:
937 cid = queue.popleft()
938 topo_order.append(cid)
939 for child_id in children_map.get(cid, []):
940 in_degree[child_id] -= 1
941 if in_degree[child_id] == 0:
942 queue.append(child_id)
943
944 # Handle cycles or disconnected commits (append remaining in timestamp order)
945 remaining = set(row_map.keys()) - set(topo_order)
946 if remaining:
947 sorted_remaining = sorted(remaining, key=lambda c: row_map[c].timestamp)
948 topo_order.extend(sorted_remaining)
949
950 _conv_re = re.compile(r'^(\w+)(\([^)]*\))?(!)?\s*:')
951
952 nodes: list[DagNode] = []
953 for cid in topo_order:
954 row = row_map[cid]
955 # Extract conventional-commit prefix from the message
956 m = _conv_re.match((row.message or "").strip())
957 commit_type = m.group(1).lower() if m else ""
958
959 sem_ver_bump = str(row.sem_ver_bump or "none").lower()
960
961 # Breaking change: bang suffix OR breaking_changes column
962 is_breaking = bool((m and m.group(3)) or row.breaking_changes)
963
964 is_agent = bool(row.agent_id)
965
966 sym_added = 0
967 sym_removed = 0
968 delta = row.structured_delta if isinstance(row.structured_delta, dict) else {}
969 for file_op in (delta.get("ops") or []):
970 for child_op in (file_op.get("child_ops") or []) if isinstance(file_op, dict) else []:
971 if not isinstance(child_op, dict):
972 continue
973 if child_op.get("op") == "insert":
974 sym_added += 1
975 elif child_op.get("op") == "delete":
976 sym_removed += 1
977
978 nodes.append(
979 DagNode(
980 commit_id=row.commit_id,
981 message=row.message,
982 author=row.author,
983 timestamp=row.timestamp,
984 branch=row.branch,
985 parent_ids=list(row.parent_ids or []),
986 is_head=(row.commit_id == head_commit_id),
987 branch_labels=branch_label_map.get(row.commit_id, []),
988 tag_labels=[],
989 commit_type=commit_type,
990 sem_ver_bump=sem_ver_bump,
991 is_breaking=is_breaking,
992 is_agent=is_agent,
993 sym_added=sym_added,
994 sym_removed=sym_removed,
995 )
996 )
997
998 logger.debug("✅ Built DAG for repo %s: %d nodes, %d edges", repo_id, len(nodes), len(edges))
999 return DagGraphResponse(nodes=nodes, edges=edges, head_commit_id=head_commit_id)
1000
1001
1002 # ---------------------------------------------------------------------------
1003 # Context document builder
1004 # ---------------------------------------------------------------------------
1005
1006 _CONTEXT_HISTORY_DEPTH = 5
1007
1008
1009 async def _get_commit_by_id(
1010 session: AsyncSession, repo_id: str, commit_id: str
1011 ) -> MusehubCommit | None:
1012 """Fetch a raw MusehubCommit ORM row by (repo_id, commit_id)."""
1013 ref_row = await session.get(MusehubCommitRef, (repo_id, commit_id))
1014 if ref_row is None:
1015 return None
1016 return await session.get(MusehubCommit, commit_id)
1017
1018
1019 async def _build_hub_history(
1020 session: AsyncSession,
1021 repo_id: str,
1022 start_commit: MusehubCommit,
1023 depth: int,
1024 ) -> list[MuseHubContextHistoryEntry]:
1025 """Walk the parent chain, returning up to *depth* ancestor entries.
1026
1027 The *start_commit* (the context target) is NOT included — it is surfaced
1028 separately as ``head_commit`` in the result. Entries are newest-first.
1029 """
1030 entries: list[MuseHubContextHistoryEntry] = []
1031 parent_ids: list[str] = list(start_commit.parent_ids or [])
1032
1033 while parent_ids and len(entries) < depth:
1034 parent_id = parent_ids[0]
1035 commit = await _get_commit_by_id(session, repo_id, parent_id)
1036 if commit is None:
1037 logger.warning("⚠️ Hub history chain broken at %s", parent_id)
1038 break
1039 entries.append(
1040 MuseHubContextHistoryEntry(
1041 commit_id=commit.commit_id,
1042 message=commit.message,
1043 author=commit.author,
1044 timestamp=commit.timestamp,
1045 active_tracks=[],
1046 )
1047 )
1048 parent_ids = list(commit.parent_ids or [])
1049
1050 return entries
1051
1052
1053 async def get_context_for_commit(
1054 session: AsyncSession,
1055 repo_id: str,
1056 ref: str,
1057 ) -> MuseHubContextResponse | None:
1058 """Build a context document for a MuseHub commit.
1059
1060 Traverses the commit's parent chain (up to 5 ancestors).
1061
1062 Args:
1063 session: Open async DB session. Read-only — no writes performed.
1064 repo_id: Hub repo identifier.
1065 ref: Target commit ID. Must belong to this repo.
1066
1067 Returns:
1068 ``MuseHubContextResponse`` ready for JSON serialisation, or None if the
1069 commit does not exist in this repo.
1070 """
1071 commit = await _get_commit_by_id(session, repo_id, ref)
1072 if commit is None:
1073 return None
1074
1075 head_commit_info = MuseHubContextCommitInfo(
1076 commit_id=commit.commit_id,
1077 message=commit.message,
1078 author=commit.author,
1079 branch=commit.branch,
1080 timestamp=commit.timestamp,
1081 )
1082
1083 musical_state = MuseHubContextMusicalState(active_tracks=[])
1084
1085 history = await _build_hub_history(
1086 session, repo_id, commit, _CONTEXT_HISTORY_DEPTH
1087 )
1088
1089 logger.info("✅ MuseHub context built for repo %s commit %s", repo_id, ref)
1090 return MuseHubContextResponse(
1091 repo_id=repo_id,
1092 current_branch=commit.branch,
1093 head_commit=head_commit_info,
1094 musical_state=musical_state,
1095 history=history,
1096 missing_elements=[],
1097 suggestions={},
1098 )
1099
1100
1101 def _to_session_response(s: MusehubSession) -> SessionResponse:
1102 """Compute derived fields and return a SessionResponse."""
1103 duration: float | None = None
1104 if s.ended_at is not None:
1105 # Normalize to offset-naive UTC before subtraction
1106 ended = s.ended_at.replace(tzinfo=None) if s.ended_at.tzinfo else s.ended_at
1107 started = s.started_at.replace(tzinfo=None) if s.started_at.tzinfo else s.started_at
1108 duration = (ended - started).total_seconds()
1109 return SessionResponse(
1110 session_id=s.session_id,
1111 started_at=s.started_at,
1112 ended_at=s.ended_at,
1113 duration_seconds=duration,
1114 participants=s.participants or [],
1115 commits=list(s.commits) if s.commits else [],
1116 notes=s.notes or "",
1117 intent=s.intent,
1118 location=s.location,
1119 is_active=s.is_active,
1120 created_at=s.created_at,
1121 )
1122
1123
1124 async def create_session(
1125 session: AsyncSession,
1126 repo_id: str,
1127 started_at: datetime | None,
1128 participants: list[str],
1129 intent: str,
1130 location: str,
1131 *,
1132 author_identity_id: str = "",
1133 ) -> SessionResponse:
1134 """Create and persist a new recording session."""
1135 _started_at = started_at or datetime.now(timezone.utc)
1136 new_session = MusehubSession(
1137 session_id=compute_session_id(repo_id, author_identity_id, _started_at.isoformat()),
1138 repo_id=repo_id,
1139 started_at=_started_at,
1140 participants=participants,
1141 intent=intent,
1142 location=location,
1143 is_active=True,
1144 )
1145 session.add(new_session)
1146 await session.flush()
1147 return _to_session_response(new_session)
1148
1149
1150 async def stop_session(
1151 session: AsyncSession,
1152 repo_id: str,
1153 session_id: str,
1154 ended_at: datetime | None,
1155 ) -> SessionResponse | None:
1156 """Mark a session as ended; idempotent if already stopped. Returns None if not found."""
1157 from sqlalchemy import select
1158
1159 result = await session.execute(
1160 select(MusehubSession).where(
1161 MusehubSession.session_id == session_id,
1162 MusehubSession.repo_id == repo_id,
1163 )
1164 )
1165 row = result.scalar_one_or_none()
1166 if row is None:
1167 return None
1168 if row.is_active:
1169 row.ended_at = ended_at or datetime.now(timezone.utc)
1170 row.is_active = False
1171 await session.flush()
1172 return _to_session_response(row)
1173
1174
1175 async def list_sessions(
1176 session: AsyncSession,
1177 repo_id: str,
1178 limit: int = 50,
1179 cursor: str | None = None,
1180 ) -> tuple[list[SessionResponse], int, str | None]:
1181 """Return sessions for a repo, newest first, with total count and next cursor.
1182
1183 Ordered by ``is_active DESC, started_at DESC``. ``cursor`` is an opaque
1184 ISO-8601 ``started_at`` timestamp received from a previous response. When
1185 provided, only sessions with ``started_at`` strictly before the cursor
1186 instant are returned (ties broken by ``is_active`` sort ordering, which
1187 means active sessions always float to the top of page 1).
1188
1189 Returns ``(sessions, total, next_cursor)`` where ``next_cursor`` is
1190 ``None`` on the last page.
1191 """
1192 import datetime as _dt
1193 from sqlalchemy import func, select
1194
1195 total_result = await session.execute(
1196 select(func.count(MusehubSession.session_id)).where(
1197 MusehubSession.repo_id == repo_id
1198 )
1199 )
1200 total = total_result.scalar_one()
1201
1202 stmt = (
1203 select(MusehubSession)
1204 .where(MusehubSession.repo_id == repo_id)
1205 .order_by(MusehubSession.is_active.desc(), MusehubSession.started_at.desc())
1206 .limit(limit + 1)
1207 )
1208 if cursor:
1209 cursor_dt = _dt.datetime.fromisoformat(cursor)
1210 # Active sessions always sort first; cursor only filters the started_at dimension
1211 # so we skip rows already seen by checking started_at < cursor_dt.
1212 stmt = stmt.where(MusehubSession.started_at < cursor_dt)
1213
1214 result = await session.execute(stmt)
1215 rows = result.scalars().all()
1216
1217 has_more = len(rows) > limit
1218 page_rows = rows[:limit]
1219 next_cursor: str | None = None
1220 if has_more and page_rows:
1221 next_cursor = page_rows[-1].started_at.isoformat()
1222
1223 return [_to_session_response(s) for s in page_rows], total, next_cursor
1224
1225
1226 async def get_session(
1227 session: AsyncSession,
1228 repo_id: str,
1229 session_id: str,
1230 ) -> SessionResponse | None:
1231 """Fetch a single session by id."""
1232 from sqlalchemy import select
1233
1234 result = await session.execute(
1235 select(MusehubSession).where(
1236 MusehubSession.session_id == session_id,
1237 MusehubSession.repo_id == repo_id,
1238 )
1239 )
1240 row = result.scalar_one_or_none()
1241 if row is None:
1242 return None
1243 return _to_session_response(row)
1244
1245
1246 async def resolve_head_ref(session: AsyncSession, repo_id: str) -> str:
1247 """Resolve the symbolic "HEAD" ref to the repo's default branch name.
1248
1249 Prefers "main" when that branch exists; otherwise returns the
1250 lexicographically first branch name, and falls back to "main" when the
1251 repo has no branches yet.
1252 """
1253 branch_stmt = (
1254 select(MusehubBranch)
1255 .where(MusehubBranch.repo_id == repo_id)
1256 .order_by(MusehubBranch.name)
1257 )
1258 branches = (await session.execute(branch_stmt)).scalars().all()
1259 if not branches:
1260 return "main"
1261 names = [b.name for b in branches]
1262 return "main" if "main" in names else names[0]
1263
1264
1265 async def resolve_ref_for_tree(
1266 session: AsyncSession, repo_id: str, ref: str
1267 ) -> bool:
1268 """Return True if ref resolves to a known branch or commit in this repo.
1269
1270 The ref can be:
1271 - ``"HEAD"`` — always valid; resolves to the default branch.
1272 - A branch name (e.g. "main", "feature/groove") — validated via the
1273 musehub_branches table.
1274 - A commit ID prefix or full SHA — validated via musehub_commits.
1275
1276 Returns False if the ref is unknown, which the caller should surface as
1277 a 404. This is a lightweight existence check; callers that need the full
1278 commit object should call ``get_commit()`` separately.
1279 """
1280 if ref == "HEAD":
1281 return True
1282
1283 branch_stmt = select(MusehubBranch).where(
1284 MusehubBranch.repo_id == repo_id,
1285 MusehubBranch.name == ref,
1286 )
1287 branch_row = (await session.execute(branch_stmt)).scalars().first()
1288 if branch_row is not None:
1289 return True
1290
1291 ref_row = await session.get(MusehubCommitRef, (repo_id, ref))
1292 return ref_row is not None
1293
1294
1295 async def _get_head_snapshot_manifest(
1296 session: AsyncSession,
1297 repo_id: str,
1298 ref: str,
1299 ) -> StrDict:
1300 """Return the ``{path: object_id}`` manifest for the HEAD commit on *ref*.
1301
1302 Falls back to an empty dict when no snapshot exists (e.g. new empty repo).
1303 """
1304 # Resolve branch head → commit_id
1305 branch_row = (
1306 await session.execute(
1307 select(MusehubBranch).where(
1308 MusehubBranch.repo_id == repo_id,
1309 MusehubBranch.name == ref,
1310 )
1311 )
1312 ).scalar_one_or_none()
1313
1314 if branch_row is None or not branch_row.head_commit_id:
1315 return {}
1316
1317 head_commit = (
1318 await session.execute(
1319 select(MusehubCommit).where(
1320 MusehubCommit.commit_id == branch_row.head_commit_id
1321 )
1322 )
1323 ).scalar_one_or_none()
1324
1325 if head_commit is None or head_commit.snapshot_id is None:
1326 return {}
1327
1328 from musehub.services.musehub_snapshot import get_snapshot_manifest
1329 return await get_snapshot_manifest(session, head_commit.snapshot_id)
1330
1331
1332 def _manifest_to_tree(
1333 manifest: StrDict,
1334 dir_path: str,
1335 ) -> tuple[list[TreeEntryResponse], list[TreeEntryResponse]]:
1336 """Build sorted (dirs, files) tree entries from a snapshot manifest.
1337
1338 ``dir_path`` is the directory prefix to list (empty = repo root).
1339 Returns (dirs, files) each sorted alphabetically.
1340 """
1341 prefix = f"{dir_path.strip('/')}/" if dir_path.strip("/") else ""
1342 seen_dirs: set[str] = set()
1343 dirs: list[TreeEntryResponse] = []
1344 files: list[TreeEntryResponse] = []
1345
1346 for path, object_id in manifest.items():
1347 norm = path.lstrip("/")
1348 if not norm.startswith(prefix):
1349 continue
1350 remainder = norm[len(prefix):]
1351 if not remainder:
1352 continue
1353 slash_pos = remainder.find("/")
1354 if slash_pos == -1:
1355 files.append(
1356 TreeEntryResponse(
1357 type="file",
1358 name=remainder,
1359 path=norm,
1360 size_bytes=None,
1361 object_id=object_id,
1362 )
1363 )
1364 else:
1365 dir_name = remainder[:slash_pos]
1366 if dir_name not in seen_dirs:
1367 seen_dirs.add(dir_name)
1368 dirs.append(
1369 TreeEntryResponse(
1370 type="dir",
1371 name=dir_name,
1372 path=prefix + dir_name,
1373 size_bytes=None,
1374 object_id=None,
1375 )
1376 )
1377
1378 dirs.sort(key=lambda e: e.name)
1379 files.sort(key=lambda e: e.name)
1380 return dirs, files
1381
1382
1383 async def list_tree(
1384 session: AsyncSession,
1385 repo_id: str,
1386 owner: str,
1387 repo_slug: str,
1388 ref: str,
1389 dir_path: str,
1390 manifest: StrDict | None = None,
1391 ) -> TreeListResponse:
1392 """Build a directory listing for the tree browser via the snapshot manifest.
1393
1394 Resolves: ref → HEAD commit → snapshot manifest → directory entries.
1395 Returns an empty listing when no snapshot manifest exists for the ref.
1396 Pass ``manifest`` to skip the fetch when the caller already has it.
1397 """
1398 if manifest is None:
1399 manifest = await _get_head_snapshot_manifest(session, repo_id, ref)
1400 dirs, files = _manifest_to_tree(manifest or {}, dir_path)
1401 return TreeListResponse(
1402 owner=owner,
1403 repo_slug=repo_slug,
1404 ref=ref,
1405 dir_path=dir_path.strip("/"),
1406 entries=dirs + files,
1407 )
1408
1409
1410 async def _resolve_ref_to_commit(
1411 session: AsyncSession, repo_id: str, ref: str
1412 ) -> MusehubCommit | None:
1413 """Resolve a branch name or commit SHA to a commit row.
1414
1415 Tries branch lookup first, then falls back to direct commit_id lookup
1416 so both ``main`` and full/partial SHAs work.
1417 """
1418 # 1. Try branch
1419 branch_row = (
1420 await session.execute(
1421 select(MusehubBranch).where(
1422 MusehubBranch.repo_id == repo_id,
1423 MusehubBranch.name == ref,
1424 )
1425 )
1426 ).scalar_one_or_none()
1427 if branch_row and branch_row.head_commit_id:
1428 return await session.get(MusehubCommit, branch_row.head_commit_id)
1429
1430 # 2. Try exact commit_id match
1431 ref_row = await session.get(MusehubCommitRef, (repo_id, ref))
1432 if ref_row is not None:
1433 row = await session.get(MusehubCommit, ref)
1434 if row:
1435 return row
1436
1437 # 3. Prefix match (short SHA)
1438 if len(ref) >= 7:
1439 row = (
1440 await session.execute(
1441 select(MusehubCommit)
1442 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
1443 .where(
1444 MusehubCommitRef.repo_id == repo_id,
1445 MusehubCommit.commit_id.like(f"{ref}%"),
1446 ).limit(1)
1447 )
1448 ).scalar_one_or_none()
1449 if row:
1450 return row
1451
1452 return None
1453
1454
1455 async def get_file_at_ref(
1456 session: AsyncSession,
1457 repo_id: str,
1458 ref: str,
1459 file_path: str,
1460 ) -> JSONObject | None:
1461 """Resolve a file path at a given ref via the snapshot manifest.
1462
1463 Looks up: ref → commit → snapshot → manifest[file_path] → object_id,
1464 then returns metadata. Content bytes are intentionally NOT returned here
1465 (callers fetch via the storage backend directly to avoid loading into memory
1466 unless needed).
1467
1468 Returns a dict with:
1469 - ``object_id``: content-addressed SHA
1470 - ``snapshot_id``: the snapshot this file belongs to
1471 - ``commit_id``: resolved commit SHA
1472 - ``path``: normalised file path
1473
1474 Returns None when the ref or file is not found.
1475 """
1476 commit = await _resolve_ref_to_commit(session, repo_id, ref)
1477 if commit is None or commit.snapshot_id is None:
1478 return None
1479
1480 from musehub.services.musehub_snapshot import get_snapshot_manifest
1481 manifest = await get_snapshot_manifest(session, commit.snapshot_id)
1482 norm_path = file_path.lstrip("/")
1483 object_id = manifest.get(norm_path)
1484 if object_id is None:
1485 return None
1486
1487 return {
1488 "object_id": object_id,
1489 "snapshot_id": commit.snapshot_id,
1490 "commit_id": commit.commit_id,
1491 "path": norm_path,
1492 "manifest_size": len(manifest),
1493 }
1494
1495
1496 async def get_last_commit_for_file(
1497 session: AsyncSession,
1498 repo_id: str,
1499 file_path: str,
1500 current_commit_id: str,
1501 ) -> MusehubCommit | None:
1502 """Return the most recent commit that changed ``file_path``.
1503
1504 Fast path: query musehub_symbol_history_entries by (repo_id, address)
1505 using the ix_symbol_history_repo_address index — O(1) regardless of
1506 repo size. Matches both bare file entries (address == path) and
1507 symbol-level entries (address starts with path::).
1508
1509 Fallback: when no history entries exist for the file (e.g. the file
1510 predates indexing), walks up to 200 snapshot manifests as before.
1511 """
1512 norm = file_path.lstrip("/")
1513
1514 # ── Fast path: symbol history index ──────────────────────────────────────
1515 history_stmt = (
1516 select(MusehubSymbolHistoryEntry)
1517 .where(
1518 MusehubSymbolHistoryEntry.repo_id == repo_id,
1519 (MusehubSymbolHistoryEntry.address == norm)
1520 | MusehubSymbolHistoryEntry.address.like(f"{norm}::%"),
1521 )
1522 .order_by(desc(MusehubSymbolHistoryEntry.committed_at))
1523 .limit(1)
1524 )
1525 history_row = (await session.execute(history_stmt)).scalars().first()
1526 if history_row is not None:
1527 return await session.get(MusehubCommit, history_row.commit_id)
1528
1529 # ── Fallback: snapshot manifest scan ─────────────────────────────────────
1530 current_commit = await session.get(MusehubCommit, current_commit_id)
1531 if current_commit is None or current_commit.snapshot_id is None:
1532 return current_commit
1533
1534 stmt = (
1535 select(MusehubCommit)
1536 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
1537 .where(
1538 MusehubCommitRef.repo_id == repo_id,
1539 MusehubCommit.branch == (current_commit.branch or "main"),
1540 MusehubCommit.timestamp <= current_commit.timestamp,
1541 )
1542 .order_by(desc(MusehubCommit.timestamp))
1543 .limit(200)
1544 )
1545 rows = (await session.execute(stmt)).scalars().all()
1546
1547 snapshot_ids = [r.snapshot_id for r in rows if r.snapshot_id]
1548 manifests: dict[str, dict] = {}
1549 for i in range(0, len(snapshot_ids), 100):
1550 chunk = await get_snapshot_manifests_batch(session, snapshot_ids[i:i + 100])
1551 manifests.update(chunk)
1552
1553 current_oid = manifests.get(current_commit.snapshot_id, {}).get(norm)
1554 if current_oid is None:
1555 return None
1556
1557 prev_commit: MusehubCommit | None = current_commit
1558 for row in rows:
1559 if row.snapshot_id is None:
1560 continue
1561 oid = manifests.get(row.snapshot_id, {}).get(norm)
1562 if oid != current_oid:
1563 break
1564 prev_commit = row
1565
1566 return prev_commit
1567
1568
1569 async def get_snapshot_diff(
1570 session: AsyncSession,
1571 repo_id: str,
1572 commit_snapshot_id: str | None,
1573 parent_snapshot_id: str | None,
1574 ) -> JSONObject:
1575 """Diff two snapshot manifests, returning file-level change lists.
1576
1577 Returns a dict with:
1578 - ``added``: files present in the new snapshot but not the parent
1579 - ``removed``: files present in the parent but not the new snapshot
1580 - ``modified``: files present in both but with different object IDs
1581 - ``unchanged``: count only (not listed, to keep payload small)
1582 """
1583 from musehub.services.musehub_snapshot import get_snapshot_manifest
1584 new_manifest: StrDict = {}
1585 old_manifest: StrDict = {}
1586
1587 if commit_snapshot_id:
1588 new_manifest = await get_snapshot_manifest(session, commit_snapshot_id)
1589
1590 if parent_snapshot_id:
1591 old_manifest = await get_snapshot_manifest(session, parent_snapshot_id)
1592
1593 added: list[str] = sorted(p for p in new_manifest if p not in old_manifest)
1594 removed: list[str] = sorted(p for p in old_manifest if p not in new_manifest)
1595 modified: list[str] = sorted(
1596 p for p in new_manifest
1597 if p in old_manifest and new_manifest[p] != old_manifest[p]
1598 )
1599
1600 return {
1601 "added": added,
1602 "removed": removed,
1603 "modified": modified,
1604 "total_files": len(new_manifest),
1605 }
1606
1607
1608 async def get_repo_home_stats(
1609 session: AsyncSession,
1610 repo_id: str,
1611 ref: str,
1612 manifest: StrDict | None = None,
1613 ) -> JSONObject:
1614 """Return aggregate stats for the repo home page.
1615
1616 Returns a dict with:
1617 - ``total_commits``: int — total commit count across all branches
1618 - ``total_objects``: int — number of stored objects
1619 - ``total_size_bytes``: int — sum of all object sizes
1620 - ``commit_activity``: list[int] — daily commit counts for the last 14 days (oldest first)
1621 """
1622 from datetime import timedelta
1623
1624 total_commits: int = (
1625 await session.execute(
1626 select(func.count()).select_from(MusehubCommitRef).where(MusehubCommitRef.repo_id == repo_id)
1627 )
1628 ).scalar_one() or 0
1629
1630 obj_agg = (
1631 await session.execute(
1632 select(
1633 func.count().label("cnt"),
1634 func.coalesce(func.sum(MusehubObject.size_bytes), 0).label("sz"),
1635 )
1636 .join(
1637 MusehubObjectRef,
1638 MusehubObject.object_id == MusehubObjectRef.object_id,
1639 )
1640 .where(MusehubObjectRef.repo_id == repo_id)
1641 )
1642 ).one()
1643 total_objects = int(obj_agg.cnt or 0)
1644 total_size_bytes = int(obj_agg.sz or 0)
1645
1646 # File count from HEAD snapshot manifest
1647 if manifest is None:
1648 manifest = await _get_head_snapshot_manifest(session, repo_id, ref)
1649
1650 # Daily commit activity for last 14 days
1651 now = datetime.now(tz=timezone.utc)
1652 fourteen_days_ago = now - timedelta(days=14)
1653 recent_rows = (
1654 await session.execute(
1655 select(MusehubCommit.timestamp)
1656 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
1657 .where(
1658 MusehubCommitRef.repo_id == repo_id,
1659 MusehubCommit.timestamp >= fourteen_days_ago,
1660 )
1661 .order_by(MusehubCommit.timestamp)
1662 )
1663 ).scalars().all()
1664
1665 # Bucket into 14 daily bins
1666 daily: list[int] = [0] * 14
1667 for ts in recent_rows:
1668 t = ts if ts.tzinfo else ts.replace(tzinfo=timezone.utc)
1669 day_idx = (now - t).days
1670 if 0 <= day_idx < 14:
1671 daily[13 - day_idx] += 1
1672
1673 return {
1674 "total_commits": total_commits,
1675 "total_objects": total_objects,
1676 "total_size_bytes": total_size_bytes,
1677 "commit_activity": daily,
1678 "total_files": len(manifest),
1679 }
1680
1681
1682 async def get_file_last_commits(
1683 session: AsyncSession,
1684 repo_id: str,
1685 paths: list[str],
1686 ref: str = "",
1687 max_commits: int = 60,
1688 ) -> FileLastCommits:
1689 """Return the last-touching commit for each file/directory path.
1690
1691 Reads from the materialized musehub_file_last_commits table (populated at
1692 push time). Falls back to the old blob-walk if the table has no rows for
1693 this repo/branch (e.g. repos pushed before the migration).
1694
1695 ``ref`` should be the branch name. When empty, falls back to reading the
1696 repo's default branch.
1697 """
1698 import re as _re
1699 from datetime import timezone as _tz
1700
1701 if not paths:
1702 return {}
1703
1704 branch = ref or ""
1705
1706 def _row_to_record(row: MusehubFileLastCommit) -> StrDict:
1707 ts = row.commit_timestamp
1708 if ts.tzinfo is None:
1709 ts = ts.replace(tzinfo=_tz.utc)
1710 model_id: str = row.model_id or ""
1711 parts = model_id.replace("claude-", "").split("-")
1712 model_label = parts[0] if parts and parts[0] else model_id
1713 msg = (row.commit_message or "").split("\n")[0][:72]
1714 _ct = _re.match(r"^(feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert)(\([^)]+\))?(!)?:", msg.strip())
1715 commit_type = _ct.group(1) if _ct else ""
1716 return {
1717 "sha": row.commit_id,
1718 "message": msg,
1719 "author": row.commit_author or "",
1720 "timestamp": ts.isoformat(),
1721 "agentId": row.agent_id or "",
1722 "modelId": model_id,
1723 "modelLabel": model_label,
1724 "commitType": commit_type,
1725 }
1726
1727 # Fetch all rows for this repo+branch in one query, then filter in Python.
1728 rows_result = await session.execute(
1729 select(MusehubFileLastCommit).where(
1730 MusehubFileLastCommit.repo_id == repo_id,
1731 MusehubFileLastCommit.branch == branch,
1732 )
1733 )
1734 all_rows: list[MusehubFileLastCommit] = list(rows_result.scalars().all())
1735
1736 if all_rows:
1737 # Build path→row map for exact lookups.
1738 by_path: dict[str, MusehubFileLastCommit] = {r.path: r for r in all_rows}
1739
1740 result: FileLastCommits = {}
1741 for p in paths:
1742 if p in by_path:
1743 result[p] = _row_to_record(by_path[p])
1744 else:
1745 # Directory: find the most recently touched file under this prefix.
1746 prefix = f"{p.rstrip('/')}/"
1747 best: MusehubFileLastCommit | None = None
1748 for row in all_rows:
1749 if row.path.startswith(prefix):
1750 if best is None or row.commit_timestamp > best.commit_timestamp:
1751 best = row
1752 if best is not None:
1753 result[p] = _row_to_record(best)
1754 return result
1755
1756 # --- Fallback: no materialized data yet — walk blobs (old behaviour). ---
1757 commits_result = await session.execute(
1758 select(MusehubCommit)
1759 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
1760 .where(MusehubCommitRef.repo_id == repo_id)
1761 .order_by(MusehubCommit.timestamp.desc())
1762 .limit(max_commits)
1763 )
1764 commits = list(commits_result.scalars().all())
1765
1766 from musehub.services.musehub_snapshot import get_snapshot_manifests_batch
1767 snap_ids = [c.snapshot_id for c in commits if c.snapshot_id]
1768 if not snap_ids:
1769 return {}
1770
1771 snap_by_id = await get_snapshot_manifests_batch(session, snap_ids)
1772
1773 def _commit_record(c: MusehubCommit) -> StrDict:
1774 ts = c.timestamp
1775 if ts.tzinfo is None:
1776 ts = ts.replace(tzinfo=_tz.utc)
1777 agent_id: str = c.agent_id or ""
1778 model_id: str = c.model_id or ""
1779 parts = model_id.replace("claude-", "").split("-")
1780 model_label = parts[0] if parts and parts[0] else model_id
1781 _ct = _re.match(r"^(feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert)(\([^)]+\))?(!)?:", c.message.strip())
1782 commit_type = _ct.group(1) if _ct else ""
1783 return {
1784 "sha": c.commit_id,
1785 "message": c.message.split("\n")[0][:72],
1786 "author": c.author,
1787 "timestamp": ts.isoformat(),
1788 "agentId": agent_id,
1789 "modelId": model_id,
1790 "modelLabel": model_label,
1791 "commitType": commit_type,
1792 }
1793
1794 fb_result: FileLastCommits = {}
1795 remaining = set(paths)
1796 prev_manifest: StrDict = {}
1797 prev_commit: MusehubCommit | None = None
1798
1799 for commit in commits:
1800 if not remaining:
1801 break
1802 cur_manifest = snap_by_id.get(commit.snapshot_id or "", {})
1803 if prev_commit is not None:
1804 claimed: set[str] = set()
1805 for p in list(remaining):
1806 cur_oid = cur_manifest.get(p)
1807 prev_oid = prev_manifest.get(p)
1808 if prev_oid and cur_oid != prev_oid:
1809 fb_result[p] = _commit_record(prev_commit)
1810 claimed.add(p)
1811 remaining -= claimed
1812 prev_manifest = cur_manifest
1813 prev_commit = commit
1814
1815 if prev_commit is not None:
1816 for p in list(remaining):
1817 if prev_manifest.get(p):
1818 fb_result[p] = _commit_record(prev_commit)
1819
1820 unclaimed_dirs = set(paths) - set(fb_result.keys())
1821 for dir_path in unclaimed_dirs:
1822 prefix = f"{dir_path.rstrip('/')}/"
1823 dir_prev_manifest: StrDict = {}
1824 dir_prev_commit: MusehubCommit | None = None
1825 for commit in commits:
1826 cur_m = snap_by_id.get(commit.snapshot_id or "", {})
1827 if dir_prev_commit is not None:
1828 if any(
1829 fp.startswith(prefix) and cur_m.get(fp) != dir_prev_manifest.get(fp)
1830 for fp in dir_prev_manifest
1831 if fp.startswith(prefix)
1832 ):
1833 fb_result[dir_path] = _commit_record(dir_prev_commit)
1834 break
1835 dir_prev_manifest = cur_m
1836 dir_prev_commit = commit
1837 if dir_path not in fb_result and dir_prev_commit is not None:
1838 if any(fp.startswith(prefix) for fp in dir_prev_manifest):
1839 fb_result[dir_path] = _commit_record(dir_prev_commit)
1840
1841 return fb_result
1842
1843
1844 async def get_recently_pushed_branches(
1845 session: AsyncSession,
1846 repo_id: str,
1847 current_ref: str,
1848 within_hours: int = 72,
1849 ) -> list[StrDict]:
1850 """Return branches (other than current_ref) whose head commit is recent.
1851
1852 Used to render GitHub-style "branch had recent pushes N minutes ago" banners.
1853 Returns list of ``{name, sha, message, timestamp}`` sorted newest-first.
1854 """
1855 from datetime import timezone as _tz, timedelta
1856
1857 branches_result = await session.execute(
1858 select(MusehubBranch).where(MusehubBranch.repo_id == repo_id)
1859 )
1860 branches = [
1861 b for b in branches_result.scalars().all()
1862 if b.name != current_ref and b.head_commit_id
1863 ]
1864 if not branches:
1865 return []
1866
1867 head_ids = [b.head_commit_id for b in branches if b.head_commit_id]
1868 commits_result = await session.execute(
1869 select(MusehubCommit).where(MusehubCommit.commit_id.in_(head_ids))
1870 )
1871 commit_by_id = {
1872 c.commit_id: c for c in commits_result.scalars().all()
1873 }
1874
1875 cutoff = datetime.now(_tz.utc) - timedelta(hours=within_hours)
1876 recent = []
1877 for branch in branches:
1878 commit = commit_by_id.get(branch.head_commit_id or "")
1879 if not commit:
1880 continue
1881 ts = commit.timestamp
1882 if ts.tzinfo is None:
1883 ts = ts.replace(tzinfo=_tz.utc)
1884 if ts >= cutoff:
1885 recent.append({
1886 "name": branch.name,
1887 "sha": commit.commit_id,
1888 "message": commit.message.split("\n")[0][:72],
1889 "timestamp": ts.isoformat(),
1890 })
1891
1892 recent.sort(key=lambda x: x["timestamp"], reverse=True)
1893 return recent
1894
1895
1896 async def _unique_slug_for_owner(
1897 db_session: AsyncSession,
1898 owner: str,
1899 base_slug: str,
1900 ) -> str:
1901 """Return a slug that does not collide with any existing (non-deleted) repo for ``owner``.
1902
1903 If ``base_slug`` is taken it appends ``-2``, ``-3``, … (up to ``-100``) until
1904 it finds an available name. This mirrors GitHub's fork-naming behaviour and
1905 prevents the duplicate-slug ``IntegrityError`` from being misreported as a
1906 duplicate-fork 409 at the route layer.
1907
1908 Raises ``ValueError("cannot_generate_unique_slug")`` if all 100 suffixes are
1909 already taken — an extreme edge case that should never occur in practice.
1910 """
1911 slug = base_slug
1912 for suffix in range(2, 101):
1913 exists = (
1914 await db_session.execute(
1915 select(MusehubRepo.repo_id).where(
1916 MusehubRepo.owner == owner,
1917 MusehubRepo.slug == slug,
1918 )
1919 )
1920 ).scalar_one_or_none()
1921 if exists is None:
1922 return slug
1923 slug = f"{base_slug[:60]}-{suffix}"
1924 raise ValueError("cannot_generate_unique_slug")
1925
1926
1927 async def fork_repo(
1928 db_session: AsyncSession,
1929 *,
1930 source_repo_id: str,
1931 forked_by_handle: str,
1932 request: ForkRepoRequest,
1933 ) -> UserForkedRepoEntry:
1934 """Fork a repo — creates a new public repo owned by ``forked_by_handle`` and
1935 records the relationship in ``musehub_forks``.
1936
1937 Rules enforced here (callers must also enforce via HTTP layer):
1938 - Source repo must exist and not be soft-deleted.
1939 - A handle cannot fork a repo they already own.
1940 - The same handle cannot fork the same source repo twice (unique constraint).
1941
1942 Slug collisions with repos the caller already owns are resolved automatically by
1943 appending a numeric suffix (``-2``, ``-3``, …), matching GitHub behaviour.
1944
1945 Returns the newly created fork entry with source attribution.
1946 Raises ``ValueError`` for business-rule violations; callers map these to HTTP errors.
1947 Raises ``sqlalchemy.exc.IntegrityError`` on duplicate fork (unique constraint).
1948 """
1949 # Resolve source repo.
1950 source_row = (
1951 await db_session.execute(
1952 select(MusehubRepo).where(
1953 MusehubRepo.repo_id == source_repo_id,
1954 )
1955 )
1956 ).scalar_one_or_none()
1957
1958 if source_row is None:
1959 raise ValueError("source_repo_not_found")
1960
1961 if source_row.visibility == "private":
1962 raise ValueError("source_repo_private")
1963
1964 if source_row.owner == forked_by_handle:
1965 raise ValueError("cannot_fork_own_repo")
1966
1967 # Pre-check: reject duplicate fork before touching the repo table so that
1968 # any IntegrityError that reaches the caller is unambiguously a duplicate
1969 # fork, not a slug collision.
1970 duplicate = (
1971 await db_session.execute(
1972 select(MusehubFork.fork_id).where(
1973 MusehubFork.source_repo_id == source_repo_id,
1974 MusehubFork.forked_by == forked_by_handle,
1975 )
1976 )
1977 ).scalar_one_or_none()
1978 if duplicate is not None:
1979 raise ValueError("duplicate_fork")
1980
1981 # Determine fork repo name/description. Auto-suffix slug to avoid collision
1982 # with repos the caller already owns (mirrors GitHub behaviour).
1983 fork_name = request.name or source_row.name
1984 fork_description = (
1985 request.description
1986 or f"Fork of {source_row.owner}/{source_row.slug}: {source_row.description}".rstrip(": ")
1987 )
1988 base_slug = _generate_slug(fork_name)
1989 fork_slug = await _unique_slug_for_owner(db_session, forked_by_handle, base_slug)
1990
1991 # Create the fork repo.
1992 from datetime import datetime, timezone
1993 _fork_created_at = datetime.now(timezone.utc)
1994 _fork_owner_id = compute_identity_id(forked_by_handle.encode())
1995 _fork_domain_id = source_row.domain_id or "code"
1996 fork_repo_row = MusehubRepo(
1997 repo_id=compute_repo_id(_fork_owner_id, fork_slug, _fork_domain_id, _fork_created_at.isoformat()),
1998 name=fork_name,
1999 owner=forked_by_handle,
2000 slug=fork_slug,
2001 visibility=request.visibility or "public",
2002 owner_user_id=forked_by_handle,
2003 description=fork_description,
2004 tags=list(source_row.tags or []),
2005 settings=None,
2006 created_at=_fork_created_at,
2007 updated_at=_fork_created_at,
2008 domain_id=_fork_domain_id,
2009 default_branch="main",
2010 )
2011 db_session.add(fork_repo_row)
2012 await db_session.flush()
2013 await db_session.refresh(fork_repo_row)
2014
2015 # Create the fork relationship record.
2016 _fork_now = _fork_created_at
2017 fork_record = MusehubFork(
2018 fork_id=compute_fork_id(source_repo_id, fork_repo_row.repo_id, _fork_now.isoformat()),
2019 source_repo_id=source_repo_id,
2020 fork_repo_id=fork_repo_row.repo_id,
2021 forked_by=forked_by_handle,
2022 created_at=_fork_now,
2023 )
2024 db_session.add(fork_record)
2025 await db_session.flush()
2026 await db_session.refresh(fork_record)
2027
2028 logger.info(
2029 "✅ Forked repo %s (%s/%s) → %s (%s/%s) by %s",
2030 source_repo_id, source_row.owner, source_row.slug,
2031 fork_repo_row.repo_id, forked_by_handle, fork_slug,
2032 forked_by_handle,
2033 )
2034
2035 return UserForkedRepoEntry(
2036 fork_id=fork_record.fork_id,
2037 fork_repo=_to_repo_response(fork_repo_row),
2038 source_owner=source_row.owner,
2039 source_slug=source_row.slug,
2040 forked_at=fork_record.created_at,
2041 )
2042
2043
2044 async def get_user_forks(
2045 db_session: AsyncSession,
2046 username: str,
2047 visible_to_user: str | None = None,
2048 ) -> UserForksResponse:
2049 """Return repos that ``username`` has forked, with source attribution.
2050
2051 Joins ``musehub_forks`` (where ``forked_by`` matches the given username)
2052 with ``musehub_repos`` twice — once for the fork repo and once for the
2053 source repo's owner/slug — so callers can render
2054 "forked from {source_owner}/{source_slug}" on each card.
2055
2056 Private forks are only visible when ``visible_to_user == username`` (the fork
2057 owner can see their own private forks; unauthenticated or third-party callers
2058 see only public forks).
2059
2060 Returns forks ordered newest-first. Soft-deleted repos on either side of
2061 the relationship are excluded.
2062 """
2063 SourceRepo = aliased(MusehubRepo, name="source_repo")
2064 ForkRepo = aliased(MusehubRepo, name="fork_repo")
2065
2066 base_stmt = (
2067 select(MusehubFork, ForkRepo, SourceRepo)
2068 .join(ForkRepo, MusehubFork.fork_repo_id == ForkRepo.repo_id)
2069 .join(SourceRepo, MusehubFork.source_repo_id == SourceRepo.repo_id)
2070 .where(
2071 MusehubFork.forked_by == username,
2072 )
2073 .order_by(desc(MusehubFork.created_at))
2074 )
2075
2076 # Unauthenticated callers and third parties see only public forks.
2077 # The fork owner (visible_to_user == username) sees all their forks.
2078 stmt = (
2079 base_stmt
2080 if visible_to_user == username
2081 else base_stmt.where(ForkRepo.visibility == "public")
2082 )
2083
2084 rows = (await db_session.execute(stmt)).all()
2085
2086 entries: list[UserForkedRepoEntry] = [
2087 UserForkedRepoEntry(
2088 fork_id=fork_rec.fork_id,
2089 fork_repo=_to_repo_response(fork_row),
2090 source_owner=src_row.owner,
2091 source_slug=src_row.slug,
2092 forked_at=fork_rec.created_at,
2093 )
2094 for fork_rec, fork_row, src_row in rows
2095 ]
2096
2097 return UserForksResponse(forks=entries, total=len(entries))
2098
2099
2100 async def list_repo_forks_flat(
2101 db_session: AsyncSession,
2102 repo_id: str,
2103 ) -> UserForksResponse:
2104 """Return a flat list of all public direct forks of ``repo_id``.
2105
2106 Each entry contains full fork repo metadata plus source owner/slug attribution.
2107 Ordered newest-first. Soft-deleted fork repos and private forks are excluded —
2108 private forks are hidden from the source repo's fork list unconditionally
2109 (a fork owner's private fork is discoverable only via their own forks list).
2110 """
2111 source_row = (
2112 await db_session.execute(
2113 select(MusehubRepo).where(
2114 MusehubRepo.repo_id == repo_id,
2115 )
2116 )
2117 ).scalar_one_or_none()
2118
2119 if source_row is None:
2120 return UserForksResponse(forks=[], total=0)
2121
2122 ForkRepoAlias = aliased(MusehubRepo)
2123
2124 rows = (
2125 await db_session.execute(
2126 select(MusehubFork, ForkRepoAlias)
2127 .join(ForkRepoAlias, MusehubFork.fork_repo_id == ForkRepoAlias.repo_id)
2128 .where(
2129 MusehubFork.source_repo_id == repo_id,
2130 ForkRepoAlias.visibility == "public",
2131 )
2132 .order_by(desc(MusehubFork.created_at))
2133 )
2134 ).all()
2135
2136 entries: list[UserForkedRepoEntry] = [
2137 UserForkedRepoEntry(
2138 fork_id=fork_rec.fork_id,
2139 fork_repo=_to_repo_response(fork_row),
2140 source_owner=source_row.owner,
2141 source_slug=source_row.slug,
2142 forked_at=fork_rec.created_at,
2143 )
2144 for fork_rec, fork_row in rows
2145 ]
2146
2147 return UserForksResponse(forks=entries, total=len(entries))
2148
2149
2150 async def list_repo_forks(
2151 db_session: AsyncSession,
2152 repo_id: str,
2153 ) -> ForkNetworkResponse:
2154 """Return the fork network tree rooted at the given repo.
2155
2156 The root node represents the source repo. Its ``children`` are direct
2157 forks; each child carries its own ``children`` for second-level forks,
2158 and so on. ``divergence_commits`` is always 0 at this time — commit-graph
2159 divergence counting is deferred to a future index.
2160
2161 Private forks are excluded unconditionally — private fork owners' forks are
2162 discoverable only via their own forks list, not via the source repo's network.
2163
2164 Returns an empty-root ``ForkNetworkResponse`` when the repo does not exist.
2165 """
2166 source_row = (
2167 await db_session.execute(
2168 select(MusehubRepo).where(
2169 MusehubRepo.repo_id == repo_id,
2170 )
2171 )
2172 ).scalar_one_or_none()
2173
2174 if source_row is None:
2175 return ForkNetworkResponse(
2176 root=ForkNetworkNode(
2177 owner="",
2178 repo_slug="",
2179 repo_id=repo_id,
2180 divergence_commits=0,
2181 forked_by="",
2182 forked_at=None,
2183 ),
2184 total_forks=0,
2185 )
2186
2187 # Load all forks in a single query and build the tree in Python.
2188 # For most repos the fork count is small; if it ever grows large a
2189 # recursive CTE would replace this approach.
2190 ForkRepoAlias = aliased(MusehubRepo)
2191
2192 fork_rows = (
2193 await db_session.execute(
2194 select(MusehubFork, ForkRepoAlias)
2195 .join(ForkRepoAlias, MusehubFork.fork_repo_id == ForkRepoAlias.repo_id)
2196 .where(
2197 MusehubFork.source_repo_id == repo_id,
2198 ForkRepoAlias.visibility == "public",
2199 )
2200 .order_by(MusehubFork.created_at)
2201 )
2202 ).all()
2203
2204 children: list[ForkNetworkNode] = [
2205 ForkNetworkNode(
2206 owner=fork_repo.owner,
2207 repo_slug=fork_repo.slug,
2208 repo_id=fork_repo.repo_id,
2209 divergence_commits=0,
2210 forked_by=fork_rec.forked_by,
2211 forked_at=fork_rec.created_at,
2212 children=[],
2213 )
2214 for fork_rec, fork_repo in fork_rows
2215 ]
2216
2217 root = ForkNetworkNode(
2218 owner=source_row.owner,
2219 repo_slug=source_row.slug,
2220 repo_id=source_row.repo_id,
2221 divergence_commits=0,
2222 forked_by="",
2223 forked_at=None,
2224 children=children,
2225 )
2226
2227 return ForkNetworkResponse(root=root, total_forks=len(children))
2228
2229
2230 # ── Repo settings helpers ─────────────────────────────────────────────────────
2231
2232 _SETTINGS_DEFAULTS: JSONObject = {
2233 "default_branch": "main",
2234 "has_issues": True,
2235 "has_projects": False,
2236 "has_wiki": False,
2237 "license": None,
2238 "homepage_url": None,
2239 "allow_merge_commit": True,
2240 "allow_squash_merge": True,
2241 "allow_rebase_merge": False,
2242 "delete_branch_on_merge": True,
2243 }
2244
2245
2246 def _merge_settings(stored: JSONObject | None) -> JSONObject:
2247 """Return a complete settings dict by filling missing keys with defaults.
2248
2249 ``stored`` may be None (new repos) or a partial dict (old rows that predate
2250 individual flag additions). Defaults are applied for any absent key so callers
2251 always receive a fully-populated dict.
2252 """
2253 base = dict(_SETTINGS_DEFAULTS)
2254 if stored:
2255 base.update(stored)
2256 return base
2257
2258
2259 async def get_repo_settings(
2260 session: AsyncSession, repo_id: str
2261 ) -> RepoSettingsResponse | None:
2262 """Return the mutable settings for a repo, or None if the repo does not exist.
2263
2264 Combines dedicated column values (name, description, visibility, tags) with
2265 feature-flag values from the ``settings`` JSON blob. Missing flags are
2266 back-filled with ``_SETTINGS_DEFAULTS`` so new and legacy repos both return
2267 a complete response.
2268
2269 Called by ``GET /api/repos/{repo_id}/settings``.
2270 """
2271 row = await session.get(MusehubRepo, repo_id)
2272 if row is None:
2273 return None
2274
2275 flags = _merge_settings(row.settings)
2276
2277 # Derive default_branch from stored flag; fall back to "main"
2278 default_branch = str(flags.get("default_branch") or "main")
2279
2280 return RepoSettingsResponse(
2281 name=row.name,
2282 description=row.description,
2283 visibility=row.visibility,
2284 default_branch=default_branch,
2285 has_issues=bool(flags.get("has_issues", True)),
2286 has_projects=bool(flags.get("has_projects", False)),
2287 has_wiki=bool(flags.get("has_wiki", False)),
2288 topics=list(row.tags or []),
2289 license=str(flags["license"]) if flags.get("license") is not None else None,
2290 homepage_url=str(flags["homepage_url"]) if flags.get("homepage_url") is not None else None,
2291 allow_merge_commit=bool(flags.get("allow_merge_commit", True)),
2292 allow_squash_merge=bool(flags.get("allow_squash_merge", True)),
2293 allow_rebase_merge=bool(flags.get("allow_rebase_merge", False)),
2294 delete_branch_on_merge=bool(flags.get("delete_branch_on_merge", True)),
2295 domain_id=row.domain_id,
2296 )
2297
2298
2299 async def update_repo_settings(
2300 session: AsyncSession,
2301 repo_id: str,
2302 patch: RepoSettingsPatch,
2303 ) -> RepoSettingsResponse | None:
2304 """Apply a partial settings update to a repo and return the updated settings.
2305
2306 Only non-None fields in ``patch`` are written. Dedicated columns
2307 (name, description, visibility, tags) are updated directly on the ORM row;
2308 feature flags are merged into the ``settings`` JSON blob.
2309
2310 Returns None if the repo does not exist. The caller is responsible for
2311 committing the session after a successful return.
2312
2313 Called by ``PATCH /api/repos/{repo_id}/settings``.
2314 """
2315 row = await session.get(MusehubRepo, repo_id)
2316 if row is None:
2317 return None
2318
2319 # ── Dedicated column fields ──────────────────────────────────────────────
2320 if patch.name is not None:
2321 row.name = patch.name
2322 if patch.description is not None:
2323 row.description = patch.description
2324 if patch.visibility is not None:
2325 row.visibility = patch.visibility
2326 if patch.topics is not None:
2327 row.tags = patch.topics
2328 if patch.domain_id is not None:
2329 row.domain_id = patch.domain_id
2330
2331 # ── Feature-flag JSON blob ───────────────────────────────────────────────
2332 current_flags = _merge_settings(row.settings)
2333
2334 flag_updates: JSONObject = {}
2335 if patch.default_branch is not None:
2336 flag_updates["default_branch"] = patch.default_branch
2337 if patch.has_issues is not None:
2338 flag_updates["has_issues"] = patch.has_issues
2339 if patch.has_projects is not None:
2340 flag_updates["has_projects"] = patch.has_projects
2341 if patch.has_wiki is not None:
2342 flag_updates["has_wiki"] = patch.has_wiki
2343 if patch.license is not None:
2344 flag_updates["license"] = patch.license
2345 if patch.homepage_url is not None:
2346 flag_updates["homepage_url"] = patch.homepage_url
2347 if patch.allow_merge_commit is not None:
2348 flag_updates["allow_merge_commit"] = patch.allow_merge_commit
2349 if patch.allow_squash_merge is not None:
2350 flag_updates["allow_squash_merge"] = patch.allow_squash_merge
2351 if patch.allow_rebase_merge is not None:
2352 flag_updates["allow_rebase_merge"] = patch.allow_rebase_merge
2353 if patch.delete_branch_on_merge is not None:
2354 flag_updates["delete_branch_on_merge"] = patch.delete_branch_on_merge
2355
2356 if flag_updates:
2357 current_flags.update(flag_updates)
2358 row.settings = current_flags
2359
2360 logger.info("✅ Updated settings for repo %s", repo_id)
2361 return await get_repo_settings(session, repo_id)
File History 2 commits
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 100 days ago
sha256:763eb2cb8675073b84c19345b27586d2ed939a9aee97c5479b69f502f1a70eff fix(tests): update test suite to match current implementation Sonnet 4.6 patch 122 days ago