From 8d1e1ff5834d7043983f96abc01601ea0a484641 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 26 Apr 2026 00:04:19 +0200 Subject: [PATCH] search filters and inspection polish --- src/repo_registry/core/service.py | 16 ++++- src/repo_registry/storage/sqlite.py | 104 +++++++++++++++++++++++----- src/repo_registry/web_api/app.py | 13 +++- src/repo_registry/web_ui/views.py | 44 +++++++++++- tests/test_registry_service.py | 37 ++++++++++ tests/test_web_api.py | 17 +++++ 6 files changed, 209 insertions(+), 22 deletions(-) diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index 1566c45..6bbe187 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -580,5 +580,17 @@ class RegistryService: def ability_map(self, repository_id: int) -> RepositoryAbilityMap: return self.store.get_ability_map(repository_id) - def search(self, query: str) -> list[SearchResult]: - return self.store.search(query) + def search( + self, + query: str, + *, + status: str | None = None, + language: str | None = None, + framework: str | None = None, + ) -> list[SearchResult]: + return self.store.search( + query, + status=status, + language=language, + framework=framework, + ) diff --git a/src/repo_registry/storage/sqlite.py b/src/repo_registry/storage/sqlite.py index f0ccaf3..a75b88e 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -1275,35 +1275,55 @@ class RegistryStore: ] return RepositoryAbilityMap(repository=repository, abilities=abilities) - def search(self, query: str) -> list[SearchResult]: + def search( + self, + query: str, + *, + status: str | None = None, + language: str | None = None, + framework: str | None = None, + ) -> list[SearchResult]: term = query.strip() needle = f"%{term}%" if not term: return [] with self.connect() as connection: + repository_ids = self._search_filter_repository_ids( + connection, + status=status, + language=language, + framework=framework, + ) + if repository_ids is not None and not repository_ids: + return [] + repository_filter, repository_params = self._repository_filter_sql( + repository_ids, + ) repository_rows = connection.execute( - """ + f""" SELECT r.id AS repository_id, r.name AS repository_name, r.description FROM repositories r - WHERE r.name LIKE ? OR COALESCE(r.description, '') LIKE ? + WHERE (r.name LIKE ? OR COALESCE(r.description, '') LIKE ?) + {repository_filter} """, - (needle, needle), + (needle, needle, *repository_params), ).fetchall() ability_rows = connection.execute( - """ + f""" SELECT r.id AS repository_id, r.name AS repository_name, a.id AS ability_id, a.name AS ability_name, a.description AS ability_description, a.confidence FROM approved_abilities a JOIN repositories r ON r.id = a.repository_id - WHERE a.name LIKE ? OR a.description LIKE ? + WHERE (a.name LIKE ? OR a.description LIKE ?) + {repository_filter} """, - (needle, needle), + (needle, needle, *repository_params), ).fetchall() capability_rows = connection.execute( - """ + f""" SELECT r.id AS repository_id, r.name AS repository_name, a.id AS ability_id, a.name AS ability_name, c.id AS capability_id, c.name AS capability_name, @@ -1311,12 +1331,13 @@ class RegistryStore: FROM approved_capabilities c JOIN approved_abilities a ON a.id = c.ability_id JOIN repositories r ON r.id = c.repository_id - WHERE c.name LIKE ? OR c.description LIKE ? + WHERE (c.name LIKE ? OR c.description LIKE ?) + {repository_filter} """, - (needle, needle), + (needle, needle, *repository_params), ).fetchall() feature_rows = connection.execute( - """ + f""" SELECT r.id AS repository_id, r.name AS repository_name, a.id AS ability_id, a.name AS ability_name, c.id AS capability_id, c.name AS capability_name, @@ -1326,12 +1347,13 @@ class RegistryStore: JOIN approved_capabilities c ON c.id = f.capability_id JOIN approved_abilities a ON a.id = c.ability_id JOIN repositories r ON r.id = f.repository_id - WHERE f.name LIKE ? OR f.type LIKE ? OR f.location LIKE ? + WHERE (f.name LIKE ? OR f.type LIKE ? OR f.location LIKE ?) + {repository_filter} """, - (needle, needle, needle), + (needle, needle, needle, *repository_params), ).fetchall() evidence_rows = connection.execute( - """ + f""" SELECT r.id AS repository_id, r.name AS repository_name, a.id AS ability_id, a.name AS ability_name, c.id AS capability_id, c.name AS capability_name, @@ -1340,9 +1362,10 @@ class RegistryStore: JOIN approved_capabilities c ON c.id = e.capability_id JOIN approved_abilities a ON a.id = c.ability_id JOIN repositories r ON r.id = e.repository_id - WHERE e.type LIKE ? OR e.reference LIKE ? OR e.strength LIKE ? + WHERE (e.type LIKE ? OR e.reference LIKE ? OR e.strength LIKE ?) + {repository_filter} """, - (needle, needle, needle), + (needle, needle, needle, *repository_params), ).fetchall() results: list[SearchResult] = [] @@ -1472,6 +1495,55 @@ class RegistryStore: def _evidence_confidence(self, strength: str) -> float: return {"strong": 0.9, "medium": 0.6, "weak": 0.3}.get(strength, 0.5) + def _search_filter_repository_ids( + self, + connection: sqlite3.Connection, + *, + status: str | None, + language: str | None, + framework: str | None, + ) -> list[int] | None: + filters: list[set[int]] = [] + if status: + rows = connection.execute( + "SELECT id FROM repositories WHERE status = ?", + (status,), + ).fetchall() + filters.append({row["id"] for row in rows}) + if language: + rows = connection.execute( + """ + SELECT DISTINCT repository_id + FROM observed_facts + WHERE kind = 'language' AND name LIKE ? + """, + (language,), + ).fetchall() + filters.append({row["repository_id"] for row in rows}) + if framework: + rows = connection.execute( + """ + SELECT DISTINCT repository_id + FROM observed_facts + WHERE kind = 'framework' AND name LIKE ? + """, + (framework,), + ).fetchall() + filters.append({row["repository_id"] for row in rows}) + if not filters: + return None + repository_ids = set.intersection(*filters) + return sorted(repository_ids) + + def _repository_filter_sql( + self, + repository_ids: list[int] | None, + ) -> tuple[str, tuple[int, ...]]: + if repository_ids is None: + return "", () + placeholders = ", ".join("?" for _ in repository_ids) + return f"AND r.id IN ({placeholders})", tuple(repository_ids) + def _insert_facts( self, connection: sqlite3.Connection, diff --git a/src/repo_registry/web_api/app.py b/src/repo_registry/web_api/app.py index f65f1d8..6dcc34a 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -768,9 +768,20 @@ def get_ability_map( @app.get("/search") def search( q: str, + status: str | None = None, + language: str | None = None, + framework: str | None = None, service: RegistryService = Depends(get_service), ) -> list[dict[str, object]]: - return [asdict(result) for result in service.search(q)] + return [ + asdict(result) + for result in service.search( + q, + status=status, + language=language, + framework=framework, + ) + ] @app.get("/abilities") diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index a16b2da..4b619d6 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -176,9 +176,21 @@ def repository_index(service: RegistryService = Depends(get_service)) -> HTMLRes @router.get("/ui/search") def search_page( q: str = "", + status: str = "", + language: str = "", + framework: str = "", service: RegistryService = Depends(get_service), ) -> HTMLResponse: - results = service.search(q) if q.strip() else [] + results = ( + service.search( + q, + status=status or None, + language=language or None, + framework=framework or None, + ) + if q.strip() + else [] + ) rows = "\n".join( f""" @@ -205,9 +217,17 @@ def search_page( Repositories
-
+ - +
+ + + +
+
+ + Clear +
@@ -241,6 +261,9 @@ def repository_detail( repository = service.get_repository(repository_id) runs = service.list_analysis_runs(repository_id) ability_map = service.ability_map(repository_id) + facts = service.list_observed_facts(repository_id) + languages = sorted({fact.name for fact in facts if fact.kind == "language"}) + frameworks = sorted({fact.name for fact in facts if fact.kind == "framework"}) run_rows = "\n".join( f""" @@ -259,6 +282,7 @@ def repository_detail(

{escape(repository.description or '')}

{escape(repository.status)} {escape(repository.url)}

+ {render_repository_facts(languages, frameworks)}

Run Analysis

@@ -699,6 +723,20 @@ def render_candidate_graph(graph: dict, repository_id: int, analysis_run_id: int return f'
    {"".join(items)}
' +def render_repository_facts(languages: list[str], frameworks: list[str]) -> str: + if not languages and not frameworks: + return "" + language_pills = "".join( + f'Language: {escape(language)}' + for language in languages + ) + framework_pills = "".join( + f'Framework: {escape(framework)}' + for framework in frameworks + ) + return f'

{language_pills}{framework_pills}

' + + def render_candidate_ability_actions( ability: dict, repository_id: int, diff --git a/tests/test_registry_service.py b/tests/test_registry_service.py index 04a08d8..3a6cf37 100644 --- a/tests/test_registry_service.py +++ b/tests/test_registry_service.py @@ -137,6 +137,43 @@ def test_search_matches_features_and_evidence_with_context(tmp_path): assert evidence_results[0].confidence == 0.9 +def test_search_filters_by_status_language_and_framework(tmp_path): + source = tmp_path / "repo" + source.mkdir() + (source / "README.md").write_text("# Filterable\n", encoding="utf-8") + (source / "requirements.txt").write_text("fastapi\n", encoding="utf-8") + (source / "app.py").write_text( + "from fastapi import FastAPI\n" + "app = FastAPI()\n" + '@app.get("/health")\n' + "def health():\n" + " return {}\n", + encoding="utf-8", + ) + + service = make_service(tmp_path) + repository = service.register_repository(name="Filterable", url=str(source)) + summary = service.analyze_repository(repository.id) + service.approve_candidate_graph(repository.id, summary.analysis_run.id) + + results = service.search( + "repository", + status="indexed", + language="Python", + framework="FastAPI", + ) + wrong_language_results = service.search( + "repository", + status="indexed", + language="TypeScript", + framework="FastAPI", + ) + + assert results + assert {result.repository_name for result in results} == {"Filterable"} + assert wrong_language_results == [] + + def test_register_repository_imports_metadata_when_name_is_omitted(tmp_path): source = tmp_path / "metadata-source" source.mkdir() diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 38ff6fd..efb3f5c 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -213,6 +213,13 @@ def test_api_analysis_run_loop(tmp_path): assert search_response.json() assert "matched_field" in search_response.json()[0] + filtered_search_response = client.get( + "/search", + params={"q": "frontend", "status": "indexed"}, + ) + assert filtered_search_response.status_code == 200 + assert filtered_search_response.json() + abilities_response = client.get("/abilities") assert abilities_response.status_code == 200 assert abilities_response.json()[0]["name"] == "Frontend Delivery" @@ -240,6 +247,7 @@ def test_ui_register_analyze_and_approve_loop(tmp_path): source = tmp_path / "repo" source.mkdir() (source / "README.md").write_text("# UI Repo\n", encoding="utf-8") + (source / "requirements.txt").write_text("fastapi\n", encoding="utf-8") (source / "app.py").write_text( "from fastapi import FastAPI\n" "app = FastAPI()\n" @@ -300,11 +308,20 @@ def test_ui_register_analyze_and_approve_loop(tmp_path): assert approved_detail.status_code == 200 assert "Approved Ability Map" in approved_detail.text assert "Review UI Repo Repository Usefulness" in approved_detail.text + assert "Language: Python" in approved_detail.text + assert "Framework: FastAPI" in approved_detail.text search_response = client.get("/ui/search", params={"q": "repository"}) assert search_response.status_code == 200 assert "UI Repo" in search_response.text assert "Field" in search_response.text + + filtered_search_response = client.get( + "/ui/search", + params={"q": "repository", "status": "indexed", "language": "Python"}, + ) + assert filtered_search_response.status_code == 200 + assert "UI Repo" in filtered_search_response.text finally: app.dependency_overrides.clear()