feat: make repository reads alias-aware
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
This commit is contained in:
tegwick 2026-08-29 10:47:51 +02:00
parent 639b9aed08
commit 2e2ae1e5d0
19 changed files with 982 additions and 94 deletions

View file

@ -54,6 +54,13 @@ from api.services.sbom_nexus import SBOMNexusError
from api.services.sbom_nexus import get_json as get_sbom_nexus_json
from api.services.sbom_nexus import reads_from_nexus
from api.services.repository_identity import stage_initial_repository_identity
from api.services.repository_aliases import (
RepositorySlugResolution,
affected_slug_predicate,
repository_resolution_for_id,
resolve_repository_slug,
stale_external_references,
)
from hub_core.routers.repos import create_repos_router
router = APIRouter(prefix="/repos", tags=["repos"])
@ -140,14 +147,14 @@ async def list_repos(
if business_stake:
q = q.where(ManagedRepo.business_stake.contains([business_stake]))
result = await session.execute(q)
return await _project_repo_reads(list(result.scalars().all()))
return await _project_repo_reads(session, list(result.scalars().all()))
@router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED)
async def register_repo(
body: RepoCreate,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
) -> RepoRead:
domain_result = await session.execute(select(Domain).where(Domain.slug == body.domain_slug))
domain_obj = domain_result.scalar_one_or_none()
if domain_obj is None:
@ -197,7 +204,7 @@ async def register_repo(
) from exc
await session.refresh(repo)
await _publish_repo_registered(repo, body, domain_obj)
return repo
return (await _project_repo_reads(session, [repo]))[0]
@router.post("/onboard", response_model=RepoOnboardResult)
@ -483,7 +490,8 @@ async def get_repo_doi(
Results are cached by fingerprint. Pass ?force_refresh=true to bypass the cache.
"""
repo = await _get_repo_by_slug(slug, session)
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
sbom_projections = await _sbom_projection_map()
domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id))
domain_obj = domain_result.scalar_one_or_none()
@ -517,7 +525,7 @@ async def get_repo_doi(
if not force_refresh and cached and cached.fingerprint == fp and cached.criteria:
return DoIReport(
repo_slug=slug,
repo_slug=repo.slug,
tier=cached.tier,
core_pass=cached.core_pass,
standard_pass=cached.standard_pass,
@ -556,11 +564,11 @@ async def get_repo_doi(
async def get_repo_by_id(
repo_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
) -> RepoRead:
repo = await session.get(ManagedRepo, repo_id)
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{repo_id}' not found")
return repo
return (await _project_repo_reads(session, [repo]))[0]
@router.get("/scope-health", response_model=list[RepoScopeHealth])
@ -615,9 +623,10 @@ async def update_repo_with_classification(
slug: str,
body: RepoUpdate,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
) -> RepoRead:
"""Patch repo metadata including classification spine fields."""
repo = await _get_repo_by_slug(slug, session)
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
payload = body.model_dump(exclude_unset=True)
requested_domain_slug = payload.pop("domain_slug", None)
if requested_domain_slug is not None:
@ -658,7 +667,7 @@ async def update_repo_with_classification(
setattr(repo, field, value)
await session.commit()
await session.refresh(repo)
return repo
return (await _project_repo_reads(session, [repo], requested=resolution))[0]
@router.get("/{slug}", response_model=RepoRead)
@ -666,28 +675,53 @@ async def get_repo_with_sbom_projection(
slug: str,
session: AsyncSession = Depends(get_session),
) -> RepoRead:
repo = await _get_repo_by_slug(slug, session)
return (await _project_repo_reads([repo]))[0]
resolution = await resolve_repository_slug(session, slug)
return (
await _project_repo_reads(
session,
[resolution.repo],
requested=resolution,
include_stale_external=True,
)
)[0]
router.include_router(
_core_repo_router(
include_collection_routes=False,
include_lookup_routes=False,
include_slug_routes=False,
)
)
@router.post("/{slug}/paths", response_model=RepoRead)
async def register_repo_path(
slug: str,
body: RepoPathRegister,
session: AsyncSession = Depends(get_session),
) -> RepoRead:
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
host_paths = dict(repo.host_paths or {})
host_paths[body.host] = body.path
repo.host_paths = host_paths
await session.commit()
await session.refresh(repo)
return (await _project_repo_reads(session, [repo], requested=resolution))[0]
@router.patch("/{slug}/archive", response_model=RepoRead)
async def archive_repo(
slug: str,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
repo = await _get_repo_by_slug(slug, session)
) -> RepoRead:
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
repo.status = "archived"
await session.commit()
await session.refresh(repo)
return repo
return (await _project_repo_reads(session, [repo], requested=resolution))[0]
@router.get("/{slug}/dispatch", response_model=RepoDispatch)
@ -701,7 +735,8 @@ async def get_repo_dispatch(
call it at session start to discover what work is pending without needing to
read state-hub summary or scan workplan files manually.
"""
repo = await _get_repo_by_slug(slug, session)
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
# Active goal
goal_result = await session.execute(
@ -765,7 +800,9 @@ async def get_repo_dispatch(
ic_result = await session.execute(
select(InterfaceChange).where(
InterfaceChange.status == "published",
InterfaceChange.affected_repo_slugs.contains([slug]),
affected_slug_predicate(
InterfaceChange.affected_repo_slugs, resolution.slug_values
),
).order_by(InterfaceChange.published_at.desc())
)
pending_changes = [
@ -794,7 +831,12 @@ async def get_repo_dispatch(
)
return RepoDispatch(
repo_slug=slug,
repo_slug=resolution.canonical_slug,
requested_slug=resolution.requested_slug,
canonical_slug=resolution.canonical_slug,
slug_status=resolution.slug_status,
aliases=list(resolution.aliases),
stale_external_references=await stale_external_references(session, resolution),
active_goal=active_goal,
active_workplans=dispatch_workstreams,
human_interventions=all_interventions,
@ -819,7 +861,8 @@ async def sync_repo_consistency(
Returns the raw JSON output from consistency_check.py.
Query param ?fix=false to run check-only without writing.
"""
repo = await _get_repo_by_slug(slug, session)
resolution = await resolve_repository_slug(session, slug)
repo = resolution.repo
hostname = socket.gethostname()
host_paths = repo.host_paths or {}
@ -828,13 +871,13 @@ async def sync_repo_consistency(
raise HTTPException(
status_code=503,
detail=(
f"No accessible path for repo '{slug}' on host '{hostname}'. "
f"Register with: POST /repos/{slug}/paths/"
f"No accessible path for repo '{repo.slug}' on host '{hostname}'. "
f"Register with: POST /repos/{repo.slug}/paths/"
),
)
script = Path(__file__).parent.parent.parent / "scripts" / "consistency_check.py"
cmd = [sys.executable, str(script), "--repo", slug, "--json",
cmd = [sys.executable, str(script), "--repo", repo.slug, "--json",
"--api-base", settings.api_base]
if fix:
cmd.append("--fix")
@ -853,11 +896,8 @@ async def sync_repo_consistency(
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
repo = result.scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return repo
resolution = await resolve_repository_slug(session, slug)
return resolution.repo
def _repo_doi_dict(repo: ManagedRepo, domain_slug: str | None) -> dict:
@ -899,11 +939,35 @@ def _projected_last_sbom_at(
return str(repo.last_sbom_at) if repo.last_sbom_at else None
async def _project_repo_reads(repositories: list[ManagedRepo]) -> list[RepoRead]:
async def _project_repo_reads(
session: AsyncSession,
repositories: list[ManagedRepo],
*,
requested: RepositorySlugResolution | None = None,
include_stale_external: bool = False,
) -> list[RepoRead]:
projections = await _sbom_projection_map()
result: list[RepoRead] = []
for repository in repositories:
resolution = (
requested
if requested is not None and requested.repo.id == repository.id
else await repository_resolution_for_id(session, repository)
)
read = RepoRead.model_validate(repository)
read = read.model_copy(
update={
"requested_slug": resolution.requested_slug,
"canonical_slug": resolution.canonical_slug,
"slug_status": resolution.slug_status,
"aliases": list(resolution.aliases),
"stale_external_references": (
await stale_external_references(session, resolution)
if include_stale_external
else []
),
}
)
if repository.slug in projections:
read = read.model_copy(
update={