diff --git a/api/schemas/managed_repo.py b/api/schemas/managed_repo.py index bf9ae69..3f4d9c3 100644 --- a/api/schemas/managed_repo.py +++ b/api/schemas/managed_repo.py @@ -33,5 +33,7 @@ class RepoRead(BaseModel): description: str | None = None status: str topic_id: uuid.UUID | None = None + sbom_source: str | None = None + last_sbom_at: datetime | None = None created_at: datetime updated_at: datetime diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index 5aeb1cf..2a0ce1f 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -8,6 +8,7 @@ export default { { name: "Decisions", path: "/decisions" }, { name: "Progress", path: "/progress" }, { name: "Domains", path: "/domains" }, + { name: "Repos", path: "/repos" }, { name: "Extension Points", path: "/extensions" }, { name: "Technical Debt", path: "/techdept" }, { name: "Contributions", path: "/contributions" }, diff --git a/dashboard/src/repos.md b/dashboard/src/repos.md new file mode 100644 index 0000000..2e604bf --- /dev/null +++ b/dashboard/src/repos.md @@ -0,0 +1,255 @@ +--- +title: Repos +--- + +```js +const API = "http://127.0.0.1:8000"; +``` + +```js +let _repos = [], _domains = [], _sbom = [], _eps = [], _tds = [], _contribs = []; +try { + [_repos, _domains, _sbom, _eps, _tds, _contribs] = await Promise.all([ + fetch(`${API}/repos/`).then(r => r.ok ? r.json() : []), + fetch(`${API}/domains/`).then(r => r.ok ? r.json() : []), + fetch(`${API}/sbom/`).then(r => r.ok ? r.json() : []), + fetch(`${API}/extension-points/`).then(r => r.ok ? r.json() : []), + fetch(`${API}/technical-debt/`).then(r => r.ok ? r.json() : []), + fetch(`${API}/contributions/`).then(r => r.ok ? r.json() : []), + ]); +} catch {} +``` + +```js +const repos = _repos ?? []; +const domains = _domains ?? []; +const sbom = _sbom ?? []; +const eps = _eps ?? []; +const tds = _tds ?? []; +const contribs = _contribs ?? []; + +// Lookups +const domainById = Object.fromEntries(domains.map(d => [d.id, d])); +const domainBySlug = Object.fromEntries(domains.map(d => [d.slug, d])); + +// Per-repo SBOM stats (from sbom entries) +const sbomByRepo = {}; +for (const e of sbom) { + if (!sbomByRepo[e.repo_id]) sbomByRepo[e.repo_id] = { count: 0, snapshot_at: e.snapshot_at }; + sbomByRepo[e.repo_id].count++; +} + +// Per-domain counts +const epByDomain = {}; +const tdByDomain = {}; +const contribByDomain = {}; + +// EPs are domain-scoped +for (const ep of eps) { + if (!ep.status || ep.status === "open" || ep.status === "in_progress") { + epByDomain[ep.domain] = (epByDomain[ep.domain] ?? 0) + 1; + } +} +for (const td of tds) { + if (!td.status || td.status === "open" || td.status === "in_progress") { + tdByDomain[td.domain] = (tdByDomain[td.domain] ?? 0) + 1; + } +} +// Contributions: try to map via workstream → topic → domain (not available here; skip for now) +// Use domain slug from contributions' related_workstream if available — fallback: count by type only + +// Build enriched repo rows +const repoRows = repos + .filter(r => r.status === "active") + .map(r => { + const domain = domainById[r.domain_id]; + const domSlug = domain?.slug ?? "—"; + const domName = domain?.name ?? "—"; + const sbomData = sbomByRepo[r.id]; + const hasSbom = !!sbomData || !!r.last_sbom_at; + const pkgCount = sbomData?.count ?? 0; + const lastScan = r.last_sbom_at + ? new Date(r.last_sbom_at).toLocaleDateString() + : (sbomData?.snapshot_at ? new Date(sbomData.snapshot_at).toLocaleDateString() : null); + return { + _id: r.id, + _domSlug: domSlug, + _hasSbom: hasSbom, + repo: r.slug, + domain: domName, + path: r.local_path ?? "—", + sbom: hasSbom ? `✓ ${lastScan}` : "⚠ not ingested", + pkgs: pkgCount || (hasSbom ? "—" : 0), + eps: epByDomain[domSlug] ?? 0, + tds: tdByDomain[domSlug] ?? 0, + }; + }) + .sort((a, b) => a._domSlug.localeCompare(b._domSlug) || a.repo.localeCompare(b.repo)); + +const gapCount = repoRows.filter(r => !r._hasSbom).length; +const coveredCount = repoRows.filter(r => r._hasSbom).length; +``` + +# Repos + +```js +// Summary KPIs +display(html`
+
+

Registered Repos

+

${repoRows.length}

+
+
+

Domains

+

${new Set(repoRows.map(r => r._domSlug)).size}

+
+
+

SBOM Ingested

+

${coveredCount} / ${repoRows.length}

+
+
+

SBOM Gaps

+

${gapCount}

+ ${gapCount === 0 ? "✓ All repos covered" : `⚠ ${gapCount} repo(s) not ingested`} +
+
`); +``` + +## Coverage Map + +```js +// Group by domain +const byDomain = {}; +for (const r of repoRows) { + (byDomain[r._domSlug] = byDomain[r._domSlug] ?? []).push(r); +} + +const domainBlocks = Object.entries(byDomain).sort(([a], [b]) => a.localeCompare(b)); + +if (domainBlocks.length === 0) { + display(html`

No repos registered. Run make add-repo DOMAIN=<slug> SLUG=<slug> NAME="..." PATH=/path.

`); +} else { + display(html`
+ ${domainBlocks.map(([slug, rows]) => { + const dom = domainBySlug[slug]; + const allCovered = rows.every(r => r._hasSbom); + const hasEps = (epByDomain[slug] ?? 0) > 0; + const hasTds = (tdByDomain[slug] ?? 0) > 0; + return html` +
+
+ ${dom?.name ?? slug} + + ${allCovered + ? html`SBOM ✓` + : html`SBOM ⚠`} + ${hasEps + ? html`EPs: ${epByDomain[slug]}` + : html`EPs: —`} + ${hasTds + ? html`TDs: ${tdByDomain[slug]}` + : html`TDs: —`} + +
+ + + + + + + + + ${rows.map(r => html` + + + + + `)} + +
RepoSBOMPackagesLocal path
${r.repo}${r.sbom}${r.pkgs}${r.path}
+
+ `; + })} +
`); +} +``` + +## All Repos Table + +```js +const domainFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._domSlug)).values()], {label: "Domain", value: "all"}); +const gapFilter = Inputs.toggle({label: "Gaps only (no SBOM)", value: false}); +display(html`
${domainFilter}${gapFilter}
`); +``` + +```js +const filteredRows = repoRows.filter(r => + (domainFilter.value === "all" || r._domSlug === domainFilter.value) && + (!gapFilter.value || !r._hasSbom) +); + +display(Inputs.table(filteredRows.map(r => ({ + Repo: r.repo, + Domain: r.domain, + SBOM: r.sbom, + Pkgs: r.pkgs, + "EPs (domain)": r.eps || "—", + "TDs (domain)": r.tds || "—", + Path: r.path, +})), {maxWidth: 1000})); +``` + +## How to Ingest a Repo + +```js +display(html`
+

Register a new repo

+
cd ~/the-custodian/state-hub
+make add-repo DOMAIN=<slug> SLUG=<repo-slug> NAME="Display Name" PATH=/absolute/path
+ +

Ingest SBOM (single ecosystem, auto-detect lockfile at root)

+
make ingest-sbom REPO=<slug> REPO_PATH=/absolute/path
+ +

Ingest SBOM (multi-ecosystem repo — scans all lockfiles recursively)

+
make ingest-sbom REPO=<slug> SCAN=1 REPO_PATH=/absolute/path
+ +

Infra-only repos (Ansible/shell — no lockfile)

+

Register the repo for inventory purposes. SBOM gap is expected and intentional. + Terraform providers are tracked via .terraform.lock.hcl (auto-detected by --scan).

+
`); +``` + +