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_scoping.acceptance import ( criteria_registry_dict, evaluate_candidate_graph_quality, load_quality_criteria, quality_gate_outcome_dicts, ) from repo_scoping.core.service import RegistryService from repo_scoping.llm_extraction import LLMCandidateExtractor, create_llm_connect_adapter from repo_scoping.repo_ingestion.git import GitIngestionService from repo_scoping.semantic import HashingEmbeddingProvider from repo_scoping.scope import ScopeGenerator, ScopeValidator from repo_scoping.storage.sqlite import NotFoundError, RegistryStore from repo_scoping.web_api.schemas import ( AbilityCreate, AbilitySummaryResponse, AbilityUpdate, AnalysisRunChangeApproval, AnalysisRunCreate, AnalysisRunDiffResponse, AnalysisRunResponse, CandidateAbilityMerge, CandidateCapabilityMerge, CandidateCapabilityRelink, CandidateEdit, CandidateEvidenceMerge, CandidateFeatureMerge, CandidateGraphApproval, CandidateGraphResponse, CharacteristicRebuildRequest, CharacteristicRebuildResponse, CandidateLeafRelink, CandidateRejection, CapabilityGapRequest, CapabilityGapResponse, CapabilityCreate, CapabilitySummaryResponse, CapabilityUpdate, ContentChunkResponse, DependencyGraphAdHocFilters, DependencyGraphProfileCreate, DependencyGraphProfileDuplicate, DependencyGraphProfileResponse, DependencyGraphProfileUpdate, EvidenceCreate, EvidenceUpdate, ErrorResponse, ExpectationGapCreate, ExpectationGapResponse, FeatureCreate, FeatureUpdate, IdResponse, ObservedFactResponse, QualityCriteriaRegistryResponse, QualityGateOverrideCreate, RepositoryAbilityMapResponse, RepositoryComparisonResponse, RepositoryCreate, RepositoryResponse, RepositoryScopeContextResponse, RepositoryUpdate, ReviewDecisionResponse, ScanSummaryResponse, SearchResultResponse, TrustedAutoApprovalMigrationRecordResponse, ) 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_SCOPING_") database_path: str = Field(default="var/repo-scoping.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_scoping.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, ) def candidate_graph_payload(graph) -> dict[str, object]: payload = asdict(graph) payload["quality_gate_outcomes"] = quality_gate_outcome_dicts( evaluate_candidate_graph_quality(graph) ) return payload 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": "visualization", "description": "Dependency graph exploration and view profiles."}, {"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_scoping.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.get( "/quality-criteria", tags=["review"], response_model=QualityCriteriaRegistryResponse, ) def list_quality_criteria() -> dict[str, object]: return criteria_registry_dict(load_quality_criteria()) @app.get( "/review/migrations/trusted-auto-approvals", tags=["review"], response_model=list[TrustedAutoApprovalMigrationRecordResponse], ) def list_trusted_auto_approval_migration_records( service: RegistryService = Depends(get_service), ) -> list[dict[str, object]]: return [ asdict(record) for record in service.list_trusted_auto_approval_migration_records() ] @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, agentic_review=payload.agentic_review, 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.post( "/repos/{repository_id}/characteristics/rebuild", tags=["analysis"], response_model=CharacteristicRebuildResponse, ) def rebuild_characteristics_from_scratch( repository_id: int, payload: CharacteristicRebuildRequest, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.rebuild_characteristics_from_scratch( repository_id, dry_run=payload.dry_run, confirm=payload.confirm, source_path=payload.source_path, use_cached_checkout=payload.use_cached_checkout, use_llm_assistance=payload.use_llm_assistance, ) ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @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.post( "/repos/{repository_id}/analysis-runs/{analysis_run_id}/quality-gate-overrides", tags=["review"], response_model=ReviewDecisionResponse, ) def create_quality_gate_override( repository_id: int, analysis_run_id: int, payload: QualityGateOverrideCreate, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.record_quality_gate_override( repository_id, analysis_run_id, **payload.model_dump(), ) ) except (NotFoundError, ValueError) as exc: raise HTTPException(status_code=400, 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 candidate_graph_payload( 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 candidate_graph_payload( 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 candidate_graph_payload( 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 candidate_graph_payload( 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 candidate_graph_payload( 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 candidate_graph_payload( 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 candidate_graph_payload( 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 candidate_graph_payload( 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 candidate_graph_payload( 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 candidate_graph_payload( 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 candidate_graph_payload( 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 candidate_graph_payload( 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 candidate_graph_payload( 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 candidate_graph_payload( 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}/dependency-graph", tags=["visualization"], ) def get_dependency_graph( repository_id: int, base_analysis_run_id: int | None = Query(default=None), target_analysis_run_id: int | None = Query(default=None), profile_id: int | None = Query(default=None), use_latest_profile: bool = Query(default=True), service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return service.dependency_graph_elements( repository_id, base_analysis_run_id=base_analysis_run_id, target_analysis_run_id=target_analysis_run_id, profile_id=profile_id, use_latest_profile=use_latest_profile, ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @app.post( "/repos/{repository_id}/dependency-graph/filter", tags=["visualization"], ) def filter_dependency_graph( repository_id: int, payload: DependencyGraphAdHocFilters, base_analysis_run_id: int | None = Query(default=None), target_analysis_run_id: int | None = Query(default=None), profile_id: int | None = Query(default=None), service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return service.dependency_graph_elements( repository_id, base_analysis_run_id=base_analysis_run_id, target_analysis_run_id=target_analysis_run_id, profile_id=profile_id, rules=payload.rules, manual_overrides=payload.manual_overrides, use_latest_profile=profile_id is not None, ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @app.get( "/repos/{repository_id}/dependency-graph/profiles", tags=["visualization"], response_model=list[DependencyGraphProfileResponse], ) def list_dependency_graph_profiles( repository_id: int, service: RegistryService = Depends(get_service), ) -> list[dict[str, object]]: try: return [ asdict(profile) for profile in service.list_dependency_graph_profiles(repository_id) ] except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.post( "/repos/{repository_id}/dependency-graph/profiles", tags=["visualization"], status_code=201, response_model=DependencyGraphProfileResponse, ) def create_dependency_graph_profile( repository_id: int, payload: DependencyGraphProfileCreate, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.create_dependency_graph_profile( repository_id, **payload.model_dump(), ) ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @app.get( "/repos/{repository_id}/dependency-graph/profiles/{profile_id}", tags=["visualization"], response_model=DependencyGraphProfileResponse, ) def get_dependency_graph_profile( repository_id: int, profile_id: int, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict(service.get_dependency_graph_profile(repository_id, profile_id)) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.patch( "/repos/{repository_id}/dependency-graph/profiles/{profile_id}", tags=["visualization"], response_model=DependencyGraphProfileResponse, ) def update_dependency_graph_profile( repository_id: int, profile_id: int, payload: DependencyGraphProfileUpdate, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.update_dependency_graph_profile( repository_id, profile_id, **payload.model_dump(exclude_unset=True), ) ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @app.post( "/repos/{repository_id}/dependency-graph/profiles/{profile_id}/duplicate", tags=["visualization"], status_code=201, response_model=DependencyGraphProfileResponse, ) def duplicate_dependency_graph_profile( repository_id: int, profile_id: int, payload: DependencyGraphProfileDuplicate, service: RegistryService = Depends(get_service), ) -> dict[str, object]: try: return asdict( service.duplicate_dependency_graph_profile( repository_id, profile_id, name=payload.name, ) ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @app.delete( "/repos/{repository_id}/dependency-graph/profiles/{profile_id}", tags=["visualization"], status_code=204, ) def delete_dependency_graph_profile( repository_id: int, profile_id: int, service: RegistryService = Depends(get_service), ) -> None: try: service.delete_dependency_graph_profile(repository_id, profile_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/context", tags=["scope"], response_model=RepositoryScopeContextResponse, ) def get_repository_scope_context( repo_slug: str, service: RegistryService = Depends(get_service), settings: Settings = Depends(get_settings), ) -> dict[str, object]: try: repository = repository_by_slug(service, repo_slug) ability_map = service.ability_map(repository.id) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc scope_path = inspectable_scope_file_path(service, repository, repo_slug, settings) scope_md_exists = bool(scope_path and scope_path.is_file()) scope_summary = ( scope_summary_from_file(scope_path) if scope_md_exists and scope_path is not None else None ) if not scope_summary: scope_summary = ( ability_map.scope.description.strip() or (repository.description.strip() if repository.description else None) or None ) return { "repo_slug": slugify(repo_slug), "capabilities": scope_context_capabilities(ability_map), "tags": scope_context_tags(ability_map), "scope_md_exists": scope_md_exists, "scope_summary": scope_summary, } @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 scope_context_capabilities(ability_map) -> list[str]: return unique_preserving_order( [ capability.name for ability in ability_map.abilities for capability in ability.capabilities ] ) def scope_context_tags(ability_map) -> list[str]: tags = [] for ability in ability_map.abilities: tags.append(ability.primary_class) tags.extend(ability.attributes) for capability in ability.capabilities: tags.append(capability.primary_class) tags.extend(capability.attributes) return sorted(unique_preserving_order(tags), key=str.lower) def unique_preserving_order(values: list[str]) -> list[str]: result: list[str] = [] seen: set[str] = set() for value in values: item = str(value).strip() key = item.lower() if not item or key in seen: continue seen.add(key) result.append(item) return result def inspectable_scope_file_path( service: RegistryService, repository, repo_slug: str, settings: Settings, ) -> Path | None: try: state_hub_path = state_hub_scope_file_path(repo_slug, settings) except ValueError: state_hub_path = None 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" return None def scope_summary_from_file(scope_path: Path) -> str | None: try: content = scope_path.read_text(encoding="utf-8") except OSError: return None paragraph: list[str] = [] in_frontmatter = False frontmatter_checked = False for line in content.splitlines(): stripped = line.strip() if not frontmatter_checked: frontmatter_checked = True if stripped == "---": in_frontmatter = True continue if in_frontmatter: if stripped == "---": in_frontmatter = False continue if not stripped: if paragraph: return " ".join(paragraph) continue if stripped == "---" or stripped.startswith("#") or stripped.startswith(">"): if paragraph: return " ".join(paragraph) continue paragraph.append(stripped) if paragraph: return " ".join(paragraph) return None 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()]