From 5503b9761ec759be3227712ac3ea883dd8293a3a Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 25 Apr 2026 23:27:28 +0200 Subject: [PATCH] advanced the review workflow --- src/repo_registry/core/service.py | 28 ++++++++++++++ src/repo_registry/storage/sqlite.py | 58 +++++++++++++++++++++++++++++ src/repo_registry/web_api/app.py | 28 ++++++++++++++ src/repo_registry/web_ui/views.py | 45 +++++++++++++++++++++- tests/test_registry_service.py | 37 ++++++++++++++++++ tests/test_web_api.py | 14 +++++++ 6 files changed, 208 insertions(+), 2 deletions(-) diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index 57cd923..a0cf827 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -130,6 +130,8 @@ class RegistryService: confidence=ability.confidence, ) for capability in ability.capabilities: + if capability.status != "candidate": + continue approved_capability_id = self.store.create_capability( repository_id, approved_ability_id, @@ -140,6 +142,8 @@ class RegistryService: confidence=capability.confidence, ) for feature in capability.features: + if feature.status != "candidate": + continue self.store.create_feature( repository_id, approved_capability_id, @@ -149,6 +153,8 @@ class RegistryService: confidence=feature.confidence, ) for evidence in capability.evidence: + if evidence.status != "candidate": + continue self.store.create_evidence( repository_id, approved_capability_id, @@ -172,6 +178,28 @@ class RegistryService: self.store.update_repository_status(repository_id, "indexed") return self.store.get_ability_map(repository_id) + def reject_candidate_ability( + self, + repository_id: int, + analysis_run_id: int, + candidate_ability_id: int, + *, + notes: str = "", + ) -> CandidateGraph: + self.store.reject_candidate_ability( + repository_id, + analysis_run_id, + candidate_ability_id, + ) + self.store.create_review_decision( + repository_id, + analysis_run_id, + action="reject_candidate_ability", + notes=notes, + ) + self.store.update_repository_status(repository_id, "reviewing") + return self.store.get_candidate_graph(repository_id, analysis_run_id) + def add_ability( self, repository_id: int, diff --git a/src/repo_registry/storage/sqlite.py b/src/repo_registry/storage/sqlite.py index 9eb6e1c..0038d77 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -383,6 +383,64 @@ class RegistryStore: (status, repository_id, analysis_run_id), ) + def reject_candidate_ability( + self, + repository_id: int, + analysis_run_id: int, + candidate_ability_id: int, + ) -> None: + with self.connect() as connection: + ability_cursor = connection.execute( + """ + UPDATE candidate_abilities + SET status = 'rejected' + WHERE id = ? + AND repository_id = ? + AND analysis_run_id = ? + AND status = 'candidate' + """, + (candidate_ability_id, repository_id, analysis_run_id), + ) + if ability_cursor.rowcount == 0: + raise NotFoundError( + "candidate ability " + f"{candidate_ability_id} was not found for repository " + f"{repository_id} analysis run {analysis_run_id}" + ) + capability_rows = connection.execute( + """ + SELECT id FROM candidate_capabilities + WHERE ability_id = ? AND repository_id = ? AND analysis_run_id = ? + """, + (candidate_ability_id, repository_id, analysis_run_id), + ).fetchall() + capability_ids = [row["id"] for row in capability_rows] + connection.execute( + """ + UPDATE candidate_capabilities + SET status = 'rejected' + WHERE ability_id = ? AND repository_id = ? AND analysis_run_id = ? + """, + (candidate_ability_id, repository_id, analysis_run_id), + ) + for capability_id in capability_ids: + connection.execute( + """ + UPDATE candidate_features + SET status = 'rejected' + WHERE capability_id = ? AND repository_id = ? AND analysis_run_id = ? + """, + (capability_id, repository_id, analysis_run_id), + ) + connection.execute( + """ + UPDATE candidate_evidence + SET status = 'rejected' + WHERE capability_id = ? AND repository_id = ? AND analysis_run_id = ? + """, + (capability_id, repository_id, analysis_run_id), + ) + def create_review_decision( self, repository_id: int, diff --git a/src/repo_registry/web_api/app.py b/src/repo_registry/web_api/app.py index 5f69f5c..1d25000 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -73,6 +73,10 @@ class CandidateGraphApproval(BaseModel): notes: str = "" +class CandidateRejection(BaseModel): + notes: str = "" + + app = FastAPI(title="Repository Ability Registry", version="0.1.0") @@ -189,6 +193,30 @@ def approve_candidate_graph( raise HTTPException(status_code=404, detail=str(exc)) from exc +@app.post( + "/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-abilities/{candidate_ability_id}/reject" +) +def reject_candidate_ability( + repository_id: int, + analysis_run_id: int, + candidate_ability_id: int, + payload: CandidateRejection, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.reject_candidate_ability( + repository_id, + analysis_run_id, + candidate_ability_id, + notes=payload.notes, + ) + ) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @app.post("/repos/{repository_id}/abilities", status_code=201) def create_ability( repository_id: int, diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index 034c4d7..9618d2c 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -280,7 +280,7 @@ def analysis_run_detail( - {render_candidate_graph(asdict(candidate_graph))} + {render_candidate_graph(asdict(candidate_graph), repository_id, analysis_run_id)}

Observed Facts

@@ -308,7 +308,29 @@ def approve_candidate_graph_from_form( return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) -def render_candidate_graph(graph: dict) -> str: +@router.post( + "/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-abilities/{candidate_ability_id}/reject" +) +def reject_candidate_ability_from_form( + repository_id: int, + analysis_run_id: int, + candidate_ability_id: int, + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.reject_candidate_ability( + repository_id, + analysis_run_id, + candidate_ability_id, + notes="Rejected from web UI", + ) + return RedirectResponse( + f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}", + status_code=303, + ) + + +def render_candidate_graph(graph: dict, repository_id: int, analysis_run_id: int) -> str: abilities = graph.get("abilities", []) if not abilities: return '

No candidates generated.

' @@ -321,6 +343,7 @@ def render_candidate_graph(graph: dict) -> str: {escape(ability['name'])} {escape(ability['status'])} {ability['confidence']:.2f} + {render_candidate_ability_actions(ability, repository_id, analysis_run_id)}

{escape(ability['description'])}

{render_sources(ability['source_refs'])} @@ -330,6 +353,24 @@ def render_candidate_graph(graph: dict) -> str: return f'
    {"".join(items)}
' +def render_candidate_ability_actions( + ability: dict, + repository_id: int, + analysis_run_id: int, +) -> str: + if ability["status"] != "candidate": + return "" + action = ( + f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" + f"/candidate-abilities/{ability['id']}/reject" + ) + return f""" +
+ +
+ """ + + def render_candidate_capability(capability: dict) -> str: features = "".join( f'
  • {escape(feature["name"])} {escape(feature["type"])} {escape(feature["location"])}
  • ' diff --git a/tests/test_registry_service.py b/tests/test_registry_service.py index df59f65..5669f57 100644 --- a/tests/test_registry_service.py +++ b/tests/test_registry_service.py @@ -206,6 +206,43 @@ def test_approve_candidate_graph_publishes_ability_map_once(tmp_path): assert candidate_graph.abilities[0].status == "approved" +def test_reject_candidate_ability_excludes_it_from_approval(tmp_path): + source = tmp_path / "repo" + source.mkdir() + (source / "README.md").write_text("# Rejectable\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="Rejectable", url=str(source)) + summary = service.analyze_repository(repository.id) + graph = service.candidate_graph(repository.id, summary.analysis_run.id) + candidate = graph.abilities[0] + + rejected_graph = service.reject_candidate_ability( + repository.id, + summary.analysis_run.id, + candidate.id, + notes="Too generic.", + ) + ability_map = service.approve_candidate_graph( + repository.id, + summary.analysis_run.id, + ) + + assert service.get_repository(repository.id).status == "reviewing" + assert rejected_graph.abilities[0].status == "rejected" + assert rejected_graph.abilities[0].capabilities[0].status == "rejected" + assert rejected_graph.abilities[0].capabilities[0].features[0].status == "rejected" + assert ability_map.abilities == [] + + def test_analyze_repository_failure_is_recorded(tmp_path): service = make_service(tmp_path) repository = service.register_repository( diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 2ff6b44..d37526f 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -138,6 +138,20 @@ def test_api_analysis_run_loop(tmp_path): assert candidate_graph["abilities"][0]["name"] == ( "Review Frontend Repository Usefulness" ) + candidate_ability_id = candidate_graph["abilities"][0]["id"] + + reject_response = client.post( + f"/repos/{repository_id}/analysis-runs/" + f"{run['analysis_run']['id']}/candidate-abilities/" + f"{candidate_ability_id}/reject", + json={"notes": "Reject once to exercise review correction."}, + ) + assert reject_response.status_code == 200 + assert reject_response.json()["abilities"][0]["status"] == "rejected" + + run_response = client.post(f"/repos/{repository_id}/analysis-runs", json={}) + assert run_response.status_code == 201 + run = run_response.json() approve_response = client.post( f"/repos/{repository_id}/analysis-runs/"