From afac54ec0940b3bc39d6bbd7f4a43286f782b60c Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 28 Feb 2026 17:28:41 +0100 Subject: [PATCH] feat(state-hub): v0.3 MCP tools + dashboard pages for contributions and SBOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- dashboard/observablehq.config.js | 2 + dashboard/src/contributions.md | 166 +++++++++++++++++++++++ dashboard/src/data/contributions.json.py | 15 ++ dashboard/src/data/sbom.json.py | 24 ++++ dashboard/src/index.md | 26 ++++ dashboard/src/sbom.md | 150 ++++++++++++++++++++ mcp_server/server.py | 149 ++++++++++++++++++++ 7 files changed, 532 insertions(+) create mode 100644 dashboard/src/contributions.md create mode 100644 dashboard/src/data/contributions.json.py create mode 100644 dashboard/src/data/sbom.json.py create mode 100644 dashboard/src/sbom.md diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index 6588057..5aeb1cf 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -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: [ diff --git a/dashboard/src/contributions.md b/dashboard/src/contributions.md new file mode 100644 index 0000000..9c759e4 --- /dev/null +++ b/dashboard/src/contributions.md @@ -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`
+ + ${_ok ? `Live · ${_ts?.toLocaleTimeString()}` : html`API offline`} +
`; +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`
+ ${typeFilter}${statFilter}${repoFilter} +
`); +``` + +```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`
+
+

Total

+

${contribs.length}

+
+ ${["br","fr","ep","upr"].map(t => html` +
+

${typeLabels[t]}

+

${typeCounts[t]}

+
+ `)} +
+${needsFollowUp > 0 ? html`` : ""} +`); +``` + +## 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`

No contributions match the current filters.

`); +} else { + display(html`
+ ${activeCols.map(s => html` +
+
${s.label} ${colMap[s.key].length}
+ ${colMap[s.key].map(c => html` +
+
${c.type.toUpperCase()}
+
${c.title}
+ ${c.target_org || c.target_repo ? html`
${[c.target_org, c.target_repo].filter(Boolean).join("/")}
` : ""} + ${c.body_path ? html`
${c.body_path}
` : ""} +
${new Date(c.created_at).toLocaleDateString()}
+
+ `)} +
+ `)} +
`); +} +``` + +## 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})); +``` + + diff --git a/dashboard/src/data/contributions.json.py b/dashboard/src/data/contributions.json.py new file mode 100644 index 0000000..b1420a0 --- /dev/null +++ b/dashboard/src/data/contributions.json.py @@ -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": []})) diff --git a/dashboard/src/data/sbom.json.py b/dashboard/src/data/sbom.json.py new file mode 100644 index 0000000..6a9606b --- /dev/null +++ b/dashboard/src/data/sbom.json.py @@ -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)) diff --git a/dashboard/src/index.md b/dashboard/src/index.md index ab376c7..e737628 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -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``); +``` + ## Status ```js diff --git a/dashboard/src/sbom.md b/dashboard/src/sbom.md new file mode 100644 index 0000000..76abff6 --- /dev/null +++ b/dashboard/src/sbom.md @@ -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`✓ No copyleft in direct prod deps` + : html`⚠ ${riskCount} direct prod dep(s) with copyleft licence`; +display(html`
+
+

Total Packages

+

${entries.length}

+
+
+

Repos Scanned

+

${new Set(entries.map(e => e.repo_id)).size}

+
+
+

Licence Risk

+

${riskCount}

+ ${riskBadge} +
+
+

Unique Licences

+

${groups.length}

+
+
`); +``` + +## Licence Distribution + +```js +import * as Plot from "npm:@observablehq/plot"; + +if (groups.length === 0) { + display(html`

No SBOM data ingested yet. Run make ingest-sbom REPO=<slug>.

`); +} 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`

✓ No copyleft packages found.

`); +} else { + display(html`
+ ${copyleftGroups.map(g => html` +
+ ${g.license_spdx ?? "unknown"} + ${g.count} package(s) + ${g.repos.join(", ")} +
+ `)} +
`); +} +``` + +## 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`
+ ${ecoFilter}${directOnly}${prodOnly} +
`); +``` + +```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})); +``` + + diff --git a/mcp_server/server.py b/mcp_server/server.py index 8a95615..fcf089e 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -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 # ---------------------------------------------------------------------------