gabriel / musehub public
musehub_discover.py python
230 lines 9.5 KB
Raw
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