feat(state-hub): v0.3 MCP tools + dashboard pages for contributions and SBOM

MCP server additions (5 tools + 3 resources):
- register_contribution(), update_contribution_status(), get_contributions()
- ingest_sbom_tool(repo_slug, lockfile_path) — shells out to ingest_sbom.py
- get_licence_report()
- state://contributions, state://sbom/aggregated, state://sbom/{repo_slug}

Dashboard pages:
- contributions.md — live-polled Kanban by status (draft→merged), filter bar
  (type/status/repo), KPI grid (total + per type), follow-up banner, full table
- sbom.md — licence distribution bar chart (Plot), copyleft risk section,
  package table with ecosystem/direct/dev filters, repo-slug resolution
- data/contributions.json.py, data/sbom.json.py — Observable data loaders
- index.md — added Contribution & SBOM Health KPI row (total, follow-up count,
  copyleft risk indicator; sourced from state summary fields)
- observablehq.config.js — added Contributions + SBOM to nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 17:28:41 +01:00
parent 8d38110275
commit afac54ec09
7 changed files with 532 additions and 0 deletions

View File

@@ -10,6 +10,8 @@ export default {
{ name: "Domains", path: "/domains" },
{ name: "Extension Points", path: "/extensions" },
{ name: "Technical Debt", path: "/techdept" },
{ name: "Contributions", path: "/contributions" },
{ name: "SBOM", path: "/sbom" },
{
name: "Reference",
pages: [

View File

@@ -0,0 +1,166 @@
---
title: Contributions
---
```js
const API = "http://127.0.0.1:8000";
const POLL = 30_000;
```
```js
// Live poll for contributions
const contribState = (async function*() {
while (true) {
let data = [], ok = false;
try {
const r = await fetch(`${API}/contributions/`);
ok = r.ok;
data = ok ? await r.json() : [];
} catch {}
yield {data, ok, ts: new Date()};
await new Promise(res => setTimeout(res, POLL));
}
})();
```
```js
const contribs = contribState.data ?? [];
const _ok = contribState.ok ?? false;
const _ts = contribState.ts;
```
# Contributions
```js
import {injectTocTop} from "./components/toc-sidebar.js";
const _liveEl = html`<div class="live-indicator">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok ? `Live · ${_ts?.toLocaleTimeString()}` : html`<span style="color:red">API offline</span>`}
</div>`;
injectTocTop("live-indicator", _liveEl);
```
```js
// Filters
const typeFilter = Inputs.select(["all", "br", "fr", "ep", "upr"], {label: "Type", value: "all"});
const statFilter = Inputs.select(
["all", "draft", "submitted", "acknowledged", "accepted", "rejected", "merged", "withdrawn"],
{label: "Status", value: "all"}
);
const repoFilter = Inputs.text({label: "Target repo", placeholder: "filter by repo…"});
display(html`<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">
${typeFilter}${statFilter}${repoFilter}
</div>`);
```
```js
const tf = typeFilter.value;
const sf = statFilter.value;
const rf = repoFilter.value?.trim().toLowerCase() ?? "";
const filtered = contribs.filter(c =>
(tf === "all" || c.type === tf) &&
(sf === "all" || c.status === sf) &&
(!rf || (c.target_repo ?? "").toLowerCase().includes(rf))
);
```
## Summary
```js
const typeLabels = {br: "Bug Report", fr: "Feature Request", ep: "Extension Point", upr: "Upstream PR"};
const typeCounts = Object.fromEntries(["br","fr","ep","upr"].map(t => [
t, contribs.filter(c => c.type === t).length
]));
const needsFollowUp = contribs.filter(c => ["submitted","acknowledged"].includes(c.status)).length;
display(html`<div class="grid grid-cols-5" style="gap:1rem;margin-bottom:1.5rem">
<div class="card">
<h3>Total</h3>
<p class="big-num">${contribs.length}</p>
</div>
${["br","fr","ep","upr"].map(t => html`
<div class="card">
<h3>${typeLabels[t]}</h3>
<p class="big-num">${typeCounts[t]}</p>
</div>
`)}
</div>
${needsFollowUp > 0 ? html`<div class="follow-up-banner">⚠ ${needsFollowUp} contribution(s) awaiting upstream response (submitted / acknowledged)</div>` : ""}
`);
```
## Status Kanban
```js
const statusCols = [
{key: "draft", label: "Draft", color: "#aaa"},
{key: "submitted", label: "Submitted", color: "steelblue"},
{key: "acknowledged", label: "Acknowledged",color: "#f0a500"},
{key: "accepted", label: "Accepted", color: "#4caf50"},
{key: "merged", label: "Merged", color: "#2e7d32"},
{key: "rejected", label: "Rejected", color: "#e53935"},
{key: "withdrawn", label: "Withdrawn", color: "#bbb"},
];
const colMap = {};
for (const c of filtered) {
(colMap[c.status] = colMap[c.status] ?? []).push(c);
}
const activeCols = statusCols.filter(s => colMap[s.key]?.length);
if (activeCols.length === 0) {
display(html`<p style="color:gray">No contributions match the current filters.</p>`);
} else {
display(html`<div class="kanban">
${activeCols.map(s => html`
<div class="kanban-col">
<div class="kanban-header" style="border-bottom:2px solid ${s.color}">${s.label} <span class="kanban-count">${colMap[s.key].length}</span></div>
${colMap[s.key].map(c => html`
<div class="contrib-card">
<div class="contrib-badge contrib-badge-${c.type}">${c.type.toUpperCase()}</div>
<div class="contrib-title">${c.title}</div>
${c.target_org || c.target_repo ? html`<div class="contrib-repo">${[c.target_org, c.target_repo].filter(Boolean).join("/")}</div>` : ""}
${c.body_path ? html`<div class="contrib-path">${c.body_path}</div>` : ""}
<div class="contrib-date">${new Date(c.created_at).toLocaleDateString()}</div>
</div>
`)}
</div>
`)}
</div>`);
}
```
## All Contributions
```js
display(Inputs.table(filtered.map(c => ({
Type: c.type.toUpperCase(),
Title: c.title,
Status: c.status,
Target: [c.target_org, c.target_repo].filter(Boolean).join("/") || "—",
Created: new Date(c.created_at).toLocaleDateString(),
})), {maxWidth: 900}));
```
<style>
.live-indicator { font-size: 0.8rem; color: gray; padding: 0.55rem 0.7rem; margin-bottom: 0.75rem; }
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
.big-num { font-size: 2.2rem; font-weight: bold; margin: 0.25rem 0; }
.follow-up-banner { background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 0.5rem 0.9rem; margin-bottom: 1rem; font-size: 0.9rem; }
.kanban { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.kanban-col { background: var(--theme-background-alt); border-radius: 8px; padding: 0.75rem; }
.kanban-header { font-weight: 600; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.04em; padding-bottom: 0.5rem; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; }
.kanban-count { font-size: 0.75rem; background: var(--theme-background); border-radius: 10px; padding: 0.1rem 0.4rem; font-weight: 500; }
.contrib-card { background: var(--theme-background); border-radius: 6px; padding: 0.6rem 0.75rem; margin-bottom: 0.5rem; }
.contrib-badge { display: inline-block; font-size: 0.65rem; font-weight: 700; border-radius: 3px; padding: 0.1rem 0.35rem; margin-bottom: 0.25rem; }
.contrib-badge-br { background: #fde8e8; color: #c62828; }
.contrib-badge-fr { background: #e3f2fd; color: #1565c0; }
.contrib-badge-ep { background: #f3e5f5; color: #6a1b9a; }
.contrib-badge-upr { background: #e8f5e9; color: #2e7d32; }
.contrib-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 0.2rem; line-height: 1.3; }
.contrib-repo { font-size: 0.75rem; color: steelblue; font-family: monospace; }
.contrib-path { font-size: 0.7rem; color: gray; font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.contrib-date { font-size: 0.7rem; color: gray; margin-top: 0.3rem; }
</style>

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env python3
"""Observable data loader: fetches /contributions/ from the API."""
import json
import os
import urllib.request
import urllib.error
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
try:
with urllib.request.urlopen(f"{API_BASE}/contributions/", timeout=10) as resp:
data = json.loads(resp.read())
print(json.dumps(data))
except urllib.error.URLError as e:
print(json.dumps({"error": str(e), "contributions": []}))

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Observable data loader: fetches /sbom/ and /sbom/report/licences/ from the API."""
import json
import os
import urllib.request
import urllib.error
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
result = {"entries": [], "licence_report": {"groups": [], "copyleft_direct_count": 0}}
try:
with urllib.request.urlopen(f"{API_BASE}/sbom/", timeout=10) as resp:
result["entries"] = json.loads(resp.read())
except urllib.error.URLError as e:
result["error_entries"] = str(e)
try:
with urllib.request.urlopen(f"{API_BASE}/sbom/report/licences/", timeout=10) as resp:
result["licence_report"] = json.loads(resp.read())
except urllib.error.URLError as e:
result["error_licences"] = str(e)
print(json.dumps(result))

View File

@@ -156,6 +156,32 @@ if (openWs.length === 0) {
}
```
## Contribution & SBOM Health
```js
const contribCounts = summary.contribution_counts ?? {};
const licenceRisk = summary.licence_risk_count ?? 0;
const totalContribs = ["br","fr","ep","upr"].reduce((s, t) => s + (contribCounts[t] ?? 0), 0);
const needsFollowUp = (contribCounts["submitted"] ?? 0) + (contribCounts["acknowledged"] ?? 0);
display(html`<div class="grid grid-cols-3" style="gap:1rem;margin-bottom:1.5rem">
<a class="card card-link" href="./contributions">
<h3>Contributions</h3>
<p class="big-num">${totalContribs}</p>
<small>${needsFollowUp > 0 ? html`<span style="color:orange">${needsFollowUp} awaiting upstream response</span>` : "all up to date"}</small>
</a>
<a class="card card-link ${licenceRisk > 0 ? 'warn' : ''}" href="./sbom">
<h3>Licence Risk</h3>
<p class="big-num">${licenceRisk}</p>
<small>${licenceRisk === 0 ? html`<span style="color:green">✓ no copyleft in direct deps</span>` : html`<span style="color:red">copyleft in direct prod deps</span>`}</small>
</a>
<a class="card card-link" href="./sbom">
<h3>SBOM</h3>
<p class="big-num" style="font-size:1rem;padding-top:0.5rem">By type: ${["br","fr","ep","upr"].filter(t => contribCounts[t]).map(t => html`<span style="margin-right:0.4rem">${t.toUpperCase()} ${contribCounts[t]}</span>`).join("") || "—"}</p>
</a>
</div>`);
```
## Status
```js

150
dashboard/src/sbom.md Normal file
View File

@@ -0,0 +1,150 @@
---
title: SBOM
---
```js
const API = "http://127.0.0.1:8000";
```
```js
// Fetch SBOM data on load
let _entries = [], _report = {groups: [], copyleft_direct_count: 0}, _repos = [];
try {
[_entries, _report, _repos] = await Promise.all([
fetch(`${API}/sbom/`).then(r => r.ok ? r.json() : []),
fetch(`${API}/sbom/report/licences/`).then(r => r.ok ? r.json() : {groups:[], copyleft_direct_count: 0}),
fetch(`${API}/repos/`).then(r => r.ok ? r.json() : []),
]);
} catch {}
```
```js
const entries = _entries ?? [];
const report = _report ?? {groups: [], copyleft_direct_count: 0};
const repos = _repos ?? [];
const groups = report.groups ?? [];
const riskCount = report.copyleft_direct_count ?? 0;
```
# SBOM
## Licence Risk
```js
const riskBadge = riskCount === 0
? html`<span class="risk-ok">✓ No copyleft in direct prod deps</span>`
: html`<span class="risk-warn">⚠ ${riskCount} direct prod dep(s) with copyleft licence</span>`;
display(html`<div class="kpi-row">
<div class="card">
<h3>Total Packages</h3>
<p class="big-num">${entries.length}</p>
</div>
<div class="card">
<h3>Repos Scanned</h3>
<p class="big-num">${new Set(entries.map(e => e.repo_id)).size}</p>
</div>
<div class="card ${riskCount > 0 ? 'card-warn' : ''}">
<h3>Licence Risk</h3>
<p class="big-num">${riskCount}</p>
<small>${riskBadge}</small>
</div>
<div class="card">
<h3>Unique Licences</h3>
<p class="big-num">${groups.length}</p>
</div>
</div>`);
```
## Licence Distribution
```js
import * as Plot from "npm:@observablehq/plot";
if (groups.length === 0) {
display(html`<p style="color:gray">No SBOM data ingested yet. Run <code>make ingest-sbom REPO=&lt;slug&gt;</code>.</p>`);
} else {
const plotData = groups.slice(0, 15).map(g => ({
licence: g.license_spdx ?? "(unknown)",
count: g.count,
copyleft: g.is_copyleft,
}));
display(Plot.plot({
x: {label: "Packages"},
y: {label: null, domain: plotData.map(d => d.licence)},
color: {domain: [false, true], range: ["steelblue", "#e53935"], legend: true, tickFormat: d => d ? "Copyleft" : "Permissive"},
marks: [
Plot.barX(plotData, {y: "licence", x: "count", fill: "copyleft", tip: true}),
Plot.ruleX([0]),
],
marginLeft: 130,
height: Math.max(80, plotData.length * 30 + 50),
width: 600,
}));
}
```
## Copyleft Risk Detail
```js
const copyleftGroups = groups.filter(g => g.is_copyleft);
if (copyleftGroups.length === 0) {
display(html`<p style="color:green">✓ No copyleft packages found.</p>`);
} else {
display(html`<div class="copyleft-section">
${copyleftGroups.map(g => html`
<div class="copyleft-card">
<span class="copyleft-badge">${g.license_spdx ?? "unknown"}</span>
<span class="copyleft-count">${g.count} package(s)</span>
<span class="copyleft-repos">${g.repos.join(", ")}</span>
</div>
`)}
</div>`);
}
```
## Package Table
```js
// Filters
const ecoFilter = Inputs.select(["all", "python", "node", "rust", "go", "java", "other"], {label: "Ecosystem", value: "all"});
const directOnly = Inputs.toggle({label: "Direct deps only", value: false});
const prodOnly = Inputs.toggle({label: "Prod deps only (no dev)", value: false});
display(html`<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">
${ecoFilter}${directOnly}${prodOnly}
</div>`);
```
```js
// Build repo_id → slug lookup
const repoById = Object.fromEntries(_repos.map(r => [r.id, r.slug]));
const filteredEntries = entries.filter(e =>
(ecoFilter.value === "all" || e.ecosystem === ecoFilter.value) &&
(!directOnly.value || e.is_direct) &&
(!prodOnly.value || !e.is_dev)
);
display(Inputs.table(filteredEntries.map(e => ({
Package: e.package_name,
Version: e.package_version ?? "—",
Ecosystem: e.ecosystem,
Licence: e.license_spdx ?? "—",
Repo: repoById[e.repo_id] ?? e.repo_id?.slice(0, 8) ?? "—",
Direct: e.is_direct ? "✓" : "",
Dev: e.is_dev ? "✓" : "",
})), {maxWidth: 900}));
```
<style>
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
.card-warn { border: 2px solid #e53935; }
.kpi-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.big-num { font-size: 2.2rem; font-weight: bold; margin: 0.25rem 0; }
.risk-ok { color: #2e7d32; font-weight: 600; }
.risk-warn { color: #e53935; font-weight: 600; }
.copyleft-section { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
.copyleft-card { background: #fde8e8; border-left: 4px solid #e53935; border-radius: 6px; padding: 0.5rem 0.9rem; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
.copyleft-badge { font-weight: 700; font-size: 0.85rem; color: #c62828; }
.copyleft-count { font-size: 0.82rem; color: #555; }
.copyleft-repos { font-size: 0.8rem; color: gray; font-family: monospace; }
</style>

View File

@@ -813,6 +813,155 @@ def validate_repo_adr(repo_path: str, domain_slug: str | None = None) -> str:
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Contribution tracking (v0.3)
# ---------------------------------------------------------------------------
@mcp.resource("state://contributions")
def resource_contributions() -> str:
"""All contribution artifacts (BR/FR/EP/UPR)."""
return json.dumps(_get("/contributions"), indent=2)
@mcp.resource("state://sbom/aggregated")
def resource_sbom_aggregated() -> str:
"""Aggregated SBOM entries across all repos."""
return json.dumps(_get("/sbom"), indent=2)
@mcp.resource("state://sbom/{repo_slug}")
def resource_sbom_repo(repo_slug: str) -> str:
"""SBOM view for a specific repo (by slug)."""
return json.dumps(_get(f"/sbom/{repo_slug}"), indent=2)
@mcp.tool()
def register_contribution(
type: str,
title: str,
target_org: str | None = None,
target_repo: str | None = None,
body_path: str | None = None,
related_workstream_id: str | None = None,
notes: str | None = None,
) -> str:
"""Register a new upstream contribution artifact (BR/FR/EP/UPR).
Args:
type: br | fr | ep | upr
title: Short human-readable title
target_org: GitHub org or owner of the upstream project
target_repo: Repository name of the upstream project
body_path: Relative path to the Markdown artifact file in the repo
related_workstream_id: UUID of the related workstream (optional)
notes: Any additional notes (optional)
"""
contrib = _post("/contributions", {
"type": type,
"title": title,
"target_org": target_org,
"target_repo": target_repo,
"body_path": body_path,
"related_workstream_id": related_workstream_id,
"notes": notes,
})
_post("/progress", {
"workstream_id": related_workstream_id,
"event_type": "contribution_registered",
"summary": f"Contribution registered [{type.upper()}]: {title}",
"author": "custodian",
"detail": {
"contribution_id": contrib["id"],
"type": type,
"target": f"{target_org}/{target_repo}" if target_org else target_repo,
"body_path": body_path,
},
})
return json.dumps(contrib, indent=2)
@mcp.tool()
def update_contribution_status(
contribution_id: str,
status: str,
notes: str | None = None,
) -> str:
"""Update the status of a contribution artifact.
Valid transitions: draft→submitted→acknowledged→accepted→merged
↘ ↘
rejected withdrawn
Args:
contribution_id: UUID of the contribution
status: submitted | acknowledged | accepted | rejected | merged | withdrawn
notes: Optional context for the status change
"""
contrib = _patch(f"/contributions/{contribution_id}/status", {
"status": status,
"notes": notes,
})
_post("/progress", {
"event_type": "contribution_status_changed",
"summary": f"Contribution status → {status}: {contrib['title']}",
"author": "custodian",
"detail": {"contribution_id": contribution_id, "status": status, "notes": notes},
})
return json.dumps(contrib, indent=2)
@mcp.tool()
def get_contributions(
type: str | None = None,
status: str | None = None,
target_repo: str | None = None,
) -> str:
"""List contribution artifacts, optionally filtered.
Args:
type: br | fr | ep | upr (optional)
status: draft | submitted | acknowledged | accepted | rejected | merged | withdrawn (optional)
target_repo: filter by upstream repo name (optional)
"""
return json.dumps(_get("/contributions", {
"type": type, "status": status, "target_repo": target_repo,
}), indent=2)
@mcp.tool()
def ingest_sbom_tool(repo_slug: str, lockfile_path: str) -> str:
"""Ingest a lockfile into the State Hub SBOM store for a repo.
Parses the lockfile and POSTs entries to /sbom/ingest/. Old entries
for the repo are replaced (snapshot strategy).
Args:
repo_slug: Managed-repo slug (must be registered via register_repo)
lockfile_path: Absolute path to the lockfile (uv.lock, package-lock.json, Cargo.lock, etc.)
"""
import subprocess
script = Path(__file__).parent.parent / "scripts" / "ingest_sbom.py"
result = subprocess.run(
[sys.executable, str(script), "--repo", repo_slug,
"--lockfile", lockfile_path, "--api-base", API_BASE],
capture_output=True, text=True,
)
output = (result.stdout + result.stderr).strip()
if result.returncode != 0:
return f"ingest_sbom failed (exit {result.returncode}):\n{output}"
return output
@mcp.tool()
def get_licence_report() -> str:
"""Get a licence report across all ingested SBOM entries.
Returns packages grouped by SPDX licence identifier, with copyleft
flag (GPL/AGPL/LGPL/EUPL/CDDL/MPL) and repos using each licence.
"""
return json.dumps(_get("/sbom/report/licences"), indent=2)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------