diff --git a/README.md b/README.md index 64dddf3..4166662 100644 --- a/README.md +++ b/README.md @@ -81,3 +81,13 @@ curl http://127.0.0.1:8000/repos/1/analysis-runs/1/candidate-graph ``` Candidate entries are source-linked review seeds. They are not canonical registry truth until a review workflow approves them. + +Approve a candidate graph into the canonical registry: + +```bash +curl -X POST http://127.0.0.1:8000/repos/1/analysis-runs/1/candidate-graph/approve \ + -H 'content-type: application/json' \ + -d '{"notes":"Approved first review package"}' +``` + +Approval copies candidate abilities, capabilities, features, and evidence into the approved registry tables, marks candidates approved, and moves the repository status to `indexed`. diff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql index e9b47f3..e22d26a 100644 --- a/migrations/0001_initial.sql +++ b/migrations/0001_initial.sql @@ -97,6 +97,15 @@ CREATE TABLE IF NOT EXISTS candidate_evidence ( created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS review_decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + analysis_run_id INTEGER REFERENCES analysis_runs(id) ON DELETE SET NULL, + action TEXT NOT NULL, + notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + CREATE TABLE IF NOT EXISTS approved_abilities ( id INTEGER PRIMARY KEY AUTOINCREMENT, repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, @@ -148,6 +157,7 @@ CREATE INDEX IF NOT EXISTS idx_candidate_abilities_repository ON candidate_abili CREATE INDEX IF NOT EXISTS idx_candidate_capabilities_repository ON candidate_capabilities(repository_id); CREATE INDEX IF NOT EXISTS idx_candidate_features_repository ON candidate_features(repository_id); CREATE INDEX IF NOT EXISTS idx_candidate_evidence_repository ON candidate_evidence(repository_id); +CREATE INDEX IF NOT EXISTS idx_review_decisions_repository ON review_decisions(repository_id); CREATE INDEX IF NOT EXISTS idx_abilities_repository ON approved_abilities(repository_id); CREATE INDEX IF NOT EXISTS idx_capabilities_repository ON approved_capabilities(repository_id); CREATE INDEX IF NOT EXISTS idx_features_repository ON approved_features(repository_id); diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index 3fcf0d3..82fd825 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -103,6 +103,67 @@ class RegistryService: def candidate_graph(self, repository_id: int, analysis_run_id: int) -> CandidateGraph: return self.store.get_candidate_graph(repository_id, analysis_run_id) + def approve_candidate_graph( + self, + repository_id: int, + analysis_run_id: int, + *, + notes: str = "", + ) -> RepositoryAbilityMap: + graph = self.store.get_candidate_graph(repository_id, analysis_run_id) + pending_abilities = [ + ability for ability in graph.abilities if ability.status == "candidate" + ] + for ability in pending_abilities: + approved_ability_id = self.store.create_ability( + repository_id, + name=ability.name, + description=ability.description, + confidence=ability.confidence, + ) + for capability in ability.capabilities: + approved_capability_id = self.store.create_capability( + repository_id, + approved_ability_id, + name=capability.name, + description=capability.description, + inputs=capability.inputs, + outputs=capability.outputs, + confidence=capability.confidence, + ) + for feature in capability.features: + self.store.create_feature( + repository_id, + approved_capability_id, + name=feature.name, + type=feature.type, + location=feature.location, + confidence=feature.confidence, + ) + for evidence in capability.evidence: + self.store.create_evidence( + repository_id, + approved_capability_id, + type=evidence.type, + reference=evidence.reference, + strength=evidence.strength, + ) + + if pending_abilities: + self.store.mark_candidate_graph_status( + repository_id, + analysis_run_id, + "approved", + ) + self.store.create_review_decision( + repository_id, + analysis_run_id, + action="approve_candidate_graph", + notes=notes, + ) + self.store.update_repository_status(repository_id, "indexed") + return self.store.get_ability_map(repository_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 be0089e..9eb6e1c 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -361,6 +361,47 @@ class RegistryStore: abilities=abilities, ) + def mark_candidate_graph_status( + self, + repository_id: int, + analysis_run_id: int, + status: str, + ) -> None: + with self.connect() as connection: + for table in ( + "candidate_abilities", + "candidate_capabilities", + "candidate_features", + "candidate_evidence", + ): + connection.execute( + f""" + UPDATE {table} + SET status = ? + WHERE repository_id = ? AND analysis_run_id = ? + """, + (status, repository_id, analysis_run_id), + ) + + def create_review_decision( + self, + repository_id: int, + analysis_run_id: int, + *, + action: str, + notes: str = "", + ) -> int: + with self.connect() as connection: + cursor = connection.execute( + """ + INSERT INTO review_decisions + (repository_id, analysis_run_id, action, notes) + VALUES (?, ?, ?, ?) + """, + (repository_id, analysis_run_id, action, notes), + ) + return int(cursor.lastrowid) + 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 a5816db..033fa2b 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -69,6 +69,10 @@ class AnalysisRunCreate(BaseModel): source_path: str | None = None +class CandidateGraphApproval(BaseModel): + notes: str = "" + + app = FastAPI(title="Repository Ability Registry", version="0.1.0") @@ -161,6 +165,25 @@ def get_candidate_graph( raise HTTPException(status_code=404, detail=str(exc)) from exc +@app.post("/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-graph/approve") +def approve_candidate_graph( + repository_id: int, + analysis_run_id: int, + payload: CandidateGraphApproval, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.approve_candidate_graph( + repository_id, + analysis_run_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/tests/test_registry_service.py b/tests/test_registry_service.py index 71a9492..df10137 100644 --- a/tests/test_registry_service.py +++ b/tests/test_registry_service.py @@ -144,6 +144,43 @@ def test_analyze_repository_records_snapshot_and_observed_facts(tmp_path): assert "Expose Repository Interface" in capability_names +def test_approve_candidate_graph_publishes_ability_map_once(tmp_path): + source = tmp_path / "repo" + source.mkdir() + (source / "README.md").write_text("# Example\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="Example", url=str(source)) + summary = service.analyze_repository(repository.id) + + ability_map = service.approve_candidate_graph( + repository.id, + summary.analysis_run.id, + notes="Looks good for the first pass.", + ) + second_approval = service.approve_candidate_graph( + repository.id, + summary.analysis_run.id, + ) + + assert service.get_repository(repository.id).status == "indexed" + assert len(ability_map.abilities) == 1 + assert len(second_approval.abilities) == 1 + assert ability_map.abilities[0].name == "Review Example Repository Usefulness" + assert ability_map.abilities[0].capabilities[0].features[0].location == "app.py" + + candidate_graph = service.candidate_graph(repository.id, summary.analysis_run.id) + assert candidate_graph.abilities[0].status == "approved" + + 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 892a2d1..6d656c9 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -112,6 +112,22 @@ def test_api_analysis_run_loop(tmp_path): "Review Frontend Repository Usefulness" ) + approve_response = client.post( + f"/repos/{repository_id}/analysis-runs/" + f"{run['analysis_run']['id']}/candidate-graph/approve", + json={"notes": "Approved in API test"}, + ) + assert approve_response.status_code == 200 + ability_map = approve_response.json() + assert ability_map["repository"]["status"] == "indexed" + assert ability_map["abilities"][0]["name"] == ( + "Review Frontend Repository Usefulness" + ) + + search_response = client.get("/search", params={"q": "structure"}) + assert search_response.status_code == 200 + assert search_response.json() + facts_response = client.get(f"/repos/{repository_id}/observed-facts") assert facts_response.status_code == 200 fact_names = {