generated from coulomb/repo-seed
Transfered deep scope functionality from the custodian
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
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
|
||||
@@ -13,6 +16,7 @@ from repo_registry.core.service import RegistryService
|
||||
from repo_registry.llm_extraction import LLMCandidateExtractor, create_llm_connect_adapter
|
||||
from repo_registry.repo_ingestion.git import GitIngestionService
|
||||
from repo_registry.semantic import HashingEmbeddingProvider
|
||||
from repo_registry.scope import ScopeGenerator, ScopeValidator
|
||||
from repo_registry.storage.sqlite import NotFoundError, RegistryStore
|
||||
from repo_registry.web_api.schemas import (
|
||||
AbilityCreate,
|
||||
@@ -58,6 +62,12 @@ from repo_registry.web_api.schemas import (
|
||||
)
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
import re
|
||||
|
||||
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="REPO_REGISTRY_")
|
||||
|
||||
@@ -67,6 +77,7 @@ class Settings(BaseSettings):
|
||||
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")
|
||||
|
||||
|
||||
@@ -111,6 +122,7 @@ OPENAPI_TAGS = [
|
||||
{"name": "analysis", "description": "Repository scans and extracted review inputs."},
|
||||
{"name": "review", "description": "Candidate graph approval and correction workflow."},
|
||||
{"name": "registry", "description": "Approved ability maps and manual registry CRUD."},
|
||||
{"name": "scope", "description": "SCOPE.md generation, diffing, and writing."},
|
||||
{"name": "search", "description": "Agent-facing discovery endpoints."},
|
||||
{"name": "discovery", "description": "Comparison, gap analysis, and export helpers."},
|
||||
]
|
||||
@@ -1120,6 +1132,144 @@ def export_repository_registry_entry(
|
||||
return PlainTextResponse(content, media_type="application/x-yaml")
|
||||
|
||||
|
||||
@app.get(
|
||||
"/repos/{repo_slug}/scope",
|
||||
tags=["scope"],
|
||||
response_class=PlainTextResponse,
|
||||
responses={
|
||||
200: {
|
||||
"content": {"text/markdown": {}},
|
||||
"description": "Generated SCOPE.md preview from approved characteristics.",
|
||||
}
|
||||
},
|
||||
)
|
||||
def generate_repository_scope(
|
||||
repo_slug: str,
|
||||
service: RegistryService = Depends(get_service),
|
||||
) -> PlainTextResponse:
|
||||
try:
|
||||
ensure_scope_generation_ready(service, repo_slug)
|
||||
content = ScopeGenerator(service).generate(repo_slug)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return PlainTextResponse(content, media_type="text/markdown")
|
||||
|
||||
|
||||
@app.get(
|
||||
"/repos/{repo_slug}/scope/diff",
|
||||
tags=["scope"],
|
||||
)
|
||||
def diff_repository_scope(
|
||||
repo_slug: str,
|
||||
service: RegistryService = Depends(get_service),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
repository = ensure_scope_generation_ready(service, repo_slug)
|
||||
scope_path = scope_file_path(service, repository, repo_slug, settings)
|
||||
diff = ScopeValidator(ScopeGenerator(service)).diff(repo_slug, scope_path)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {
|
||||
"sections": [asdict(section) for section in diff.sections],
|
||||
"needs_update": diff.needs_update,
|
||||
}
|
||||
|
||||
|
||||
@app.post(
|
||||
"/repos/{repo_slug}/scope/write",
|
||||
tags=["scope"],
|
||||
)
|
||||
def write_repository_scope(
|
||||
repo_slug: str,
|
||||
service: RegistryService = Depends(get_service),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
repository = ensure_scope_generation_ready(service, repo_slug)
|
||||
scope_path = scope_file_path(service, repository, repo_slug, settings)
|
||||
content = ScopeGenerator(service).generate(repo_slug)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
scope_path.write_text(content, encoding="utf-8")
|
||||
return {"written": True, "path": str(scope_path)}
|
||||
|
||||
|
||||
def ensure_scope_generation_ready(
|
||||
service: RegistryService,
|
||||
repo_slug: str,
|
||||
):
|
||||
repository = repository_by_slug(service, repo_slug)
|
||||
ability_map = service.ability_map(repository.id)
|
||||
if not ability_map.abilities:
|
||||
raise NotFoundError(
|
||||
f"repository {repo_slug!r} has no approved characteristics"
|
||||
)
|
||||
return repository
|
||||
|
||||
|
||||
def repository_by_slug(service: RegistryService, repo_slug: str):
|
||||
wanted = slugify(repo_slug)
|
||||
for repository in service.list_repositories():
|
||||
candidates = {
|
||||
slugify(repository.name),
|
||||
slugify(repository.url.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git")),
|
||||
}
|
||||
if wanted in candidates:
|
||||
return repository
|
||||
raise NotFoundError(f"repository slug {repo_slug!r} was not found")
|
||||
|
||||
|
||||
def scope_file_path(
|
||||
service: RegistryService,
|
||||
repository,
|
||||
repo_slug: str,
|
||||
settings: Settings,
|
||||
) -> Path:
|
||||
state_hub_path = state_hub_scope_file_path(repo_slug, settings)
|
||||
if state_hub_path is not None:
|
||||
return state_hub_path
|
||||
source_path = Path(repository.url)
|
||||
if source_path.exists() and source_path.is_dir():
|
||||
return source_path / "SCOPE.md"
|
||||
checkout = service.ingestion.cached_checkout(repository.url)
|
||||
if checkout is not None and checkout.source_path.exists():
|
||||
return checkout.source_path / "SCOPE.md"
|
||||
raise ValueError(
|
||||
"repository has no known local checkout path on this host"
|
||||
)
|
||||
|
||||
|
||||
def state_hub_scope_file_path(repo_slug: str, settings: Settings) -> Path | None:
|
||||
base_url = settings.state_hub_base_url.rstrip("/")
|
||||
if not base_url:
|
||||
return None
|
||||
try:
|
||||
with urlopen(f"{base_url}/repos/{repo_slug}/", timeout=2) as response:
|
||||
repo = json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return None
|
||||
raise ValueError("state hub repository path lookup failed") from exc
|
||||
except (URLError, TimeoutError, OSError, json.JSONDecodeError):
|
||||
return None
|
||||
local_path = repo.get("local_path")
|
||||
if not local_path:
|
||||
raise ValueError(
|
||||
f"state hub repo {repo_slug!r} has no local path on this host"
|
||||
)
|
||||
path = Path(local_path)
|
||||
if path.exists() and path.is_dir():
|
||||
return path / "SCOPE.md"
|
||||
raise ValueError(
|
||||
f"state hub local path for repo {repo_slug!r} is not available: {path}"
|
||||
)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/repository-comparisons",
|
||||
tags=["discovery"],
|
||||
|
||||
Reference in New Issue
Block a user