musehub_discover.py
python
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352
Merge branch 'task/version-tags-phase3-server' into dev
Human
19 days ago
| 1 | """MuseHub discover/explore service — public repo discovery with filtering and sorting. |
| 2 | |
| 3 | This module is the ONLY place that executes the discover query. Route handlers |
| 4 | delegate here; no filtering or sorting 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 | Sort semantics: |
| 12 | "activity" — repos with the most recent commit first |
| 13 | "commits" — repos with the highest total commit count first |
| 14 | "created" — newest repos first (default for explore page) |
| 15 | "trending" — repos sorted by commit volume and recency |
| 16 | |
| 17 | Tag filtering uses a ``cast(tags, Text).ilike`` pattern on the JSON ``tags`` |
| 18 | column rather than JSON containment operators — simple and sufficient at this scale. |
| 19 | """ |
| 20 | |
| 21 | import logging |
| 22 | from typing import Literal |
| 23 | |
| 24 | from sqlalchemy import Text, cast as sql_cast, desc, func, or_, outerjoin, select, and_ |
| 25 | from sqlalchemy.ext.asyncio import AsyncSession |
| 26 | |
| 27 | from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubRepo |
| 28 | from musehub.db.utils import escape_like |
| 29 | from musehub.db import muse_cli_models as cli_db |
| 30 | from musehub.models.musehub import ( |
| 31 | ExploreRepoResult, |
| 32 | ExploreResponse, |
| 33 | ) |
| 34 | |
| 35 | logger = logging.getLogger(__name__) |
| 36 | |
| 37 | SortField = Literal["activity", "commits", "created", "trending"] |
| 38 | |
| 39 | _PAGE_SIZE_MAX = 100 |
| 40 | |
| 41 | |
| 42 | async def list_public_repos( |
| 43 | session: AsyncSession, |
| 44 | *, |
| 45 | langs: list[str] | None = None, |
| 46 | topics: list[str] | None = None, |
| 47 | license: str | None = None, |
| 48 | sort: SortField = "created", |
| 49 | cursor: str | None = None, |
| 50 | page_size: int = 24, |
| 51 | ) -> ExploreResponse: |
| 52 | """Return a cursor-paginated list of public repos that match the given filters. |
| 53 | |
| 54 | Only repos with ``visibility = 'public'`` are returned. All filter parameters |
| 55 | are optional; omitting them returns all public repos in the requested sort order. |
| 56 | |
| 57 | Args: |
| 58 | session: Async DB session. |
| 59 | langs: Multi-select language/topic chips — repo must have at least one |
| 60 | matching tag in the muse_tags table (OR across selections). |
| 61 | topics: Multi-select topic chips — repo.tags JSON must contain at least one |
| 62 | of the selected values (OR across selections). |
| 63 | license: Exact match against ``settings['license']`` (e.g. "CC BY"). |
| 64 | sort: One of "activity", "commits", "created", "trending". Defaults to "activity". |
| 65 | cursor: Opaque cursor from a previous nextCursor response field. |
| 66 | page_size: Number of results per page (clamped to _PAGE_SIZE_MAX). |
| 67 | |
| 68 | Returns: |
| 69 | ExploreResponse with repo cards and pagination metadata. |
| 70 | """ |
| 71 | from datetime import datetime as _datetime |
| 72 | |
| 73 | page_size = min(page_size, _PAGE_SIZE_MAX) |
| 74 | |
| 75 | # Aggregated sub-expressions ───────────────────────────────────────────── |
| 76 | commit_count_col = func.count(MusehubCommitRef.commit_id).label("commit_count") |
| 77 | latest_commit_col = func.max(MusehubCommit.timestamp).label("latest_commit") |
| 78 | |
| 79 | # Build the base aggregated query over public repos. |
| 80 | # Left-join through commit_refs so repos with zero commits are included. |
| 81 | base_q = ( |
| 82 | select( |
| 83 | MusehubRepo, |
| 84 | commit_count_col, |
| 85 | latest_commit_col, |
| 86 | ) |
| 87 | .select_from(MusehubRepo) |
| 88 | .outerjoin(MusehubCommitRef, MusehubRepo.repo_id == MusehubCommitRef.repo_id) |
| 89 | .outerjoin(MusehubCommit, MusehubCommitRef.commit_id == MusehubCommit.commit_id) |
| 90 | .where( |
| 91 | MusehubRepo.visibility == "public", |
| 92 | ) |
| 93 | .group_by(MusehubRepo.repo_id) |
| 94 | ) |
| 95 | |
| 96 | # Apply filters ────────────────────────────────────────────────────────── |
| 97 | # Multi-select tag chips — filter by musehub_repos.tags JSON (OR across values). |
| 98 | if langs: |
| 99 | lang_conditions = [ |
| 100 | sql_cast(MusehubRepo.tags, Text).ilike(f"%{escape_like(v.lower())}%", escape="\\") |
| 101 | for v in langs |
| 102 | ] |
| 103 | base_q = base_q.where(or_(*lang_conditions)) |
| 104 | |
| 105 | # Multi-select topic chips — filter on repo.tags JSON (OR across values). |
| 106 | if topics: |
| 107 | topic_conditions = [ |
| 108 | sql_cast(MusehubRepo.tags, Text).ilike(f"%{escape_like(t.lower())}%", escape="\\") |
| 109 | for t in topics |
| 110 | ] |
| 111 | base_q = base_q.where(or_(*topic_conditions)) |
| 112 | |
| 113 | # License filter — exact match on settings['license'] JSON key. |
| 114 | # json_extract_path_text is the Postgres function equivalent to ->>, |
| 115 | # returning unquoted text (unlike -> which returns JSON-encoded "MIT"). |
| 116 | if license: |
| 117 | base_q = base_q.where( |
| 118 | func.json_extract_path_text(MusehubRepo.settings, "license") == license |
| 119 | ) |
| 120 | |
| 121 | # Count total results before pagination ────────────────────────────────── |
| 122 | count_q = select(func.count()).select_from(base_q.subquery()) |
| 123 | total: int = (await session.execute(count_q)).scalar_one() |
| 124 | |
| 125 | # Apply sort ───────────────────────────────────────────────────────────── |
| 126 | if sort == "activity": |
| 127 | base_q = base_q.order_by(desc("latest_commit"), desc(MusehubRepo.created_at)) |
| 128 | elif sort == "commits": |
| 129 | base_q = base_q.order_by(desc("commit_count"), desc(MusehubRepo.created_at)) |
| 130 | elif sort == "trending": |
| 131 | base_q = base_q.order_by( |
| 132 | desc(commit_count_col), |
| 133 | desc("latest_commit"), |
| 134 | desc(MusehubRepo.created_at), |
| 135 | ) |
| 136 | else: # "created" |
| 137 | base_q = base_q.order_by(desc(MusehubRepo.created_at)) |
| 138 | |
| 139 | # Apply cursor: filter rows where created_at < cursor_dt (all sorts use DESC) |
| 140 | # Normalize space→+ because URL-decoding can corrupt the ISO timezone offset (+00:00). |
| 141 | if cursor: |
| 142 | try: |
| 143 | cursor_dt = _datetime.fromisoformat(cursor.replace(" ", "+")) |
| 144 | base_q = base_q.where(MusehubRepo.created_at < cursor_dt) |
| 145 | except ValueError: |
| 146 | pass # ignore malformed cursor, start from beginning |
| 147 | |
| 148 | rows = (await session.execute(base_q.limit(page_size + 1))).all() |
| 149 | has_more = len(rows) > page_size |
| 150 | page_rows = rows[:page_size] |
| 151 | next_cursor_val = page_rows[-1].MusehubRepo.created_at.isoformat() if (page_rows and has_more) else None |
| 152 | |
| 153 | results = [] |
| 154 | for row in page_rows: |
| 155 | results.append(ExploreRepoResult( |
| 156 | repo_id=row.MusehubRepo.repo_id, |
| 157 | name=row.MusehubRepo.name, |
| 158 | owner=row.MusehubRepo.owner, |
| 159 | slug=row.MusehubRepo.slug, |
| 160 | owner_user_id=row.MusehubRepo.owner_user_id, |
| 161 | description=row.MusehubRepo.description, |
| 162 | tags=list(row.MusehubRepo.tags or []), |
| 163 | commit_count=row.commit_count or 0, |
| 164 | created_at=row.MusehubRepo.created_at, |
| 165 | pushed_at=row.MusehubRepo.pushed_at, |
| 166 | )) |
| 167 | |
| 168 | logger.debug("✅ Explore query: %d/%d repos (cursor=%r, sort=%s)", len(results), total, cursor, sort) |
| 169 | return ExploreResponse(repos=results, total=total, next_cursor=next_cursor_val) |
| 170 | |
| 171 | |
| 172 | async def search_repos_by_text( |
| 173 | session: AsyncSession, |
| 174 | q: str, |
| 175 | *, |
| 176 | limit: int = 20, |
| 177 | ) -> list[ExploreRepoResult]: |
| 178 | """Search public repos across name, slug, description, and tags. |
| 179 | |
| 180 | Returns up to ``limit`` results ordered by commit count. |
| 181 | """ |
| 182 | pattern = f"%{escape_like(q.lower())}%" |
| 183 | pattern_hyph = f"%{escape_like(q.lower().replace(' ', '-'))}%" |
| 184 | commit_count_col = func.count(MusehubCommitRef.commit_id).label("commit_count") |
| 185 | |
| 186 | stmt = ( |
| 187 | select(MusehubRepo, commit_count_col) |
| 188 | .select_from(MusehubRepo) |
| 189 | .outerjoin(MusehubCommitRef, MusehubRepo.repo_id == MusehubCommitRef.repo_id) |
| 190 | .where( |
| 191 | MusehubRepo.visibility == "public", |
| 192 | or_( |
| 193 | func.lower(MusehubRepo.name).ilike(pattern, escape="\\"), |
| 194 | func.lower(MusehubRepo.name).ilike(pattern_hyph, escape="\\"), |
| 195 | func.lower(MusehubRepo.slug).ilike(pattern, escape="\\"), |
| 196 | func.lower(MusehubRepo.description).ilike(pattern, escape="\\"), |
| 197 | func.lower(MusehubRepo.description).ilike(pattern_hyph, escape="\\"), |
| 198 | sql_cast(MusehubRepo.tags, Text).ilike(pattern, escape="\\"), |
| 199 | ), |
| 200 | ) |
| 201 | .group_by(MusehubRepo.repo_id) |
| 202 | .order_by(desc(commit_count_col)) |
| 203 | .limit(limit) |
| 204 | ) |
| 205 | |
| 206 | rows = (await session.execute(stmt)).all() |
| 207 | results: list[ExploreRepoResult] = [] |
| 208 | for row in rows: |
| 209 | results.append( |
| 210 | ExploreRepoResult( |
| 211 | repo_id=row.MusehubRepo.repo_id, |
| 212 | name=row.MusehubRepo.name, |
| 213 | owner=row.MusehubRepo.owner, |
| 214 | slug=row.MusehubRepo.slug, |
| 215 | owner_user_id=row.MusehubRepo.owner_user_id, |
| 216 | description=row.MusehubRepo.description, |
| 217 | tags=list(row.MusehubRepo.tags or []), |
| 218 | commit_count=row.commit_count or 0, |
| 219 | created_at=row.MusehubRepo.created_at, |
| 220 | ) |
| 221 | ) |
| 222 | logger.debug("🔍 text search '%s' → %d repos", q, len(results)) |
| 223 | return results |
| 224 | |
| 225 |
File History
10 commits
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352
Merge branch 'task/version-tags-phase3-server' into dev
Human
19 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53
merge: rescue snapshot-recovery hardening (c00aa21d) into d…
Opus 4.8
minor
⚠
31 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a
fix: remove false-positive proposal_comments index drop fro…
Sonnet 4.6
patch
35 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3
feat: render markdown mists as HTML with heading anchor links
Sonnet 4.6
patch
36 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6
Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump …
Human
37 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee
Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump …
Human
37 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c
Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo…
Human
37 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd
Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop…
Human
37 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f
fix: use wire_bytes not mpack_bytes_raw in compute_object_b…
Sonnet 4.6
patch
50 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583
rename: delta_add → delta_upsert across wire format, models…
Sonnet 4.6
patch
52 days ago