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