from __future__ import annotations import logging import json from dataclasses import asdict from pathlib import Path from urllib.error import HTTPError, URLError from urllib.request import urlopen from fastapi import Depends, FastAPI, HTTPException, Query from fastapi.responses import PlainTextResponse from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict from repo_registry.core.service import RegistryService from repo_registry.llm_extraction import LLMCandidateExtractor, create_llm_connect_adapter from repo_registry.repo_ingestion.git import GitIngestionService from repo_registry.semantic import HashingEmbeddingProvider from repo_registry.scope import ScopeGenerator, ScopeValidator from repo_registry.storage.sqlite import NotFoundError, RegistryStore from repo_registry.web_api.schemas import ( AbilityCreate, AbilitySummaryResponse, AbilityUpdate, AnalysisRunChangeApproval, AnalysisRunCreate, AnalysisRunDiffResponse, AnalysisRunResponse, CandidateAbilityMerge, CandidateCapabilityMerge, CandidateCapabilityRelink, CandidateEdit, CandidateEvidenceMerge, CandidateFeatureMerge, CandidateGraphApproval, CandidateGraphResponse, CandidateLeafRelink, CandidateRejection, CapabilityGapRequest, CapabilityGapResponse, CapabilityCreate, CapabilitySummaryResponse, CapabilityUpdate, ContentChunkResponse, EvidenceCreate, EvidenceUpdate, ErrorResponse, ExpectationGapCreate, ExpectationGapResponse, FeatureCreate, FeatureUpdate, IdResponse, ObservedFactResponse, RepositoryAbilityMapResponse, RepositoryComparisonResponse, RepositoryCreate, RepositoryResponse, RepositoryUpdate, ReviewDecisionResponse, ScanSummaryResponse, SearchResultResponse, ) def slugify(value: str) -> str: import re return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") class Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix="REPO_REGISTRY_") database_path: str = Field(default="var/repo-registry.sqlite3") checkout_root: str = Field(default="var/checkouts") llm_enabled: bool = Field(default=True) llm_provider: str | None = Field(default=None) llm_model: str | None = Field(default=None) embedding_provider: str | None = Field(default=None) state_hub_base_url: str = Field(default="http://127.0.0.1:8000") log_level: str = Field(default="INFO") def get_settings() -> Settings: return Settings() def get_service(settings: Settings = Depends(get_settings)) -> RegistryService: logging.getLogger("repo_registry.operations").setLevel( getattr(logging, settings.log_level.upper(), logging.INFO) ) database_path = Path(settings.database_path) database_path.parent.mkdir(parents=True, exist_ok=True) store = RegistryStore(database_path) store.initialize() llm_extractor = None if settings.llm_enabled and settings.llm_provider: adapter = create_llm_connect_adapter( settings.llm_provider, model=settings.llm_model, ) llm_extractor = LLMCandidateExtractor(adapter) embedding_provider = None if settings.embedding_provider == "hashing": embedding_provider = HashingEmbeddingProvider() return RegistryService( store, ingestion=GitIngestionService(settings.checkout_root), llm_extractor=llm_extractor, embedding_provider=embedding_provider, ) API_DESCRIPTION = ( "Register repositories, analyze their observable implementation facts, " "curate reviewable scope graphs, and search approved repository characteristics." ) OPENAPI_TAGS = [ {"name": "health", "description": "Service health checks."}, {"name": "repositories", "description": "Repository registration and metadata."}, {"name": "analysis", "description": "Repository scans and extracted review inputs."}, {"name": "review", "description": "Candidate graph approval and correction workflow."}, {"name": "registry", "description": "Approved ability maps and manual registry CRUD."}, {"name": "scope", "description": "SCOPE.md generation, diffing, and writing."}, {"name": "search", "description": "Agent-facing discovery endpoints."}, {"name": "discovery", "description": "Comparison, gap analysis, and export helpers."}, ] app = FastAPI( title="Repository Scoping", version="0.1.0", description=API_DESCRIPTION, openapi_tags=OPENAPI_TAGS, responses={ 400: {"model": ErrorResponse, "description": "Bad request."}, 404: {"model": ErrorResponse, "description": "Resource not found."}, }, ) from repo_registry.web_ui.views import router as ui_router app.include_router(ui_router) @app.get("/health", tags=["health"]) def health(settings: Settings = Depends(get_settings)) -> dict[str, object]: database_path = Path(settings.database_path) checkout_root = Path(settings.checkout_root) database_reachable = False database_error = None try: database_path.parent.mkdir(parents=True, exist_ok=True) store = RegistryStore(database_path) store.initialize() with store.connect() as connection: connection.execute("SELECT 1").fetchone() database_reachable = True except Exception as exc: database_error = str(exc) return { "status": "ok" if database_reachable else "degraded", "database": { "path": str(database_path), "reachable": database_reachable, "error": database_error, }, "checkout_root": { "path": str(checkout_root), "exists": checkout_root.exists(), }, } @app.post( "/repos", status_code=201, tags=["repositories"], response_model=RepositoryResponse, ) def create_repository( payload: RepositoryCreate, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: repository = service.register_repository(**payload.model_dump()) except (RuntimeError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return asdict(repository) @app.get("/repos", tags=["repositories"], response_model=list[RepositoryResponse]) def list_repositories( service: RegistryService = Depends(get_service), ) -> list[dict[str, object]]: return [asdict(repository) for repository in service.list_repositories()] @app.get( "/repos/{repository_id}", tags=["repositories"], response_model=RepositoryResponse, ) def get_repository( repository_id: int, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict(service.get_repository(repository_id)) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.patch( "/repos/{repository_id}", tags=["repositories"], response_model=RepositoryResponse, ) def update_repository( repository_id: int, payload: RepositoryUpdate, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.update_repository( repository_id, **payload.model_dump(exclude_unset=True), ) ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.delete("/repos/{repository_id}", status_code=204, tags=["repositories"]) def delete_repository( repository_id: int, service: RegistryService = Depends(get_service), ) -> None: try: service.delete_repository(repository_id) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.post( "/repos/{repository_id}/analysis-runs", status_code=201, tags=["analysis"], response_model=ScanSummaryResponse, ) def create_analysis_run( repository_id: int, payload: AnalysisRunCreate, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: summary = service.analyze_repository( repository_id, source_path=payload.source_path, use_cached_checkout=payload.use_cached_checkout, use_llm_assistance=payload.use_llm_assistance, trusted_auto_approve=payload.trusted_auto_approve, access_username=payload.access_username, access_password=payload.access_password, ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return asdict(summary) @app.get( "/repos/{repository_id}/analysis-runs", tags=["analysis"], response_model=list[AnalysisRunResponse], ) def list_analysis_runs( repository_id: int, service: RegistryService = Depends(get_service), ) -> list[dict[str, object]]: try: return [asdict(run) for run in service.list_analysis_runs(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}", tags=["analysis"], response_model=AnalysisRunResponse, ) def get_analysis_run( repository_id: int, analysis_run_id: int, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict(service.get_analysis_run(repository_id, analysis_run_id)) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.get( "/repos/{repository_id}/expectation-gaps", tags=["review"], response_model=list[ExpectationGapResponse], ) def list_expectation_gaps( repository_id: int, analysis_run_id: int | None = Query(default=None), service: RegistryService = Depends(get_service), ) -> list[dict[str, object]]: try: return [ asdict(gap) for gap in service.list_expectation_gaps(repository_id, analysis_run_id) ] except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.post( "/repos/{repository_id}/expectation-gaps", status_code=201, tags=["review"], response_model=ExpectationGapResponse, ) def create_expectation_gap( repository_id: int, payload: ExpectationGapCreate, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.record_expectation_gap( repository_id, analysis_run_id=payload.analysis_run_id, expected_type=payload.expected_type, expected_name=payload.expected_name, source=payload.source, notes=payload.notes, ) ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.get( "/repos/{repository_id}/analysis-runs/{base_analysis_run_id}/diff/{target_analysis_run_id}", tags=["review"], response_model=AnalysisRunDiffResponse, ) def diff_analysis_runs( repository_id: int, base_analysis_run_id: int, target_analysis_run_id: int, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.diff_analysis_runs( repository_id, base_analysis_run_id, target_analysis_run_id, ) ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.get( "/repos/{repository_id}/review-decisions", tags=["review"], response_model=list[ReviewDecisionResponse], ) 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", tags=["review"], response_model=list[ReviewDecisionResponse], ) 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", tags=["analysis"], response_model=list[ObservedFactResponse], ) def list_observed_facts( repository_id: int, analysis_run_id: int | None = None, service: RegistryService = Depends(get_service), ) -> list[dict[str, object]]: try: return [ asdict(fact) for fact in service.list_observed_facts(repository_id, analysis_run_id) ] except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.get( "/repos/{repository_id}/content-chunks", tags=["analysis"], response_model=list[ContentChunkResponse], ) def list_content_chunks( repository_id: int, analysis_run_id: int | None = None, service: RegistryService = Depends(get_service), ) -> list[dict[str, object]]: try: return [ asdict(chunk) for chunk in service.list_content_chunks(repository_id, analysis_run_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}/content-chunks", tags=["analysis"], response_model=list[ContentChunkResponse], ) def list_analysis_run_content_chunks( repository_id: int, analysis_run_id: int, service: RegistryService = Depends(get_service), ) -> list[dict[str, object]]: try: return [ asdict(chunk) for chunk in service.list_content_chunks(repository_id, analysis_run_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}/candidate-graph", tags=["review"], response_model=CandidateGraphResponse, ) def get_candidate_graph( repository_id: int, analysis_run_id: int, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict(service.candidate_graph(repository_id, analysis_run_id)) 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-graph/approve", tags=["review"], response_model=RepositoryAbilityMapResponse, ) 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}/analysis-runs/{analysis_run_id}/changes/approve", tags=["review"], response_model=RepositoryAbilityMapResponse, ) def approve_analysis_run_changes( repository_id: int, analysis_run_id: int, payload: AnalysisRunChangeApproval, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.approve_analysis_run_changes( 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}/analysis-runs/{analysis_run_id}" "/candidate-abilities/{candidate_ability_id}/reject", tags=["review"], response_model=CandidateGraphResponse, ) 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}/analysis-runs/{analysis_run_id}" "/candidate-capabilities/{candidate_capability_id}/reject", tags=["review"], response_model=CandidateGraphResponse, ) def reject_candidate_capability( repository_id: int, analysis_run_id: int, candidate_capability_id: int, payload: CandidateRejection, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.reject_candidate_capability( repository_id, analysis_run_id, candidate_capability_id, notes=payload.notes, ) ) 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}/reject", tags=["review"], response_model=CandidateGraphResponse, ) def reject_candidate_feature( repository_id: int, analysis_run_id: int, candidate_feature_id: int, payload: CandidateRejection, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.reject_candidate_feature( repository_id, analysis_run_id, candidate_feature_id, notes=payload.notes, ) ) 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}/reject", tags=["review"], response_model=CandidateGraphResponse, ) def reject_candidate_evidence( repository_id: int, analysis_run_id: int, candidate_evidence_id: int, payload: CandidateRejection, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.reject_candidate_evidence( repository_id, analysis_run_id, candidate_evidence_id, notes=payload.notes, ) ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.patch( "/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-abilities/{candidate_ability_id}", tags=["review"], response_model=CandidateGraphResponse, ) def edit_candidate_ability( repository_id: int, analysis_run_id: int, candidate_ability_id: int, payload: CandidateEdit, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.edit_candidate_ability( repository_id, analysis_run_id, candidate_ability_id, **payload.model_dump(), ) ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.patch( "/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-capabilities/{candidate_capability_id}", tags=["review"], response_model=CandidateGraphResponse, ) def edit_candidate_capability( repository_id: int, analysis_run_id: int, candidate_capability_id: int, payload: CandidateEdit, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.edit_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-capabilities/{candidate_capability_id}/relink", tags=["review"], response_model=CandidateGraphResponse, ) 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", tags=["review"], response_model=CandidateGraphResponse, ) 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", tags=["review"], response_model=CandidateGraphResponse, ) 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}/analysis-runs/{analysis_run_id}" "/candidate-abilities/{source_ability_id}/merge", tags=["review"], response_model=CandidateGraphResponse, ) 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", tags=["review"], response_model=CandidateGraphResponse, ) 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", tags=["review"], response_model=CandidateGraphResponse, ) 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", tags=["review"], response_model=CandidateGraphResponse, ) 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, tags=["registry"], response_model=IdResponse, ) def create_ability( repository_id: int, payload: AbilityCreate, service: RegistryService = Depends(get_service), ) -> dict[str, int]: try: ability_id = service.add_ability(repository_id, **payload.model_dump()) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return {"id": ability_id} @app.patch( "/repos/{repository_id}/abilities/{ability_id}", tags=["registry"], response_model=RepositoryAbilityMapResponse, ) def update_ability( repository_id: int, ability_id: int, payload: AbilityUpdate, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.update_ability( repository_id, ability_id, **payload.model_dump(exclude_unset=True), ) ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.delete( "/repos/{repository_id}/abilities/{ability_id}", tags=["registry"], response_model=RepositoryAbilityMapResponse, ) def delete_ability( repository_id: int, ability_id: int, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict(service.delete_ability(repository_id, ability_id)) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.post( "/repos/{repository_id}/capabilities", status_code=201, tags=["registry"], response_model=IdResponse, ) def create_capability( repository_id: int, payload: CapabilityCreate, service: RegistryService = Depends(get_service), ) -> dict[str, int]: try: capability_id = service.add_capability(repository_id, **payload.model_dump()) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return {"id": capability_id} @app.patch( "/repos/{repository_id}/capabilities/{capability_id}", tags=["registry"], response_model=RepositoryAbilityMapResponse, ) def update_capability( repository_id: int, capability_id: int, payload: CapabilityUpdate, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.update_capability( repository_id, capability_id, **payload.model_dump(exclude_unset=True), ) ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.delete( "/repos/{repository_id}/capabilities/{capability_id}", tags=["registry"], response_model=RepositoryAbilityMapResponse, ) def delete_capability( repository_id: int, capability_id: int, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict(service.delete_capability(repository_id, capability_id)) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.post( "/repos/{repository_id}/features", status_code=201, tags=["registry"], response_model=IdResponse, ) def create_feature( repository_id: int, payload: FeatureCreate, service: RegistryService = Depends(get_service), ) -> dict[str, int]: try: feature_id = service.add_feature(repository_id, **payload.model_dump()) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return {"id": feature_id} @app.patch( "/repos/{repository_id}/features/{feature_id}", tags=["registry"], response_model=RepositoryAbilityMapResponse, ) def update_feature( repository_id: int, feature_id: int, payload: FeatureUpdate, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.update_feature( repository_id, feature_id, **payload.model_dump(exclude_unset=True), ) ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.delete( "/repos/{repository_id}/features/{feature_id}", tags=["registry"], response_model=RepositoryAbilityMapResponse, ) def delete_feature( repository_id: int, feature_id: int, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict(service.delete_feature(repository_id, feature_id)) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.post( "/repos/{repository_id}/evidence", status_code=201, tags=["registry"], response_model=IdResponse, ) def create_evidence( repository_id: int, payload: EvidenceCreate, service: RegistryService = Depends(get_service), ) -> dict[str, int]: try: evidence_id = service.add_evidence(repository_id, **payload.model_dump()) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return {"id": evidence_id} @app.patch( "/repos/{repository_id}/evidence/{evidence_id}", tags=["registry"], response_model=RepositoryAbilityMapResponse, ) def update_evidence( repository_id: int, evidence_id: int, payload: EvidenceUpdate, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.update_evidence( repository_id, evidence_id, **payload.model_dump(exclude_unset=True), ) ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.delete( "/repos/{repository_id}/evidence/{evidence_id}", tags=["registry"], response_model=RepositoryAbilityMapResponse, ) def delete_evidence( repository_id: int, evidence_id: int, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict(service.delete_evidence(repository_id, evidence_id)) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.get( "/repos/{repository_id}/ability-map", tags=["registry"], response_model=RepositoryAbilityMapResponse, ) def get_ability_map( repository_id: int, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict(service.ability_map(repository_id)) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.get( "/repos/{repository_id}/export", tags=["discovery"], response_class=PlainTextResponse, responses={ 200: { "content": {"application/x-yaml": {}}, "description": "YAML registry export suitable for repo-abilities.yaml.", } }, ) def export_repository_registry_entry( repository_id: int, service: RegistryService = Depends(get_service), ) -> PlainTextResponse: try: content = service.export_registry_entry(repository_id) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return PlainTextResponse(content, media_type="application/x-yaml") @app.get( "/repos/{repo_slug}/scope", tags=["scope"], response_class=PlainTextResponse, responses={ 200: { "content": {"text/markdown": {}}, "description": "Generated SCOPE.md preview from approved characteristics.", } }, ) def generate_repository_scope( repo_slug: str, service: RegistryService = Depends(get_service), ) -> PlainTextResponse: try: ensure_scope_generation_ready(service, repo_slug) content = ScopeGenerator(service).generate(repo_slug) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return PlainTextResponse(content, media_type="text/markdown") @app.get( "/repos/{repo_slug}/scope/diff", tags=["scope"], ) def diff_repository_scope( repo_slug: str, service: RegistryService = Depends(get_service), settings: Settings = Depends(get_settings), ) -> dict[str, object]: try: repository = ensure_scope_generation_ready(service, repo_slug) scope_path = scope_file_path(service, repository, repo_slug, settings) diff = ScopeValidator(ScopeGenerator(service)).diff(repo_slug, scope_path) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc return { "sections": [asdict(section) for section in diff.sections], "needs_update": diff.needs_update, } @app.post( "/repos/{repo_slug}/scope/write", tags=["scope"], ) def write_repository_scope( repo_slug: str, service: RegistryService = Depends(get_service), settings: Settings = Depends(get_settings), ) -> dict[str, object]: try: repository = ensure_scope_generation_ready(service, repo_slug) scope_path = scope_file_path(service, repository, repo_slug, settings) content = ScopeGenerator(service).generate(repo_slug) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc scope_path.write_text(content, encoding="utf-8") return {"written": True, "path": str(scope_path)} def ensure_scope_generation_ready( service: RegistryService, repo_slug: str, ): repository = repository_by_slug(service, repo_slug) ability_map = service.ability_map(repository.id) if not ability_map.abilities: raise NotFoundError( f"repository {repo_slug!r} has no approved characteristics" ) return repository def repository_by_slug(service: RegistryService, repo_slug: str): wanted = slugify(repo_slug) for repository in service.list_repositories(): candidates = { slugify(repository.name), slugify(repository.url.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git")), } if wanted in candidates: return repository raise NotFoundError(f"repository slug {repo_slug!r} was not found") def scope_file_path( service: RegistryService, repository, repo_slug: str, settings: Settings, ) -> Path: state_hub_path = state_hub_scope_file_path(repo_slug, settings) if state_hub_path is not None: return state_hub_path source_path = Path(repository.url) if source_path.exists() and source_path.is_dir(): return source_path / "SCOPE.md" checkout = service.ingestion.cached_checkout(repository.url) if checkout is not None and checkout.source_path.exists(): return checkout.source_path / "SCOPE.md" raise ValueError( "repository has no known local checkout path on this host" ) def state_hub_scope_file_path(repo_slug: str, settings: Settings) -> Path | None: base_url = settings.state_hub_base_url.rstrip("/") if not base_url: return None try: with urlopen(f"{base_url}/repos/{repo_slug}/", timeout=2) as response: repo = json.loads(response.read().decode("utf-8")) except HTTPError as exc: if exc.code == 404: return None raise ValueError("state hub repository path lookup failed") from exc except (URLError, TimeoutError, OSError, json.JSONDecodeError): return None local_path = repo.get("local_path") if not local_path: raise ValueError( f"state hub repo {repo_slug!r} has no local path on this host" ) path = Path(local_path) if path.exists() and path.is_dir(): return path / "SCOPE.md" raise ValueError( f"state hub local path for repo {repo_slug!r} is not available: {path}" ) @app.get( "/repository-comparisons", tags=["discovery"], response_model=RepositoryComparisonResponse, ) def compare_repositories( repository_ids: list[int] = Query( ..., description="Repository ids to compare by approved abilities and capabilities.", min_length=2, ), service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return service.compare_repositories(repository_ids) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.post( "/capability-gaps", tags=["discovery"], response_model=CapabilityGapResponse, ) def detect_capability_gaps( payload: CapabilityGapRequest, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return service.detect_capability_gaps(**payload.model_dump()) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.get("/search", tags=["search"], response_model=list[SearchResultResponse]) def search( q: str = Query( ..., description="Natural-language or keyword query over approved registry entries.", examples=["classify email", "FastAPI health endpoint"], ), status: str | None = Query( default=None, description="Filter repositories by registry status, such as indexed.", ), language: str | None = Query( default=None, description="Filter by observed programming language.", ), framework: str | None = Query( default=None, description="Filter by observed framework hint.", ), ability: str | None = Query( default=None, description="Filter to results under an approved ability name.", ), capability: str | None = Query( default=None, description="Filter to results under an approved capability name.", ), service: RegistryService = Depends(get_service), ) -> list[dict[str, object]]: return [ asdict(result) for result in service.search( q, status=status, language=language, framework=framework, ability=ability, capability=capability, ) ] @app.get("/abilities", tags=["search"], response_model=list[AbilitySummaryResponse]) def list_abilities( service: RegistryService = Depends(get_service), ) -> list[dict[str, object]]: return [asdict(ability) for ability in service.list_abilities()] @app.get( "/capabilities", tags=["search"], response_model=list[CapabilitySummaryResponse], ) def list_capabilities( service: RegistryService = Depends(get_service), ) -> list[dict[str, object]]: return [asdict(capability) for capability in service.list_capabilities()]