musehub_discover.py
python
sha256:8e05daa29ba6702b4a2380a16d690ba31cc099d69c5c859bc6e6f16a0e945f99
Merge 'fix/deploy-memory-limits-and-log-group' into 'dev' —…
Human
5 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 | # A mist's backing repo (domain_id="mist") is VCS plumbing for a |
| 93 | # gist-like artifact, not a project to showcase — exclude it from |
| 94 | # repo discovery. See docs/issues/ for the mists-explore-filter design. |
| 95 | MusehubRepo.domain_id != "mist", |
| 96 | ) |
| 97 | .group_by(MusehubRepo.repo_id) |
| 98 | ) |
| 99 | |
| 100 | # Apply filters ────────────────────────────────────────────────────────── |
| 101 | # Multi-select tag chips — filter by musehub_repos.tags JSON (OR across values). |
| 102 | if langs: |
| 103 | lang_conditions = [ |
| 104 | sql_cast(MusehubRepo.tags, Text).ilike(f"%{escape_like(v.lower())}%", escape="\\") |
| 105 | for v in langs |
| 106 | ] |
| 107 | base_q = base_q.where(or_(*lang_conditions)) |
| 108 | |
| 109 | # Multi-select topic chips — filter on repo.tags JSON (OR across values). |
| 110 | if topics: |
| 111 | topic_conditions = [ |
| 112 | sql_cast(MusehubRepo.tags, Text).ilike(f"%{escape_like(t.lower())}%", escape="\\") |
| 113 | for t in topics |
| 114 | ] |
| 115 | base_q = base_q.where(or_(*topic_conditions)) |
| 116 | |
| 117 | # License filter — exact match on settings['license'] JSON key. |
| 118 | # json_extract_path_text is the Postgres function equivalent to ->>, |
| 119 | # returning unquoted text (unlike -> which returns JSON-encoded "MIT"). |
| 120 | if license: |
| 121 | base_q = base_q.where( |
| 122 | func.json_extract_path_text(MusehubRepo.settings, "license") == license |
| 123 | ) |
| 124 | |
| 125 | # Count total results before pagination ────────────────────────────────── |
| 126 | count_q = select(func.count()).select_from(base_q.subquery()) |
| 127 | total: int = (await session.execute(count_q)).scalar_one() |
| 128 | |
| 129 | # Apply sort ───────────────────────────────────────────────────────────── |
| 130 | if sort == "activity": |
| 131 | base_q = base_q.order_by(desc("latest_commit"), desc(MusehubRepo.created_at)) |
| 132 | elif sort == "commits": |
| 133 | base_q = base_q.order_by(desc("commit_count"), desc(MusehubRepo.created_at)) |
| 134 | elif sort == "trending": |
| 135 | base_q = base_q.order_by( |
| 136 | desc(commit_count_col), |
| 137 | desc("latest_commit"), |
| 138 | desc(MusehubRepo.created_at), |
| 139 | ) |
| 140 | else: # "created" |
| 141 | base_q = base_q.order_by(desc(MusehubRepo.created_at)) |
| 142 | |
| 143 | # Apply cursor: filter rows where created_at < cursor_dt (all sorts use DESC) |
| 144 | # Normalize space→+ because URL-decoding can corrupt the ISO timezone offset (+00:00). |
| 145 | if cursor: |
| 146 | try: |
| 147 | cursor_dt = _datetime.fromisoformat(cursor.replace(" ", "+")) |
| 148 | base_q = base_q.where(MusehubRepo.created_at < cursor_dt) |
| 149 | except ValueError: |
| 150 | pass # ignore malformed cursor, start from beginning |
| 151 | |
| 152 | rows = (await session.execute(base_q.limit(page_size + 1))).all() |
| 153 | has_more = len(rows) > page_size |
| 154 | page_rows = rows[:page_size] |
| 155 | next_cursor_val = page_rows[-1].MusehubRepo.created_at.isoformat() if (page_rows and has_more) else None |
| 156 | |
| 157 | results = [] |
| 158 | for row in page_rows: |
| 159 | results.append(ExploreRepoResult( |
| 160 | repo_id=row.MusehubRepo.repo_id, |
| 161 | name=row.MusehubRepo.name, |
| 162 | owner=row.MusehubRepo.owner, |
| 163 | slug=row.MusehubRepo.slug, |
| 164 | owner_user_id=row.MusehubRepo.owner_user_id, |
| 165 | description=row.MusehubRepo.description, |
| 166 | tags=list(row.MusehubRepo.tags or []), |
| 167 | commit_count=row.commit_count or 0, |
| 168 | created_at=row.MusehubRepo.created_at, |
| 169 | pushed_at=row.MusehubRepo.pushed_at, |
| 170 | )) |
| 171 | |
| 172 | logger.debug("✅ Explore query: %d/%d repos (cursor=%r, sort=%s)", len(results), total, cursor, sort) |
| 173 | return ExploreResponse(repos=results, total=total, next_cursor=next_cursor_val) |
| 174 | |
| 175 | |
| 176 | async def search_repos_by_text( |
| 177 | session: AsyncSession, |
| 178 | q: str, |
| 179 | *, |
| 180 | limit: int = 20, |
| 181 | ) -> list[ExploreRepoResult]: |
| 182 | """Search public repos across name, slug, description, and tags. |
| 183 | |
| 184 | Returns up to ``limit`` results ordered by commit count. |
| 185 | """ |
| 186 | pattern = f"%{escape_like(q.lower())}%" |
| 187 | pattern_hyph = f"%{escape_like(q.lower().replace(' ', '-'))}%" |
| 188 | commit_count_col = func.count(MusehubCommitRef.commit_id).label("commit_count") |
| 189 | |
| 190 | stmt = ( |
| 191 | select(MusehubRepo, commit_count_col) |
| 192 | .select_from(MusehubRepo) |
| 193 | .outerjoin(MusehubCommitRef, MusehubRepo.repo_id == MusehubCommitRef.repo_id) |
| 194 | .where( |
| 195 | MusehubRepo.visibility == "public", |
| 196 | MusehubRepo.domain_id != "mist", |
| 197 | or_( |
| 198 | func.lower(MusehubRepo.name).ilike(pattern, escape="\\"), |
| 199 | func.lower(MusehubRepo.name).ilike(pattern_hyph, escape="\\"), |
| 200 | func.lower(MusehubRepo.slug).ilike(pattern, escape="\\"), |
| 201 | func.lower(MusehubRepo.description).ilike(pattern, escape="\\"), |
| 202 | func.lower(MusehubRepo.description).ilike(pattern_hyph, escape="\\"), |
| 203 | sql_cast(MusehubRepo.tags, Text).ilike(pattern, escape="\\"), |
| 204 | ), |
| 205 | ) |
| 206 | .group_by(MusehubRepo.repo_id) |
| 207 | .order_by(desc(commit_count_col)) |
| 208 | .limit(limit) |
| 209 | ) |
| 210 | |
| 211 | rows = (await session.execute(stmt)).all() |
| 212 | results: list[ExploreRepoResult] = [] |
| 213 | for row in rows: |
| 214 | results.append( |
| 215 | ExploreRepoResult( |
| 216 | repo_id=row.MusehubRepo.repo_id, |
| 217 | name=row.MusehubRepo.name, |
| 218 | owner=row.MusehubRepo.owner, |
| 219 | slug=row.MusehubRepo.slug, |
| 220 | owner_user_id=row.MusehubRepo.owner_user_id, |
| 221 | description=row.MusehubRepo.description, |
| 222 | tags=list(row.MusehubRepo.tags or []), |
| 223 | commit_count=row.commit_count or 0, |
| 224 | created_at=row.MusehubRepo.created_at, |
| 225 | ) |
| 226 | ) |
| 227 | logger.debug("🔍 text search '%s' → %d repos", q, len(results)) |
| 228 | return results |
| 229 | |
| 230 |
File History
3 commits
sha256:8e05daa29ba6702b4a2380a16d690ba31cc099d69c5c859bc6e6f16a0e945f99
Merge 'fix/deploy-memory-limits-and-log-group' into 'dev' —…
Human
5 days ago
sha256:3fadb0439bba9451b89229676971c0d4a40900dec7810e9d5f8791b8d950d505
fix: install.sh version from latest published tarball, not …
Sonnet 4.6
minor
⚠
96 days ago
sha256:763eb2cb8675073b84c19345b27586d2ed939a9aee97c5479b69f502f1a70eff
fix(tests): update test suite to match current implementation
Sonnet 4.6
patch
118 days ago