Skip to content

Commit 5b7e6f5

Browse files
committed
feat(api): CHAOSS metrics JSON API under /api/chaoss/v1
New programmatic surface — same data the dashboard renders, exposed as JSON for callers that don't want to scrape HTML fragments. GET /api/chaoss/v1/topics 4 topic groups (Contributor / Software / Lifecycle / Organization) with metric counts. GET /api/chaoss/v1/metrics (?category=X) Catalogue of every metric spec. Pure static data, no upstream stores touched. Optional ?category filter. GET /api/chaoss/v1/metrics/{slug} One metric spec by slug. 404 on unknown. GET /api/chaoss/v1/repositories/github.com/{owner}/{repo}/metrics (?window=, ?category=, ?include=traces,recipes,series) Compute every metric for a repo. Per-metric upstream errors don't 500 the request; the metric reports "—" and the trace carries the error. GET /api/chaoss/v1/repositories/github.com/{owner}/{repo}/metrics/{slug} Compute a single metric. Same payload shape as one element from the all-metrics endpoint. Implementation details: * Path keeps ``github.com/`` as an explicit segment so a future ``gitlab.com/...`` resolver slots in without breaking existing clients. Versioned under ``/v1`` so a breaking change later bumps to ``/v2`` without disturbing what's deployed. * ``include`` query param is opt-in: traces, recipes and series are omitted by default. Without them the full 22-metric response is ~25 KB; with ``include=recipes`` it jumps to ~114 KB (recipes are three full scripts per metric × 22). * ``_spec_to_dict``, ``_trace_to_dict`` and ``_result_to_dict`` serialise the MetricSpec / QueryTrace / MetricResult dataclasses into stable JSON shapes. Recipe-builder output is already plain dict[str, str], passed through. * ``computed_at`` is an ISO-8601 timestamp (seconds precision) so callers can tell whether they're looking at fresh data. * Auth via the same ``maybe_require_auth`` gate as the existing routes — public when ``HUB_PUBLIC_KNOWLEDGE=true``. The existing HTML fragment route at ``GET /api/chaoss/github.com/{owner}/{repo}/{slug}`` is untouched — the dashboard's lazy-load skeletons keep calling it as before.
1 parent 6384f37 commit 5b7e6f5

1 file changed

Lines changed: 260 additions & 0 deletions

File tree

src/open_pulse/gui/hub/chaoss/routes.py

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
import logging
3131
import urllib.parse
32+
from datetime import datetime, timezone
3233
from pathlib import Path
3334

