Files
repo-scoping/src/repo_scoping/web_api/app.py

1776 lines
54 KiB
Python

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()]