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

1776 lines
54 KiB
Python
Raw Normal View History

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
2026-04-26 08:45:00 +02:00
from fastapi import Depends, FastAPI, HTTPException, Query
2026-04-26 12:45:49 +02:00
from fastapi.responses import PlainTextResponse
from pydantic import Field
2026-04-26 03:16:46 +02:00
from pydantic_settings import BaseSettings, SettingsConfigDict
2026-05-15 21:16:34 +02:00
from repo_scoping.acceptance import (
criteria_registry_dict,
evaluate_candidate_graph_quality,
load_quality_criteria,
quality_gate_outcome_dicts,
)
2026-05-15 21:16:34 +02:00
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,
2026-05-02 11:54:07 +02:00
CharacteristicRebuildRequest,
CharacteristicRebuildResponse,
CandidateLeafRelink,
CandidateRejection,
2026-04-26 12:45:49 +02:00
CapabilityGapRequest,
CapabilityGapResponse,
CapabilityCreate,
CapabilitySummaryResponse,
CapabilityUpdate,
ContentChunkResponse,
DependencyGraphAdHocFilters,
DependencyGraphProfileCreate,
DependencyGraphProfileDuplicate,
DependencyGraphProfileResponse,
DependencyGraphProfileUpdate,
EvidenceCreate,
EvidenceUpdate,
2026-04-26 17:32:01 +02:00
ErrorResponse,
2026-04-29 01:19:59 +02:00
ExpectationGapCreate,
ExpectationGapResponse,
FeatureCreate,
FeatureUpdate,
IdResponse,
ObservedFactResponse,
2026-05-15 15:22:45 +02:00
QualityCriteriaRegistryResponse,
2026-05-15 16:30:58 +02:00
QualityGateOverrideCreate,
RepositoryAbilityMapResponse,
2026-04-26 12:45:49 +02:00
RepositoryComparisonResponse,
RepositoryCreate,
RepositoryResponse,
2026-05-15 01:40:49 +02:00
RepositoryScopeContextResponse,
RepositoryUpdate,
ReviewDecisionResponse,
ScanSummaryResponse,
SearchResultResponse,
TrustedAutoApprovalMigrationRecordResponse,
)
def slugify(value: str) -> str:
import re
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
2026-04-26 03:16:46 +02:00
class Settings(BaseSettings):
2026-05-15 21:16:34 +02:00
model_config = SettingsConfigDict(env_prefix="REPO_SCOPING_")
2026-04-26 03:16:46 +02:00
2026-05-15 21:16:34 +02:00
database_path: str = Field(default="var/repo-scoping.sqlite3")
2026-04-25 22:37:06 +02:00
checkout_root: str = Field(default="var/checkouts")
2026-04-29 00:40:02 +02:00
llm_enabled: bool = Field(default=True)
llm_provider: str | None = Field(default=None)
llm_model: str | None = Field(default=None)
2026-04-26 16:05:27 +02:00
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:
2026-05-15 21:16:34 +02:00
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
2026-04-29 00:40:02 +02:00
if settings.llm_enabled and settings.llm_provider:
adapter = create_llm_connect_adapter(
settings.llm_provider,
model=settings.llm_model,
)
llm_extractor = LLMCandidateExtractor(adapter)
2026-04-26 16:05:27 +02:00
embedding_provider = None
if settings.embedding_provider == "hashing":
embedding_provider = HashingEmbeddingProvider()
return RegistryService(
store,
ingestion=GitIngestionService(settings.checkout_root),
llm_extractor=llm_extractor,
2026-04-26 16:05:27 +02:00
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
2026-04-26 08:45:00 +02:00
API_DESCRIPTION = (
"Register repositories, analyze their observable implementation facts, "
"curate reviewable scope graphs, and search approved repository characteristics."
2026-04-26 08:45:00 +02:00
)
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."},
2026-04-26 08:45:00 +02:00
{"name": "search", "description": "Agent-facing discovery endpoints."},
2026-04-26 12:45:49 +02:00
{"name": "discovery", "description": "Comparison, gap analysis, and export helpers."},
2026-04-26 08:45:00 +02:00
]
app = FastAPI(
title="Repository Scoping",
2026-04-26 08:45:00 +02:00
version="0.1.0",
description=API_DESCRIPTION,
openapi_tags=OPENAPI_TAGS,
2026-04-26 17:32:01 +02:00
responses={
400: {"model": ErrorResponse, "description": "Bad request."},
404: {"model": ErrorResponse, "description": "Resource not found."},
},
2026-04-26 08:45:00 +02:00
)
2026-05-15 21:16:34 +02:00
from repo_scoping.web_ui.views import router as ui_router
2026-04-25 23:04:15 +02:00
app.include_router(ui_router)
2026-04-26 08:45:00 +02:00
@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(),
},
}
2026-05-15 15:22:45 +02:00
@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()
]
2026-04-26 09:12:27 +02:00
@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())
2026-04-25 23:04:15 +02:00
except (RuntimeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return asdict(repository)
2026-04-26 09:12:27 +02:00
@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()]
2026-04-26 09:12:27 +02:00
@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
2026-04-26 09:12:27 +02:00
@app.patch(
"/repos/{repository_id}",
tags=["repositories"],
response_model=RepositoryResponse,
)
2026-04-26 00:29:07 +02:00
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
2026-04-26 08:45:00 +02:00
@app.delete("/repos/{repository_id}", status_code=204, tags=["repositories"])
2026-04-26 00:29:07 +02:00
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
2026-04-26 09:12:27 +02:00
@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,
2026-04-28 03:01:10 +02:00
use_cached_checkout=payload.use_cached_checkout,
2026-04-29 00:40:02 +02:00
use_llm_assistance=payload.use_llm_assistance,
agentic_review=payload.agentic_review,
2026-04-29 00:40:02 +02:00
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)
2026-05-02 11:54:07 +02:00
@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
2026-04-26 09:12:27 +02:00
@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
2026-04-26 09:12:27 +02:00
@app.get(
"/repos/{repository_id}/analysis-runs/{analysis_run_id}",
tags=["analysis"],
response_model=AnalysisRunResponse,
)
2026-04-25 23:56:19 +02:00
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
2026-04-29 01:19:59 +02:00
@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
2026-04-26 09:12:27 +02:00
@app.get(
"/repos/{repository_id}/review-decisions",
tags=["review"],
response_model=list[ReviewDecisionResponse],
)
2026-04-26 00:24:02 +02:00
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
2026-04-26 08:45:00 +02:00
@app.get(
"/repos/{repository_id}/analysis-runs/{analysis_run_id}/review-decisions",
tags=["review"],
2026-04-26 09:12:27 +02:00
response_model=list[ReviewDecisionResponse],
2026-04-26 08:45:00 +02:00
)
2026-04-26 00:24:02 +02:00
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
2026-05-15 16:30:58 +02:00
@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
2026-04-26 09:12:27 +02:00
@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
2026-04-26 09:12:27 +02:00
@app.get(
"/repos/{repository_id}/content-chunks",
tags=["analysis"],
response_model=list[ContentChunkResponse],
)
2026-04-26 02:47:10 +02:00
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
2026-04-26 08:45:00 +02:00
@app.get(
"/repos/{repository_id}/analysis-runs/{analysis_run_id}/content-chunks",
tags=["analysis"],
2026-04-26 09:12:27 +02:00
response_model=list[ContentChunkResponse],
2026-04-26 08:45:00 +02:00
)
2026-04-26 02:47:10 +02:00
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
2026-04-26 08:45:00 +02:00
@app.get(
"/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-graph",
tags=["review"],
2026-04-26 09:12:27 +02:00
response_model=CandidateGraphResponse,
2026-04-26 08:45:00 +02:00
)
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
2026-04-26 08:45:00 +02:00
@app.post(
"/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-graph/approve",
tags=["review"],
2026-04-26 09:12:27 +02:00
response_model=RepositoryAbilityMapResponse,
2026-04-26 08:45:00 +02:00
)
2026-04-25 22:46:22 +02:00
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
2026-04-25 22:46:22 +02:00
2026-04-25 23:27:28 +02:00
@app.post(
"/repos/{repository_id}/analysis-runs/{analysis_run_id}"
2026-04-26 08:45:00 +02:00
"/candidate-abilities/{candidate_ability_id}/reject",
tags=["review"],
2026-04-26 09:12:27 +02:00
response_model=CandidateGraphResponse,
2026-04-25 23:27:28 +02:00
)
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(
2026-04-25 23:27:28 +02:00
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}"
2026-04-26 08:45:00 +02:00
"/candidate-capabilities/{candidate_capability_id}/reject",
tags=["review"],
2026-04-26 09:12:27 +02:00
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}"
2026-04-26 08:45:00 +02:00
"/candidate-features/{candidate_feature_id}/reject",
tags=["review"],
2026-04-26 09:12:27 +02:00
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}"
2026-04-26 08:45:00 +02:00
"/candidate-evidence/{candidate_evidence_id}/reject",
tags=["review"],
2026-04-26 09:12:27 +02:00
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}"
2026-04-26 08:45:00 +02:00
"/candidate-abilities/{candidate_ability_id}",
tags=["review"],
2026-04-26 09:12:27 +02:00
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}"
2026-04-26 08:45:00 +02:00
"/candidate-capabilities/{candidate_capability_id}",
tags=["review"],
2026-04-26 09:12:27 +02:00
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
2026-04-25 23:44:25 +02:00
@app.post(
"/repos/{repository_id}/analysis-runs/{analysis_run_id}"
2026-04-26 08:45:00 +02:00
"/candidate-capabilities/{candidate_capability_id}/relink",
tags=["review"],
2026-04-26 09:12:27 +02:00
response_model=CandidateGraphResponse,
2026-04-25 23:44:25 +02:00
)
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(
2026-04-25 23:44:25 +02:00
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}"
2026-04-26 08:45:00 +02:00
"/candidate-features/{candidate_feature_id}/relink",
tags=["review"],
2026-04-26 09:12:27 +02:00
response_model=CandidateGraphResponse,
2026-04-25 23:44:25 +02:00
)
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(
2026-04-25 23:44:25 +02:00
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}"
2026-04-26 08:45:00 +02:00
"/candidate-evidence/{candidate_evidence_id}/relink",
tags=["review"],
2026-04-26 09:12:27 +02:00
response_model=CandidateGraphResponse,
2026-04-25 23:44:25 +02:00
)
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(
2026-04-25 23:44:25 +02:00
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}"
2026-04-26 08:45:00 +02:00
"/candidate-abilities/{source_ability_id}/merge",
tags=["review"],
2026-04-26 09:12:27 +02:00
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}"
2026-04-26 08:45:00 +02:00
"/candidate-capabilities/{source_capability_id}/merge",
tags=["review"],
2026-04-26 09:12:27 +02:00
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}"
2026-04-26 08:45:00 +02:00
"/candidate-features/{source_feature_id}/merge",
tags=["review"],
2026-04-26 09:12:27 +02:00
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}"
2026-04-26 08:45:00 +02:00
"/candidate-evidence/{source_evidence_id}/merge",
tags=["review"],
2026-04-26 09:12:27 +02:00
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
2026-04-26 09:12:27 +02:00
@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}
2026-04-26 09:12:27 +02:00
@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
2026-04-26 09:12:27 +02:00
@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
2026-04-26 09:12:27 +02:00
@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}
2026-04-26 09:12:27 +02:00
@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
2026-04-26 09:12:27 +02:00
@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
2026-04-26 09:12:27 +02:00
@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}
2026-04-26 09:12:27 +02:00
@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
2026-04-26 09:12:27 +02:00
@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
2026-04-26 09:12:27 +02:00
@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}
2026-04-26 09:12:27 +02:00
@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
2026-04-26 09:12:27 +02:00
@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
2026-04-26 09:12:27 +02:00
@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
2026-05-03 12:52:51 +02:00
@app.get(
"/repos/{repository_id}/dependency-graph",
tags=["visualization"],
2026-05-03 12:52:51 +02:00
)
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),
2026-05-03 12:52:51 +02:00
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,
)
2026-05-03 12:52:51 +02:00
)
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
2026-04-26 12:45:49 +02:00
@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")
2026-05-15 01:40:49 +02:00
@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)}
2026-05-15 01:40:49 +02:00
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}"
)
2026-04-26 12:45:49 +02:00
@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
2026-04-26 09:12:27 +02:00
@app.get("/search", tags=["search"], response_model=list[SearchResultResponse])
def search(
2026-04-26 08:45:00 +02:00
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]]:
2026-04-26 00:04:19 +02:00
return [
asdict(result)
for result in service.search(
q,
status=status,
language=language,
framework=framework,
2026-04-26 00:08:55 +02:00
ability=ability,
capability=capability,
2026-04-26 00:04:19 +02:00
)
]
2026-04-25 23:56:19 +02:00
2026-04-26 09:12:27 +02:00
@app.get("/abilities", tags=["search"], response_model=list[AbilitySummaryResponse])
2026-04-25 23:56:19 +02:00
def list_abilities(
service: RegistryService = Depends(get_service),
) -> list[dict[str, object]]:
return [asdict(ability) for ability in service.list_abilities()]
2026-04-26 09:12:27 +02:00
@app.get(
"/capabilities",
tags=["search"],
response_model=list[CapabilitySummaryResponse],
)
2026-04-25 23:56:19 +02:00
def list_capabilities(
service: RegistryService = Depends(get_service),
) -> list[dict[str, object]]:
return [asdict(capability) for capability in service.list_capabilities()]