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