|
29 | 29 |
|
30 | 30 | import logging |
31 | 31 | import urllib.parse |
| 32 | +from datetime import datetime, timezone |
32 | 33 | from pathlib import Path |
33 | 34 |
|
34 | 35 | from fastapi import APIRouter, Depends, HTTPException, Query, Request |
@@ -299,3 +300,262 @@ def chaoss_metric_card( |
299 | 300 | "window": window, |
300 | 301 | }, |
301 | 302 | ) |
| 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