generated from coulomb/repo-seed
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:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user