From 29e855e5b36983ef2852425245692f3843eacc54 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 26 Apr 2026 00:24:02 +0200 Subject: [PATCH] ReviewDecision domain model --- src/repo_registry/core/models.py | 10 +++++++++ src/repo_registry/core/service.py | 8 +++++++ src/repo_registry/storage/sqlite.py | 35 +++++++++++++++++++++++++++++ src/repo_registry/web_api/app.py | 32 ++++++++++++++++++++++++++ src/repo_registry/web_ui/views.py | 29 ++++++++++++++++++++++++ tests/test_registry_service.py | 3 +++ tests/test_web_api.py | 13 +++++++++++ 7 files changed, 130 insertions(+) diff --git a/src/repo_registry/core/models.py b/src/repo_registry/core/models.py index 299f4e6..99c6cd7 100644 --- a/src/repo_registry/core/models.py +++ b/src/repo_registry/core/models.py @@ -36,6 +36,16 @@ class AnalysisRun: scanner_version: str +@dataclass(frozen=True) +class ReviewDecision: + id: int + repository_id: int + analysis_run_id: int | None + action: str + notes: str + created_at: str + + @dataclass(frozen=True) class ObservedFact: id: int diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index df70c06..fa794d9 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -10,6 +10,7 @@ from repo_registry.core.models import ( ObservedFact, Repository, RepositoryAbilityMap, + ReviewDecision, ScanSummary, SearchResult, ) @@ -112,6 +113,13 @@ class RegistryService: def list_capabilities(self) -> list[CapabilitySummary]: return self.store.list_capabilities() + def list_review_decisions( + self, + repository_id: int, + analysis_run_id: int | None = None, + ) -> list[ReviewDecision]: + return self.store.list_review_decisions(repository_id, analysis_run_id) + def list_observed_facts( self, repository_id: int, diff --git a/src/repo_registry/storage/sqlite.py b/src/repo_registry/storage/sqlite.py index 465b7d1..5120a5f 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -21,6 +21,7 @@ from repo_registry.core.models import ( Repository, RepositoryAbilityMap, RepositorySnapshot, + ReviewDecision, SearchResult, SourceReference, ) @@ -929,6 +930,40 @@ class RegistryStore: ) return int(cursor.lastrowid) + def list_review_decisions( + self, + repository_id: int, + analysis_run_id: int | None = None, + ) -> list[ReviewDecision]: + self.get_repository(repository_id) + params: tuple[int, ...] + where = "WHERE repository_id = ?" + params = (repository_id,) + if analysis_run_id is not None: + where += " AND analysis_run_id = ?" + params = (repository_id, analysis_run_id) + with self.connect() as connection: + rows = connection.execute( + f""" + SELECT id, repository_id, analysis_run_id, action, notes, created_at + FROM review_decisions + {where} + ORDER BY created_at DESC, id DESC + """, + params, + ).fetchall() + return [ + ReviewDecision( + id=row["id"], + repository_id=row["repository_id"], + analysis_run_id=row["analysis_run_id"], + action=row["action"], + notes=row["notes"], + created_at=row["created_at"], + ) + for row in rows + ] + def fail_analysis_run( self, repository_id: int, diff --git a/src/repo_registry/web_api/app.py b/src/repo_registry/web_api/app.py index 57383e6..f49563e 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -344,6 +344,38 @@ def get_analysis_run( raise HTTPException(status_code=404, detail=str(exc)) from exc +@app.get("/repos/{repository_id}/review-decisions") +def list_repository_review_decisions( + repository_id: int, + service: RegistryService = Depends(get_service), +) -> list[dict[str, object]]: + try: + return [ + asdict(decision) + for decision in service.list_review_decisions(repository_id) + ] + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@app.get("/repos/{repository_id}/analysis-runs/{analysis_run_id}/review-decisions") +def list_analysis_run_review_decisions( + repository_id: int, + analysis_run_id: int, + service: RegistryService = Depends(get_service), +) -> list[dict[str, object]]: + try: + return [ + asdict(decision) + for decision in service.list_review_decisions( + repository_id, + analysis_run_id, + ) + ] + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @app.get("/repos/{repository_id}/observed-facts") def list_observed_facts( repository_id: int, diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index c45757b..84ecb14 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -267,6 +267,7 @@ def repository_detail( repository = service.get_repository(repository_id) runs = service.list_analysis_runs(repository_id) ability_map = service.ability_map(repository_id) + decisions = service.list_review_decisions(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"}) @@ -307,6 +308,10 @@ def repository_detail( {render_ability_map(asdict(ability_map))} +
+

Review Decisions

+ {render_review_decisions(decisions)} +
""" return page(repository.name, body) @@ -336,6 +341,7 @@ def analysis_run_detail( repository = service.get_repository(repository_id) candidate_graph = service.candidate_graph(repository_id, analysis_run_id) facts = service.list_observed_facts(repository_id, analysis_run_id) + decisions = service.list_review_decisions(repository_id, analysis_run_id) fact_rows = "\n".join( f""" @@ -368,6 +374,8 @@ def analysis_run_detail( KindNamePathValue {fact_rows or 'No observed facts.'} +

Review Decisions

+ {render_review_decisions(decisions)} """ @@ -743,6 +751,27 @@ def render_repository_facts(languages: list[str], frameworks: list[str]) -> str: return f'

{language_pills}{framework_pills}

' +def render_review_decisions(decisions: list) -> str: + if not decisions: + return '

No review decisions yet.

' + rows = "\n".join( + f""" + + {escape(decision.action)} + {escape(decision.created_at)} + {escape(decision.notes)} + + """ + for decision in decisions + ) + return f""" + + + {rows} +
ActionCreatedNotes
+ """ + + 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 275e8aa..c0b405a 100644 --- a/tests/test_registry_service.py +++ b/tests/test_registry_service.py @@ -303,6 +303,9 @@ def test_approve_candidate_graph_publishes_ability_map_once(tmp_path): candidate_graph = service.candidate_graph(repository.id, summary.analysis_run.id) assert candidate_graph.abilities[0].status == "approved" + decisions = service.list_review_decisions(repository.id, summary.analysis_run.id) + assert decisions[0].action == "approve_candidate_graph" + assert decisions[0].notes == "Looks good for the first pass." def test_reject_candidate_ability_excludes_it_from_approval(tmp_path): diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 13f49ea..6d6202c 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -155,6 +155,17 @@ def test_api_analysis_run_loop(tmp_path): ) assert reject_response.status_code == 200 assert reject_response.json()["abilities"][0]["status"] == "rejected" + decisions_response = client.get(f"/repos/{repository_id}/review-decisions") + assert decisions_response.status_code == 200 + assert decisions_response.json()[0]["action"] == "reject_candidate_ability" + run_decisions_response = client.get( + f"/repos/{repository_id}/analysis-runs/" + f"{run['analysis_run']['id']}/review-decisions" + ) + assert run_decisions_response.status_code == 200 + assert run_decisions_response.json()[0]["notes"] == ( + "Reject once to exercise review correction." + ) run_response = client.post(f"/repos/{repository_id}/analysis-runs", json={}) assert run_response.status_code == 201 @@ -302,6 +313,7 @@ def test_ui_register_analyze_and_approve_loop(tmp_path): assert run_detail.status_code == 200 assert "Candidate Graph" in run_detail.text assert "ID " in run_detail.text + assert "No review decisions yet." in run_detail.text approve_response = client.post( f"{run_path}/candidate-graph/approve", @@ -316,6 +328,7 @@ def test_ui_register_analyze_and_approve_loop(tmp_path): assert "Language: Python" in approved_detail.text assert "Framework: FastAPI" in approved_detail.text assert "interface:app.py:3" in approved_detail.text + assert "approve_candidate_graph" in approved_detail.text search_response = client.get("/ui/search", params={"q": "repository"}) assert search_response.status_code == 200