generated from coulomb/repo-seed
feat(doi): Repository DoI automated gate and dashboard integration (CUST-WP-0024)
Implements the 14-criterion DoI checklist as a runnable gate with API,
MCP tools, CLI script, and dashboard integration.
Core components:
- api/doi_engine.py — async engine evaluating all 14 criteria (asyncio.to_thread
for non-blocking HTTP self-calls), shared by API and CLI
- api/schemas/doi.py — DoICriterion, DoIReport, DoISummaryEntry schemas
- api/routers/repos.py — GET /repos/{slug}/doi + GET /repos/doi/summary
- scripts/check_doi.py — CLI: make check-doi REPO=<slug> / check-doi-all
- mcp_server/server.py — check_repo_doi(), get_doi_summary() tools
Dashboard (repos.md):
- DoI tier badge per repo (None/Core/Standard/Full) colour-coded red→green
- Domain block shows lowest DoI tier across its repos
- DoI KPI card in summary row
- DoI filter in All Repos Table
- Link to Repository DoI policy page
Also fixes: TPSC snapshots 500 error (missing nested selectinload for
catalog_entry relationship in list_snapshots endpoint).
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,11 +5,13 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.doi_engine import evaluate as _doi_evaluate
|
||||
from api.models.domain import Domain
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repo_goal import RepoGoal
|
||||
from api.models.task import Task
|
||||
from api.models.workstream import Workstream
|
||||
from api.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
|
||||
from api.schemas.managed_repo import (
|
||||
DispatchTask,
|
||||
DispatchWorkstream,
|
||||
@@ -68,6 +70,72 @@ async def register_repo(
|
||||
return repo
|
||||
|
||||
|
||||
@router.get("/doi/summary", response_model=list[DoISummaryEntry])
|
||||
async def doi_summary(session: AsyncSession = Depends(get_session)) -> list[DoISummaryEntry]:
|
||||
"""Return DoI tier for all active repos, worst tier first."""
|
||||
result = await session.execute(
|
||||
select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.name)
|
||||
)
|
||||
repos = list(result.scalars().all())
|
||||
domain_result = await session.execute(select(Domain))
|
||||
domain_map = {d.id: d.slug for d in domain_result.scalars().all()}
|
||||
|
||||
entries: list[DoISummaryEntry] = []
|
||||
for repo in repos:
|
||||
repo_dict = {
|
||||
"slug": repo.slug,
|
||||
"domain_slug": domain_map.get(repo.domain_id),
|
||||
"local_path": repo.local_path,
|
||||
"remote_url": repo.remote_url,
|
||||
"host_paths": repo.host_paths or {},
|
||||
"last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None,
|
||||
}
|
||||
report = await _doi_evaluate(repo_dict)
|
||||
entries.append(DoISummaryEntry(
|
||||
repo_slug=repo.slug,
|
||||
domain_slug=domain_map.get(repo.domain_id),
|
||||
tier=report.tier,
|
||||
core_pass=report.core_pass,
|
||||
standard_pass=report.standard_pass,
|
||||
full_pass=report.full_pass,
|
||||
checked_at=report.checked_at,
|
||||
))
|
||||
|
||||
tier_order = {"none": 0, "core": 1, "standard": 2, "full": 3}
|
||||
entries.sort(key=lambda e: tier_order.get(e.tier, 0))
|
||||
return entries
|
||||
|
||||
|
||||
@router.get("/{slug}/doi", response_model=DoIReport)
|
||||
async def get_repo_doi(slug: str, session: AsyncSession = Depends(get_session)) -> DoIReport:
|
||||
"""Evaluate the 14 DoI criteria for a single repo."""
|
||||
repo = await _get_repo_by_slug(slug, session)
|
||||
domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id))
|
||||
domain_obj = domain_result.scalar_one_or_none()
|
||||
|
||||
repo_dict = {
|
||||
"slug": repo.slug,
|
||||
"domain_slug": domain_obj.slug if domain_obj else None,
|
||||
"local_path": repo.local_path,
|
||||
"remote_url": repo.remote_url,
|
||||
"host_paths": repo.host_paths or {},
|
||||
"last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None,
|
||||
}
|
||||
report = await _doi_evaluate(repo_dict)
|
||||
return DoIReport(
|
||||
repo_slug=report.repo_slug,
|
||||
tier=report.tier,
|
||||
core_pass=report.core_pass,
|
||||
standard_pass=report.standard_pass,
|
||||
full_pass=report.full_pass,
|
||||
checked_at=report.checked_at,
|
||||
criteria=[
|
||||
DoICriterion(id=c.id, label=c.label, tier=c.tier, status=c.status, detail=c.detail)
|
||||
for c in report.criteria
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{slug}/", response_model=RepoRead)
|
||||
async def get_repo(
|
||||
slug: str,
|
||||
|
||||
@@ -144,7 +144,9 @@ async def list_snapshots(
|
||||
repo_slug: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
q = select(TPSCSnapshot).options(selectinload(TPSCSnapshot.entries))
|
||||
q = select(TPSCSnapshot).options(
|
||||
selectinload(TPSCSnapshot.entries).selectinload(TPSCEntry.catalog_entry)
|
||||
)
|
||||
if repo_slug:
|
||||
repo = (await session.execute(select(ManagedRepo).where(ManagedRepo.slug == repo_slug))).scalar_one_or_none()
|
||||
if not repo:
|
||||
|
||||
Reference in New Issue
Block a user