diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index 5f4eea8..72888ba 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -322,6 +322,78 @@ class RegistryService: self.store.update_repository_status(repository_id, "reviewing") return self.store.get_candidate_graph(repository_id, analysis_run_id) + def relink_candidate_capability( + self, + repository_id: int, + analysis_run_id: int, + candidate_capability_id: int, + *, + target_ability_id: int, + notes: str = "", + ) -> CandidateGraph: + self.store.relink_candidate_capability( + repository_id, + analysis_run_id, + candidate_capability_id, + target_ability_id, + ) + self.store.create_review_decision( + repository_id, + analysis_run_id, + action="relink_candidate_capability", + notes=notes, + ) + self.store.update_repository_status(repository_id, "reviewing") + return self.store.get_candidate_graph(repository_id, analysis_run_id) + + def relink_candidate_feature( + self, + repository_id: int, + analysis_run_id: int, + candidate_feature_id: int, + *, + target_capability_id: int, + notes: str = "", + ) -> CandidateGraph: + self.store.relink_candidate_feature( + repository_id, + analysis_run_id, + candidate_feature_id, + target_capability_id, + ) + self.store.create_review_decision( + repository_id, + analysis_run_id, + action="relink_candidate_feature", + notes=notes, + ) + self.store.update_repository_status(repository_id, "reviewing") + return self.store.get_candidate_graph(repository_id, analysis_run_id) + + def relink_candidate_evidence( + self, + repository_id: int, + analysis_run_id: int, + candidate_evidence_id: int, + *, + target_capability_id: int, + notes: str = "", + ) -> CandidateGraph: + self.store.relink_candidate_evidence( + repository_id, + analysis_run_id, + candidate_evidence_id, + target_capability_id, + ) + self.store.create_review_decision( + repository_id, + analysis_run_id, + action="relink_candidate_evidence", + 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 b2969e0..a9c80a2 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -576,6 +576,133 @@ class RegistryStore: f"{repository_id} analysis run {analysis_run_id}" ) + def relink_candidate_capability( + self, + repository_id: int, + analysis_run_id: int, + candidate_capability_id: int, + target_ability_id: int, + ) -> None: + self._ensure_candidate_row( + table="candidate_abilities", + label="target candidate ability", + repository_id=repository_id, + analysis_run_id=analysis_run_id, + candidate_id=target_ability_id, + ) + with self.connect() as connection: + cursor = connection.execute( + """ + UPDATE candidate_capabilities + SET ability_id = ? + WHERE id = ? AND repository_id = ? AND analysis_run_id = ? + """, + ( + target_ability_id, + candidate_capability_id, + repository_id, + analysis_run_id, + ), + ) + if cursor.rowcount == 0: + raise NotFoundError( + "candidate capability " + f"{candidate_capability_id} was not found for repository " + f"{repository_id} analysis run {analysis_run_id}" + ) + + def relink_candidate_feature( + self, + repository_id: int, + analysis_run_id: int, + candidate_feature_id: int, + target_capability_id: int, + ) -> None: + self._relink_candidate_leaf( + table="candidate_features", + label="candidate feature", + repository_id=repository_id, + analysis_run_id=analysis_run_id, + candidate_id=candidate_feature_id, + target_capability_id=target_capability_id, + ) + + def relink_candidate_evidence( + self, + repository_id: int, + analysis_run_id: int, + candidate_evidence_id: int, + target_capability_id: int, + ) -> None: + self._relink_candidate_leaf( + table="candidate_evidence", + label="candidate evidence", + repository_id=repository_id, + analysis_run_id=analysis_run_id, + candidate_id=candidate_evidence_id, + target_capability_id=target_capability_id, + ) + + def _ensure_candidate_row( + self, + *, + table: str, + label: str, + repository_id: int, + analysis_run_id: int, + candidate_id: int, + ) -> None: + with self.connect() as connection: + row = connection.execute( + f""" + SELECT id FROM {table} + WHERE id = ? AND repository_id = ? AND analysis_run_id = ? + """, + (candidate_id, repository_id, analysis_run_id), + ).fetchone() + if row is None: + raise NotFoundError( + f"{label} {candidate_id} was not found for repository " + f"{repository_id} analysis run {analysis_run_id}" + ) + + def _relink_candidate_leaf( + self, + *, + table: str, + label: str, + repository_id: int, + analysis_run_id: int, + candidate_id: int, + target_capability_id: int, + ) -> None: + self._ensure_candidate_row( + table="candidate_capabilities", + label="target candidate capability", + repository_id=repository_id, + analysis_run_id=analysis_run_id, + candidate_id=target_capability_id, + ) + with self.connect() as connection: + cursor = connection.execute( + f""" + UPDATE {table} + SET capability_id = ? + WHERE id = ? AND repository_id = ? AND analysis_run_id = ? + """, + ( + target_capability_id, + candidate_id, + repository_id, + analysis_run_id, + ), + ) + if cursor.rowcount == 0: + raise NotFoundError( + f"{label} {candidate_id} was not found for repository " + f"{repository_id} analysis run {analysis_run_id}" + ) + def _reject_candidate_leaf( self, *, diff --git a/src/repo_registry/web_api/app.py b/src/repo_registry/web_api/app.py index 9f369d7..ca939ac 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -84,6 +84,16 @@ class CandidateEdit(BaseModel): notes: str = "" +class CandidateCapabilityRelink(BaseModel): + target_ability_id: int + notes: str = "" + + +class CandidateLeafRelink(BaseModel): + target_capability_id: int + notes: str = "" + + app = FastAPI(title="Repository Ability Registry", version="0.1.0") @@ -344,6 +354,78 @@ def edit_candidate_capability( raise HTTPException(status_code=404, detail=str(exc)) from exc +@app.post( + "/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-capabilities/{candidate_capability_id}/relink" +) +def relink_candidate_capability( + repository_id: int, + analysis_run_id: int, + candidate_capability_id: int, + payload: CandidateCapabilityRelink, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.relink_candidate_capability( + repository_id, + analysis_run_id, + candidate_capability_id, + **payload.model_dump(), + ) + ) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@app.post( + "/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-features/{candidate_feature_id}/relink" +) +def relink_candidate_feature( + repository_id: int, + analysis_run_id: int, + candidate_feature_id: int, + payload: CandidateLeafRelink, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.relink_candidate_feature( + repository_id, + analysis_run_id, + candidate_feature_id, + **payload.model_dump(), + ) + ) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@app.post( + "/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-evidence/{candidate_evidence_id}/relink" +) +def relink_candidate_evidence( + repository_id: int, + analysis_run_id: int, + candidate_evidence_id: int, + payload: CandidateLeafRelink, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.relink_candidate_evidence( + repository_id, + analysis_run_id, + candidate_evidence_id, + **payload.model_dump(), + ) + ) + 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 92cac0f..42e59ec 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -452,6 +452,78 @@ def edit_candidate_capability_from_form( ) +@router.post( + "/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-capabilities/{candidate_capability_id}/relink" +) +def relink_candidate_capability_from_form( + repository_id: int, + analysis_run_id: int, + candidate_capability_id: int, + target_ability_id: int = Form(...), + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.relink_candidate_capability( + repository_id, + analysis_run_id, + candidate_capability_id, + target_ability_id=target_ability_id, + notes="Relinked from web UI", + ) + return RedirectResponse( + f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}", + status_code=303, + ) + + +@router.post( + "/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-features/{candidate_feature_id}/relink" +) +def relink_candidate_feature_from_form( + repository_id: int, + analysis_run_id: int, + candidate_feature_id: int, + target_capability_id: int = Form(...), + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.relink_candidate_feature( + repository_id, + analysis_run_id, + candidate_feature_id, + target_capability_id=target_capability_id, + notes="Relinked from web UI", + ) + return RedirectResponse( + f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}", + status_code=303, + ) + + +@router.post( + "/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-evidence/{candidate_evidence_id}/relink" +) +def relink_candidate_evidence_from_form( + repository_id: int, + analysis_run_id: int, + candidate_evidence_id: int, + target_capability_id: int = Form(...), + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.relink_candidate_evidence( + repository_id, + analysis_run_id, + candidate_evidence_id, + target_capability_id=target_capability_id, + notes="Relinked 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: @@ -541,6 +613,7 @@ def render_candidate_capability( {render_candidate_reject_form('candidate-capabilities', capability, repository_id, analysis_run_id)}
{escape(capability['description'])}
{render_candidate_edit_form('candidate-capabilities', capability, repository_id, analysis_run_id)} + {render_candidate_relink_form('candidate-capabilities', capability, repository_id, analysis_run_id, 'target_ability_id', 'Target ability ID')} {render_sources(capability['source_refs'])}