diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index 72888ba..4be446d 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -394,6 +394,102 @@ class RegistryService: self.store.update_repository_status(repository_id, "reviewing") return self.store.get_candidate_graph(repository_id, analysis_run_id) + def merge_candidate_ability( + self, + repository_id: int, + analysis_run_id: int, + source_ability_id: int, + *, + target_ability_id: int, + notes: str = "", + ) -> CandidateGraph: + self.store.merge_candidate_ability( + repository_id, + analysis_run_id, + source_ability_id, + target_ability_id, + ) + self.store.create_review_decision( + repository_id, + analysis_run_id, + action="merge_candidate_ability", + notes=notes, + ) + self.store.update_repository_status(repository_id, "reviewing") + return self.store.get_candidate_graph(repository_id, analysis_run_id) + + def merge_candidate_capability( + self, + repository_id: int, + analysis_run_id: int, + source_capability_id: int, + *, + target_capability_id: int, + notes: str = "", + ) -> CandidateGraph: + self.store.merge_candidate_capability( + repository_id, + analysis_run_id, + source_capability_id, + target_capability_id, + ) + self.store.create_review_decision( + repository_id, + analysis_run_id, + action="merge_candidate_capability", + notes=notes, + ) + self.store.update_repository_status(repository_id, "reviewing") + return self.store.get_candidate_graph(repository_id, analysis_run_id) + + def merge_candidate_feature( + self, + repository_id: int, + analysis_run_id: int, + source_feature_id: int, + *, + target_feature_id: int, + notes: str = "", + ) -> CandidateGraph: + self.store.merge_candidate_feature( + repository_id, + analysis_run_id, + source_feature_id, + target_feature_id, + ) + self.store.create_review_decision( + repository_id, + analysis_run_id, + action="merge_candidate_feature", + notes=notes, + ) + self.store.update_repository_status(repository_id, "reviewing") + return self.store.get_candidate_graph(repository_id, analysis_run_id) + + def merge_candidate_evidence( + self, + repository_id: int, + analysis_run_id: int, + source_evidence_id: int, + *, + target_evidence_id: int, + notes: str = "", + ) -> CandidateGraph: + self.store.merge_candidate_evidence( + repository_id, + analysis_run_id, + source_evidence_id, + target_evidence_id, + ) + self.store.create_review_decision( + repository_id, + analysis_run_id, + action="merge_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 a9c80a2..e208da3 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -643,6 +643,132 @@ class RegistryStore: target_capability_id=target_capability_id, ) + def merge_candidate_ability( + self, + repository_id: int, + analysis_run_id: int, + source_ability_id: int, + target_ability_id: int, + ) -> None: + if source_ability_id == target_ability_id: + raise ValueError("source and target candidate ability must be different") + 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_abilities + SET status = 'merged' + WHERE id = ? + AND repository_id = ? + AND analysis_run_id = ? + AND status = 'candidate' + """, + (source_ability_id, repository_id, analysis_run_id), + ) + if cursor.rowcount == 0: + raise NotFoundError( + "source candidate ability " + f"{source_ability_id} was not found for repository " + f"{repository_id} analysis run {analysis_run_id}" + ) + connection.execute( + """ + UPDATE candidate_capabilities + SET ability_id = ? + WHERE ability_id = ? AND repository_id = ? AND analysis_run_id = ? + """, + (target_ability_id, source_ability_id, repository_id, analysis_run_id), + ) + + def merge_candidate_capability( + self, + repository_id: int, + analysis_run_id: int, + source_capability_id: int, + target_capability_id: int, + ) -> None: + if source_capability_id == target_capability_id: + raise ValueError("source and target candidate capability must be different") + 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( + """ + UPDATE candidate_capabilities + SET status = 'merged' + WHERE id = ? + AND repository_id = ? + AND analysis_run_id = ? + AND status = 'candidate' + """, + (source_capability_id, repository_id, analysis_run_id), + ) + if cursor.rowcount == 0: + raise NotFoundError( + "source candidate capability " + f"{source_capability_id} was not found for repository " + f"{repository_id} analysis run {analysis_run_id}" + ) + for table in ("candidate_features", "candidate_evidence"): + connection.execute( + f""" + UPDATE {table} + SET capability_id = ? + WHERE capability_id = ? + AND repository_id = ? + AND analysis_run_id = ? + """, + ( + target_capability_id, + source_capability_id, + repository_id, + analysis_run_id, + ), + ) + + def merge_candidate_feature( + self, + repository_id: int, + analysis_run_id: int, + source_feature_id: int, + target_feature_id: int, + ) -> None: + self._merge_candidate_leaf( + table="candidate_features", + label="candidate feature", + repository_id=repository_id, + analysis_run_id=analysis_run_id, + source_id=source_feature_id, + target_id=target_feature_id, + ) + + def merge_candidate_evidence( + self, + repository_id: int, + analysis_run_id: int, + source_evidence_id: int, + target_evidence_id: int, + ) -> None: + self._merge_candidate_leaf( + table="candidate_evidence", + label="candidate evidence", + repository_id=repository_id, + analysis_run_id=analysis_run_id, + source_id=source_evidence_id, + target_id=target_evidence_id, + ) + def _ensure_candidate_row( self, *, @@ -703,6 +829,43 @@ class RegistryStore: f"{repository_id} analysis run {analysis_run_id}" ) + def _merge_candidate_leaf( + self, + *, + table: str, + label: str, + repository_id: int, + analysis_run_id: int, + source_id: int, + target_id: int, + ) -> None: + if source_id == target_id: + raise ValueError(f"source and target {label} must be different") + self._ensure_candidate_row( + table=table, + label=f"target {label}", + repository_id=repository_id, + analysis_run_id=analysis_run_id, + candidate_id=target_id, + ) + with self.connect() as connection: + cursor = connection.execute( + f""" + UPDATE {table} + SET status = 'merged' + WHERE id = ? + AND repository_id = ? + AND analysis_run_id = ? + AND status = 'candidate' + """, + (source_id, repository_id, analysis_run_id), + ) + if cursor.rowcount == 0: + raise NotFoundError( + f"source {label} {source_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 ca939ac..2981ea1 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -94,6 +94,26 @@ class CandidateLeafRelink(BaseModel): notes: str = "" +class CandidateAbilityMerge(BaseModel): + target_ability_id: int + notes: str = "" + + +class CandidateCapabilityMerge(BaseModel): + target_capability_id: int + notes: str = "" + + +class CandidateFeatureMerge(BaseModel): + target_feature_id: int + notes: str = "" + + +class CandidateEvidenceMerge(BaseModel): + target_evidence_id: int + notes: str = "" + + app = FastAPI(title="Repository Ability Registry", version="0.1.0") @@ -426,6 +446,102 @@ def relink_candidate_evidence( raise HTTPException(status_code=404, detail=str(exc)) from exc +@app.post( + "/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-abilities/{source_ability_id}/merge" +) +def merge_candidate_ability( + repository_id: int, + analysis_run_id: int, + source_ability_id: int, + payload: CandidateAbilityMerge, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.merge_candidate_ability( + repository_id, + analysis_run_id, + source_ability_id, + **payload.model_dump(), + ) + ) + except (NotFoundError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.post( + "/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-capabilities/{source_capability_id}/merge" +) +def merge_candidate_capability( + repository_id: int, + analysis_run_id: int, + source_capability_id: int, + payload: CandidateCapabilityMerge, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.merge_candidate_capability( + repository_id, + analysis_run_id, + source_capability_id, + **payload.model_dump(), + ) + ) + except (NotFoundError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.post( + "/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-features/{source_feature_id}/merge" +) +def merge_candidate_feature( + repository_id: int, + analysis_run_id: int, + source_feature_id: int, + payload: CandidateFeatureMerge, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.merge_candidate_feature( + repository_id, + analysis_run_id, + source_feature_id, + **payload.model_dump(), + ) + ) + except (NotFoundError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@app.post( + "/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-evidence/{source_evidence_id}/merge" +) +def merge_candidate_evidence( + repository_id: int, + analysis_run_id: int, + source_evidence_id: int, + payload: CandidateEvidenceMerge, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.merge_candidate_evidence( + repository_id, + analysis_run_id, + source_evidence_id, + **payload.model_dump(), + ) + ) + except (NotFoundError, ValueError) as exc: + raise HTTPException(status_code=400, 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 42e59ec..2c7c467 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -124,7 +124,10 @@ def page(title: str, body: str) -> HTMLResponse:
Repository Ability Registry - API Docs +
{body}
@@ -170,6 +173,49 @@ def repository_index(service: RegistryService = Depends(get_service)) -> HTMLRes return page("Repositories", body) +@router.get("/ui/search") +def search_page( + q: str = "", + service: RegistryService = Depends(get_service), +) -> HTMLResponse: + results = service.search(q) if q.strip() else [] + rows = "\n".join( + f""" + + {escape(result.repository_name)} + {escape(result.match_type)} + {escape(result.match_name)} + {result.confidence:.2f} + + """ + for result in results + ) + empty = ( + 'No matches.' + if q.strip() + else 'Enter a need, capability, or repository name.' + ) + body = f""" +
+

Search

+ Repositories +
+
+
+ + +
+
+
+ + + {rows or empty} +
RepositoryMatchNameConfidence
+
+ """ + return page("Search", body) + + @router.post("/ui/repos") def create_repository_from_form( url: str = Form(...), @@ -524,6 +570,102 @@ def relink_candidate_evidence_from_form( ) +@router.post( + "/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-abilities/{source_ability_id}/merge" +) +def merge_candidate_ability_from_form( + repository_id: int, + analysis_run_id: int, + source_ability_id: int, + target_ability_id: int = Form(...), + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.merge_candidate_ability( + repository_id, + analysis_run_id, + source_ability_id, + target_ability_id=target_ability_id, + notes="Merged 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-capabilities/{source_capability_id}/merge" +) +def merge_candidate_capability_from_form( + repository_id: int, + analysis_run_id: int, + source_capability_id: int, + target_capability_id: int = Form(...), + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.merge_candidate_capability( + repository_id, + analysis_run_id, + source_capability_id, + target_capability_id=target_capability_id, + notes="Merged 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/{source_feature_id}/merge" +) +def merge_candidate_feature_from_form( + repository_id: int, + analysis_run_id: int, + source_feature_id: int, + target_feature_id: int = Form(...), + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.merge_candidate_feature( + repository_id, + analysis_run_id, + source_feature_id, + target_feature_id=target_feature_id, + notes="Merged 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/{source_evidence_id}/merge" +) +def merge_candidate_evidence_from_form( + repository_id: int, + analysis_run_id: int, + source_evidence_id: int, + target_evidence_id: int = Form(...), + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.merge_candidate_evidence( + repository_id, + analysis_run_id, + source_evidence_id, + target_evidence_id=target_evidence_id, + notes="Merged 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: @@ -538,11 +680,13 @@ def render_candidate_graph(graph: dict, repository_id: int, analysis_run_id: int f"""
  • {escape(ability['name'])} + ID {ability['id']} {escape(ability['status'])} {ability['confidence']:.2f} {render_candidate_ability_actions(ability, repository_id, analysis_run_id)}

    {escape(ability['description'])}

    {render_candidate_edit_form('candidate-abilities', ability, repository_id, analysis_run_id)} + {render_candidate_merge_form('candidate-abilities', ability, repository_id, analysis_run_id, 'target_ability_id', 'Merge into ability ID')} {render_sources(ability['source_refs'])}
  • @@ -608,12 +752,14 @@ def render_candidate_capability( return f"""
  • {escape(capability['name'])} + ID {capability['id']} {escape(capability['status'])} {capability['confidence']:.2f} {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_candidate_merge_form('candidate-capabilities', capability, repository_id, analysis_run_id, 'target_capability_id', 'Merge into capability ID')} {render_sources(capability['source_refs'])}

    Features

    @@ -631,11 +777,13 @@ def render_candidate_feature( return f"""
  • {escape(feature["name"])} + ID {feature["id"]} {escape(feature["status"])} {escape(feature["type"])} {escape(feature["location"])} {render_candidate_reject_form('candidate-features', feature, repository_id, analysis_run_id)} {render_candidate_relink_form('candidate-features', feature, repository_id, analysis_run_id, 'target_capability_id', 'Target capability ID')} + {render_candidate_merge_form('candidate-features', feature, repository_id, analysis_run_id, 'target_feature_id', 'Merge into feature ID')}
  • """ @@ -648,11 +796,13 @@ def render_candidate_evidence( return f"""
  • {escape(evidence["type"])} + ID {evidence["id"]} {escape(evidence["status"])} {escape(evidence["strength"])} {escape(evidence["reference"])} {render_candidate_reject_form('candidate-evidence', evidence, repository_id, analysis_run_id)} {render_candidate_relink_form('candidate-evidence', evidence, repository_id, analysis_run_id, 'target_capability_id', 'Target capability ID')} + {render_candidate_merge_form('candidate-evidence', evidence, repository_id, analysis_run_id, 'target_evidence_id', 'Merge into evidence ID')}
  • """ @@ -698,6 +848,28 @@ def render_candidate_relink_form( """ +def render_candidate_merge_form( + collection: str, + candidate: dict, + repository_id: int, + analysis_run_id: int, + field_name: str, + label: str, +) -> str: + if candidate["status"] != "candidate": + return "" + action = ( + f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" + f"/{collection}/{candidate['id']}/merge" + ) + return f""" +
    + + +
    + """ + + def render_ability_map(ability_map: dict) -> str: abilities = ability_map.get("abilities", []) if not abilities: diff --git a/tests/test_registry_service.py b/tests/test_registry_service.py index 74cc08e..b12da3a 100644 --- a/tests/test_registry_service.py +++ b/tests/test_registry_service.py @@ -480,6 +480,154 @@ def test_relink_candidate_feature_and_evidence_to_another_capability(tmp_path): } +def test_merge_candidate_ability_moves_capabilities_to_target(tmp_path): + source = tmp_path / "repo" + source.mkdir() + (source / "README.md").write_text("# Merge Ability\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="Merge Ability", url=str(source)) + summary = service.analyze_repository(repository.id) + graph = service.candidate_graph(repository.id, summary.analysis_run.id) + source_ability = graph.abilities[0] + with service.store.connect() as connection: + cursor = connection.execute( + """ + INSERT INTO candidate_abilities + (repository_id, analysis_run_id, name, description, confidence) + VALUES (?, ?, ?, ?, ?) + """, + ( + repository.id, + summary.analysis_run.id, + "Merged Operational Ability", + "Preferred duplicate ability.", + 0.83, + ), + ) + target_ability_id = int(cursor.lastrowid) + + graph = service.merge_candidate_ability( + repository.id, + summary.analysis_run.id, + source_ability.id, + target_ability_id=target_ability_id, + ) + ability_map = service.approve_candidate_graph(repository.id, summary.analysis_run.id) + + merged_source = [ability for ability in graph.abilities if ability.id == source_ability.id][0] + target = [ability for ability in graph.abilities if ability.id == target_ability_id][0] + assert merged_source.status == "merged" + assert target.capabilities + assert [ability.name for ability in ability_map.abilities] == [ + "Merged Operational Ability" + ] + assert ability_map.abilities[0].capabilities + + +def test_merge_candidate_capability_moves_children_to_target(tmp_path): + source = tmp_path / "repo" + source.mkdir() + (source / "README.md").write_text("# Merge Capability\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="Merge Capability", url=str(source)) + summary = service.analyze_repository(repository.id) + graph = service.candidate_graph(repository.id, summary.analysis_run.id) + source_capability = graph.abilities[0].capabilities[0] + target_capability = graph.abilities[0].capabilities[1] + + graph = service.merge_candidate_capability( + repository.id, + summary.analysis_run.id, + source_capability.id, + target_capability_id=target_capability.id, + ) + ability_map = service.approve_candidate_graph(repository.id, summary.analysis_run.id) + + merged_source = [ + capability + for ability in graph.abilities + for capability in ability.capabilities + if capability.id == source_capability.id + ][0] + target = [ + capability + for ability in graph.abilities + for capability in ability.capabilities + if capability.id == target_capability.id + ][0] + assert merged_source.status == "merged" + assert target.features + assert [capability.name for capability in ability_map.abilities[0].capabilities] == [ + target_capability.name + ] + assert ability_map.abilities[0].capabilities[0].features + + +def test_merge_candidate_feature_and_evidence_omits_duplicate_leaves(tmp_path): + source = tmp_path / "repo" + source.mkdir() + (source / "README.md").write_text("# Merge Leaves\n", encoding="utf-8") + (source / "tests").mkdir() + (source / "tests" / "test_health.py").write_text( + "def test_health(): pass\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" + '@app.get("/ready")\n' + "def ready():\n" + " return {}\n", + encoding="utf-8", + ) + + service = make_service(tmp_path) + repository = service.register_repository(name="Merge Leaves", url=str(source)) + summary = service.analyze_repository(repository.id) + graph = service.candidate_graph(repository.id, summary.analysis_run.id) + capability = graph.abilities[0].capabilities[0] + + service.merge_candidate_feature( + repository.id, + summary.analysis_run.id, + capability.features[1].id, + target_feature_id=capability.features[0].id, + ) + service.merge_candidate_evidence( + repository.id, + summary.analysis_run.id, + capability.evidence[1].id, + target_evidence_id=capability.evidence[0].id, + ) + ability_map = service.approve_candidate_graph(repository.id, summary.analysis_run.id) + + approved_capability = ability_map.abilities[0].capabilities[0] + assert len(approved_capability.features) == len(capability.features) - 1 + assert len(approved_capability.evidence) == len(capability.evidence) - 1 + + 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 1a27857..4429385 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -271,6 +271,7 @@ def test_ui_register_analyze_and_approve_loop(tmp_path): run_detail = client.get(run_path) assert run_detail.status_code == 200 assert "Candidate Graph" in run_detail.text + assert "ID " in run_detail.text approve_response = client.post( f"{run_path}/candidate-graph/approve", @@ -282,6 +283,10 @@ 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 + + search_response = client.get("/ui/search", params={"q": "repository"}) + assert search_response.status_code == 200 + assert "UI Repo" in search_response.text finally: app.dependency_overrides.clear() @@ -443,3 +448,90 @@ def test_api_relinks_candidate_feature_and_evidence(tmp_path): assert relinked_capabilities[1]["evidence"][0]["id"] == evidence_id finally: app.dependency_overrides.clear() + + +def test_api_merges_candidate_capability_feature_and_evidence(tmp_path): + source = tmp_path / "repo" + source.mkdir() + (source / "README.md").write_text("# API Merge\n", encoding="utf-8") + (source / "requirements.txt").write_text("fastapi\n", encoding="utf-8") + (source / "tests").mkdir() + (source / "tests" / "test_status.py").write_text( + "def test_status(): pass\n", + encoding="utf-8", + ) + (source / "app.py").write_text( + "from fastapi import FastAPI\n" + "app = FastAPI()\n" + '@app.get("/status")\n' + "def status():\n" + " return {}\n" + '@app.get("/ready")\n' + "def ready():\n" + " return {}\n", + encoding="utf-8", + ) + + def override_settings(): + return Settings( + database_path=str(tmp_path / "api-merge.sqlite3"), + checkout_root=str(tmp_path / "api-merge-checkouts"), + ) + + app.dependency_overrides[get_settings] = override_settings + client = TestClient(app) + try: + repository_response = client.post( + "/repos", + json={"name": "API Merge", "url": str(source)}, + ) + repository_id = repository_response.json()["id"] + run_response = client.post(f"/repos/{repository_id}/analysis-runs", json={}) + run_id = run_response.json()["analysis_run"]["id"] + graph_response = client.get( + f"/repos/{repository_id}/analysis-runs/{run_id}/candidate-graph" + ) + capabilities = graph_response.json()["abilities"][0]["capabilities"] + source_capability = capabilities[0] + target_capability = capabilities[1] + + feature_response = client.post( + f"/repos/{repository_id}/analysis-runs/{run_id}" + f"/candidate-features/{source_capability['features'][1]['id']}/merge", + json={ + "target_feature_id": source_capability["features"][0]["id"], + "notes": "Duplicate route", + }, + ) + assert feature_response.status_code == 200 + assert ( + feature_response.json()["abilities"][0]["capabilities"][0]["features"][1][ + "status" + ] + == "merged" + ) + + evidence_response = client.post( + f"/repos/{repository_id}/analysis-runs/{run_id}" + f"/candidate-evidence/{source_capability['evidence'][1]['id']}/merge", + json={ + "target_evidence_id": source_capability["evidence"][0]["id"], + "notes": "Duplicate evidence", + }, + ) + assert evidence_response.status_code == 200 + + capability_response = client.post( + f"/repos/{repository_id}/analysis-runs/{run_id}" + f"/candidate-capabilities/{source_capability['id']}/merge", + json={ + "target_capability_id": target_capability["id"], + "notes": "Duplicate capability", + }, + ) + assert capability_response.status_code == 200 + capabilities = capability_response.json()["abilities"][0]["capabilities"] + assert capabilities[0]["status"] == "merged" + assert capabilities[1]["features"] + finally: + app.dependency_overrides.clear()