3435
from fastapi import APIRouter, Depends, HTTPException, Query, Request
@@ -299,3 +300,262 @@ def chaoss_metric_card(
299300
"window": window,
300301
},
301302
)
303+
304+
305+
# ═════════════════════════════════════════════════════════════════════════
306+
# JSON API · /api/chaoss/v1/*
307+
# ═════════════════════════════════════════════════════════════════════════
308+
#
309+
# Path layout:
310+
# GET /api/chaoss/v1/topics
311+
# GET /api/chaoss/v1/metrics
312+
# GET /api/chaoss/v1/metrics/{slug}
313+
# GET /api/chaoss/v1/repositories/github.com/{owner}/{repo}/metrics
314+
# GET /api/chaoss/v1/repositories/github.com/{owner}/{repo}/metrics/{slug}
315+
#
316+
# All routes return JSON. The repositories paths keep ``github.com`` as
317+
# an explicit path segment so the URL pattern stays host-agnostic — a
318+
# future ``gitlab.com/...`` resolver can slot in without breaking
319+
# existing clients.
320+
321+
# Fields requested via ``?include=`` that aren't returned by default
322+
# because they bulk up the payload.
323+
_OPTIONAL_FIELDS = {"recipes", "traces", "series"}
324+
325+
326+
def _parse_include(include: str | None) -> set[str]:
327+
"""Parse ``?include=recipes,traces,series`` into a set, ignoring
328+
unknown tokens. Empty string / missing param → empty set."""
329+
if not include:
330+
return set()
331+
return {
332+
tok.strip().lower()
333+
for tok in include.split(",")
334+
if tok.strip().lower() in _OPTIONAL_FIELDS
335+
}
336+
337+
338+
def _spec_to_dict(spec: metrics_mod.MetricSpec) -> dict[str, Any]:
339+
"""Stable JSON shape for one MetricSpec entry. Used by /topics, /metrics
340+
and as the static-spec header on every per-repo result."""
341+
return {
342+
"slug": spec.slug,
343+
"name": spec.name,
344+
"category": spec.category,
345+
"question": spec.question,
346+
"description": spec.description,
347+
"chaoss_url": spec.chaoss_url,
348+
"chaoss_level": spec.chaoss_level,
349+
"is_time_based": spec.is_time_based,
350+
}
351+
352+
353+
def _trace_to_dict(t: metrics_mod.QueryTrace) -> dict[str, Any]:
354+
return {
355+
"store": t.store,
356+
"engine": t.engine,
357+
"mode": t.mode,
358+
"title": t.title,
359+
"query": t.query,
360+
"result_summary": t.result_summary,
361+
"error": t.error,
362+
"deep_link": _open_in_databases(t.engine, t.query, t.mode),
363+
}
364+
365+
366+
def _result_to_dict(
367+
spec: metrics_mod.MetricSpec,
368+
result: metrics_mod.MetricResult,
369+
include: set[str],
370+
) -> dict[str, Any]:
371+
"""Serialise a computed MetricResult. ``traces`` / ``recipes`` /
372+
``series`` are omitted unless explicitly requested in ``include``."""
373+
payload: dict[str, Any] = {
374+
**_spec_to_dict(spec),
375+
"value": result.value,
376+
"label": result.label,
377+
"secondary": result.secondary,
378+
"headline_tone": result.headline_tone,
379+
"unification": result.unification or None,
380+
"notes": result.notes or None,
381+
"series_unit": result.series_unit,
382+
"visual": result.visual,
383+
"examples": result.examples or [],
384+
}
385+
if "series" in include and result.series:
386+
payload["series"] = result.series
387+
if "traces" in include and result.queries:
388+
payload["traces"] = [_trace_to_dict(t) for t in result.queries]
389+
if "recipes" in include and result.recipes:
390+
payload["recipes"] = result.recipes
391+
return payload
392+
393+
394+
def _compute_one(slug: str, full: str, window: int) -> tuple[metrics_mod.MetricSpec, metrics_mod.MetricResult]:
395+
"""Look up the spec, compute the metric. Raises 404 for an unknown
396+
slug. Per-metric upstream failures are caught inside ``compute()``
397+
and surface as ``queries[*].error`` rather than as HTTP errors."""
398+
spec = metrics_mod.spec_for(slug)
399+
if spec is None:
400+
raise HTTPException(status_code=404, detail=f"unknown metric: {slug}")
401+
canonical = f"https://github.com/{full}"
402+
try:
403+
result = spec.compute(full, canonical, window)
404+
except Exception as exc: # noqa: BLE001
405+
# Hard failure during compute (rather than per-trace) bubbles
406+
# up as a 500 with the error message attached.
407+
log.exception("metric %s failed for %s", slug, full)
408+
raise HTTPException(
409+
status_code=500,
410+
detail=f"compute failed for {slug} on {full}: {exc}",
411+
) from exc
412+
return spec, result
413+
414+
415+
@router.get(
416+
"/api/chaoss/v1/topics",
417+
dependencies=[Depends(maybe_require_auth)],
418+
)
419+
def chaoss_api_topics() -> dict[str, Any]:
420+
"""List the 4 CHAOSS topic groups (Contributor / Software /
421+
Lifecycle / Organization) with metric counts."""
422+
grouped = _grouped(metrics_mod.REGISTRY)
423+
by_name = {g["name"]: len(g["metrics"]) for g in grouped}
424+
return {
425+
"topics": [
426+
{
427+
"name": cat["name"],
428+
"blurb": cat["blurb"],
429+
"css": cat["css"],
430+
"url": cat["url"],
431+
"metric_count": by_name.get(cat["name"], 0),
432+
}
433+
for cat in CATEGORIES
434+
],
435+
}
436+
437+
438+
@router.get(
439+
"/api/chaoss/v1/metrics",
440+
dependencies=[Depends(maybe_require_auth)],
441+
)
442+
def chaoss_api_metrics(
443+
category: str | None = Query(
444+
None, description="Filter to one topic — Contributor / Software / Lifecycle / Organization."
445+
),
446+
) -> dict[str, Any]:
447+
"""List every metric spec (catalogue). Pure static data — no
448+
upstream stores are touched. Optionally filtered by ``category``."""
449+
specs = metrics_mod.REGISTRY
450+
if category:
451+
specs = [m for m in specs if m.category.lower() == category.lower()]
452+
return {"metrics": [_spec_to_dict(m) for m in specs]}
453+
454+
455+
@router.get(
456+
"/api/chaoss/v1/metrics/{slug}",
457+
dependencies=[Depends(maybe_require_auth)],
458+
)
459+
def chaoss_api_metric_spec(slug: str) -> dict[str, Any]:
460+
"""One metric spec by slug. 404 if unknown."""
461+
spec = metrics_mod.spec_for(slug)
462+
if spec is None:
463+
raise HTTPException(status_code=404, detail=f"unknown metric: {slug}")
464+
return _spec_to_dict(spec)
465+
466+
467+
@router.get(
468+
"/api/chaoss/v1/repositories/github.com/{owner}/{repo}/metrics",
469+
dependencies=[Depends(maybe_require_auth)],
470+
)
471+
def chaoss_api_repo_metrics(
472+
owner: str,
473+
repo: str,
474+
window: int = Query(DEFAULT_WINDOW_DAYS, ge=7, le=3650),
475+
category: str | None = Query(
476+
None, description="Compute only metrics in this topic."
477+
),
478+
include: str | None = Query(
479+
None,
480+
description=(
481+
"Comma-separated optional fields to include: "
482+
"``traces``, ``recipes``, ``series``. Default omits all "
483+
"three to keep payloads small (recipes alone are ~130 KB "
484+
"for the full 22-metric set)."
485+
),
486+
),
487+
) -> dict[str, Any]:
488+
"""Compute every metric for a repository and return them as JSON.
489+
490+
Per-metric upstream-store errors don't fail the request — they end
491+
up in that metric's ``traces[i].error`` (when ``include=traces``)
492+
and the metric reports a value of ``"—"``.
493+
"""
494+
window = _clamp_window(window)
495+
full = f"{owner}/{repo}"
496+
fields = _parse_include(include)
497+
specs = metrics_mod.REGISTRY
498+
if category:
499+
specs = [m for m in specs if m.category.lower() == category.lower()]
500+
501+
metrics_out: list[dict[str, Any]] = []
502+
for spec in specs:
503+
canonical = f"https://github.com/{full}"
504+
try:
505+
result = spec.compute(full, canonical, window)
506+
except Exception: # noqa: BLE001
507+
# One blown-up metric mustn't take out the others.
508+
log.exception("metric %s failed for %s", spec.slug, full)
509+
metrics_out.append({
510+
**_spec_to_dict(spec),
511+
"value": "—",
512+
"label": "compute failed",
513+
"secondary": None,
514+
"headline_tone": "danger",
515+
"unification": None,
516+
"notes": None,
517+
"series_unit": "events",
518+
"visual": None,
519+
"examples": [],
520+
"error": "internal compute error",
521+
})
522+
continue
523+
metrics_out.append(_result_to_dict(spec, result, fields))
524+
525+
return {
526+
"repo": full,
527+
"canonical_url": f"https://github.com/{full}",
528+
"window_days": window,
529+
"computed_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
530+
"metric_count": len(metrics_out),
531+
"metrics": metrics_out,
532+
}
533+
534+
535+
@router.get(
536+
"/api/chaoss/v1/repositories/github.com/{owner}/{repo}/metrics/{slug}",
537+
dependencies=[Depends(maybe_require_auth)],
538+
)
539+
def chaoss_api_repo_metric_one(
540+
owner: str,
541+
repo: str,
542+
slug: str,
543+
window: int = Query(DEFAULT_WINDOW_DAYS, ge=7, le=3650),
544+
include: str | None = Query(
545+
None,
546+
description="``traces``, ``recipes``, ``series`` (comma-separated).",
547+
),
548+
) -> dict[str, Any]:
549+
"""Compute a single metric for a repository. Same payload shape as
550+
one element of the ``metrics`` array in the all-metrics endpoint."""
551+
window = _clamp_window(window)
552+
full = f"{owner}/{repo}"
553+
fields = _parse_include(include)
554+
spec, result = _compute_one(slug, full, window)
555+
return {
556+
"repo": full,
557+
"canonical_url": f"https://github.com/{full}",
558+
"window_days": window,
559+
"computed_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
560+
**_result_to_dict(spec, result, fields),
561+
}

0 commit comments

Comments
 (0)