Files
cleveragents-core/tools/controller/master/ci_summarize.py
T
drew 6ba1926c52 fix(controller): batch A — datetime json, Forgejo state map, ISO compare, dequeue docs
Four narrow bug fixes flagged by adversarial code review (items 1, 5,
6, 7 from the consolidated critique).

ITEM 1 — datetime → json.dumps crash (silent write-after-work failure):
- ``worker/runner.py:274`` and ``master/scheduler.py:283`` now pass
  ``default=str`` to ``json.dumps`` so nested datetime fields (e.g.
  CISummary.observed_at) serialize without raising.
- Before this fix: a worker would do its real work, then crash on
  the terminal-state UPDATE with TypeError, get recorded as
  ``worker-internal-error``, and the output payload would be lost.
- Test: TestDatetimeSerializationSafety in test_master_ci_summarize
  + test_scheduler_handles_datetime_in_input_payload in
  test_master_prefetch (both pin the regression — the with-default
  test passes, the without-default test asserts the TypeError so
  future maintainers see the failure mode).

ITEM 5 — Forgejo state mapping completeness:
- Extended ``_FORGEJO_STATE_TO_GATE_STATUS`` in
  ``master/ci_summarize.py`` to cover ``cancelled``, ``timed_out``,
  ``action_required``, ``queued``, ``in_progress``, ``neutral``,
  ``skipped``, ``stale`` — states observed across Forgejo / Gitea /
  GH-mirror that previously collapsed to ``pending``, telling the
  implementer "CI is still running" when really a job was cancelled.
- ``cancelled`` / ``timed_out`` / ``action_required`` / ``stale``
  now map to ``error`` (the gate failed).
- ``queued`` / ``in_progress`` stay ``pending`` (still running).
- ``neutral`` / ``skipped`` → ``passed``/``skipped`` (informational).
- Test: TestExtendedForgejoStates — 6 tests covering each new state.

ITEM 6 — lexicographic ISO comparison drops/dupes comments:
- ``master/prefetch.py:_comment_bodies_since`` and ``_iso`` replaced
  with ``_to_aware_datetime`` + datetime comparison. Forgejo emits
  ``2026-05-18T12:00:00Z``; Python's ``datetime.isoformat()`` emits
  ``2026-05-18T12:00:00+00:00`` — a string compare gives 'Z' (0x5A)
  vs '+' (0x2B) which silently misorders timestamps.
- Now parses via ``datetime.fromisoformat`` (with Z → +00:00
  rewrite), defaults naive timestamps to UTC, and compares as
  ``datetime``.
- Test: test_comments_filter_handles_z_suffix_vs_offset_form pins
  the regression.

ITEM 7 — false BEGIN IMMEDIATE claim in dequeue docstring:
- The dequeue docstring claimed ``BEGIN IMMEDIATE`` was applied by
  session_scope; it wasn't. Attempted a global ``begin``-event
  listener that conflicted with StaticPool's shared-connection
  model (test_prefetch_callback_works_in_scheduler broke).
- Reverted to a documentation fix: SQLite stays on default
  BEGIN DEFERRED (the SQLITE_BUSY retry via busy_timeout=5s is
  acceptable for single-host dev) + the docs make MULTI-MACHINE
  REQUIRES POSTGRES explicit at three call sites
  (db/session.py, db/dequeue.py, RUNBOOK.md was already updated
  in Phase 1l). Postgres has FOR UPDATE SKIP LOCKED which is what
  production actually uses.

Tests: 586 controller tests pass (+10 new), 0 regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:17:38 -04:00

327 lines
11 KiB
Python

"""Deterministic CI summarizer — builds CISummary from Forgejo status.
The implementer/reviewer prompt prefetch needs a CISummary V1 dict for
the head_sha. This module takes the Forgejo combined-status response
(``{"state": "...", "statuses": [...]}``) plus a log fetcher, and
returns a fully-populated CISummary covering every gate.
Per-gate flow:
1. Map the Forgejo gate context (e.g., "CI / lint") → nox session
name. The job names in ``.forgejo/workflows/*.yml`` ARE the nox
session names by convention, so a simple suffix-after-/ extraction
works. Unknown contexts pass through as ``UnknownGate`` and skip
the parser.
2. Look up the nox session in NOX_SESSION_TO_PARSER → one or more
parser names.
3. For each parser, fetch the log (via the injected ``log_fetcher``),
run ``parse(log, gate_name)``, and wrap the result in a
GateResult.failure CIFailure dict.
4. For composite gates (security_scan = bandit + semgrep + vulture),
the outer CIFailure carries the FIRST parser's structure + each
sub-parser's result goes into ``composite_findings``.
The summarizer never raises — log fetch failures degrade to a
``CIFailure`` with ``error_class='log-fetch-failed'`` so the
implementer at least sees the failure existed.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from datetime import datetime, timezone
from typing import Any
from ..ci_summary_parsers import NOX_SESSION_TO_PARSER, NOX_SESSIONS_SKIP_COVERAGE
from ..ci_summary_parsers._registry import (
ResolvedParser,
resolve_for_nox_session,
)
logger = logging.getLogger(__name__)
# log_fetcher(gate_name) -> str | None; production wires this to a
# Forgejo job-log fetcher. None means the log was unreachable.
LogFetcher = Callable[[str], str | None]
# Forgejo / Gitea / GH-mirror status states → GateResult.status.
# Covers states observed in the wild across Forgejo, Gitea, and
# GitHub-mirroring (action_required, neutral, stale, timed_out come
# from the GH side; Forgejo can emit cancelled/queued/in_progress for
# Actions-style runs).
_FORGEJO_STATE_TO_GATE_STATUS = {
"success": "passed",
"failure": "failed",
"error": "error",
"pending": "pending",
"queued": "pending",
"in_progress": "pending",
"warning": "passed", # advisory: treat as passed
"neutral": "passed", # GH-style "no decision" — informational
"skipped": "skipped",
"stale": "error", # GH-style — treat as failed-equivalent
"cancelled": "error", # user/system cancelled mid-run
"timed_out": "error",
"action_required": "error", # GH waits for human approval — treat as failed
None: "pending",
}
def summarize_ci_status(
*, head_sha: str, forgejo_status: dict | None, log_fetcher: LogFetcher,
observed_at: datetime | None = None,
) -> dict:
"""Build a CISummary-shape dict from the Forgejo combined-status.
Args:
head_sha: the commit SHA the status covers.
forgejo_status: response body from
``GET /repos/{owner}/{repo}/commits/{sha}/status``.
Shape: ``{"state": str, "statuses": [{"context": str,
"state": str, "target_url": str}]}``. None for
"couldn't fetch" — produces a unknown-state summary.
log_fetcher: callable that takes the gate context and returns
the raw log text (or None if unreachable). Production
wires this via Forgejo's job-log endpoint.
observed_at: timestamp the status was fetched. Defaults to
now(UTC).
Returns the CISummary V1-shape dict (a CISummary contract parses
it after the caller validates).
"""
when = observed_at or datetime.now(timezone.utc)
if forgejo_status is None:
return _unknown_summary(head_sha=head_sha, observed_at=when)
overall = _FORGEJO_OVERALL_STATE_MAPPING.get(
forgejo_status.get("state"), "unknown",
)
raw_statuses = forgejo_status.get("statuses") or []
statuses = [s for s in raw_statuses if isinstance(s, dict)]
gates: list[dict] = []
parser_versions: dict[str, str] = {}
for s in statuses:
gate, versions_seen = _build_gate(s, log_fetcher)
gates.append(gate)
parser_versions.update(versions_seen)
counts = _count_gates(gates)
return {
"summary_version": "V1",
"head_sha": head_sha,
"observed_at": when,
"overall_state": overall,
"gates": gates,
"gates_total": counts["total"],
"gates_passed": counts["passed"],
"gates_failed": counts["failed"],
"gates_skipped": counts["skipped"],
"gates_pending": counts["pending"],
"parser_versions": parser_versions,
}
# Forgejo combined-status states → CISummary.overall_state.
_FORGEJO_OVERALL_STATE_MAPPING: dict[str | None, str] = {
"success": "success",
"failure": "failure",
"error": "error",
"pending": "pending",
None: "unknown",
}
def _unknown_summary(*, head_sha: str, observed_at: datetime) -> dict:
return {
"summary_version": "V1",
"head_sha": head_sha,
"observed_at": observed_at,
"overall_state": "unknown",
"gates": [],
"gates_total": 0,
"gates_passed": 0,
"gates_failed": 0,
"gates_skipped": 0,
"gates_pending": 0,
"parser_versions": {},
}
def _build_gate(
status: dict, log_fetcher: LogFetcher,
) -> tuple[dict, dict[str, str]]:
"""Build one GateResult dict + return parser versions used."""
context = status.get("context") or "(unknown)"
state_raw = status.get("state")
gate_status = _FORGEJO_STATE_TO_GATE_STATUS.get(state_raw, "pending")
target_url = status.get("target_url")
severity = "info" if gate_status in {"passed", "skipped"} else "error"
gate: dict[str, Any] = {
"name": context,
"status": gate_status,
"severity": severity,
"target_url": target_url,
"duration_seconds": None,
"failure": None,
}
parser_versions: dict[str, str] = {}
# Only parse failures/errors. Passed/pending/skipped don't need a
# log fetch — they have nothing to report.
if gate_status not in {"failed", "error"}:
return gate, parser_versions
nox_session = _gate_to_nox_session(context)
if nox_session is None or nox_session in NOX_SESSIONS_SKIP_COVERAGE:
gate["failure"] = _no_parser_failure(context)
return gate, parser_versions
try:
parsers = resolve_for_nox_session(nox_session)
except KeyError:
logger.warning(
"no parser map for nox session %r (gate=%r)",
nox_session, context,
)
gate["failure"] = _no_parser_failure(context)
return gate, parser_versions
log = _safe_fetch(log_fetcher, context)
if log is None:
gate["failure"] = _log_fetch_failed_failure(parsers, context)
for p in parsers:
parser_versions[p.name] = p.version
return gate, parser_versions
gate["failure"] = _build_failure(parsers, log, context, parser_versions)
return gate, parser_versions
def _gate_to_nox_session(context: str) -> str | None:
"""Forgejo gate context → nox session name.
Convention: ``<workflow-name> / <job-name>`` (e.g., "CI / lint").
The job name IS the nox session name by repo convention. If the
name doesn't match any known session, return None and let the
summarizer fall through to NoParserAvailable."""
if not context:
return None
tail = context.rsplit("/", 1)[-1].strip()
if tail in NOX_SESSION_TO_PARSER:
return tail
# Also support the parameterized form e.g. "unit_tests-3.13".
base = tail.split("-", 1)[0]
if base in NOX_SESSION_TO_PARSER:
return base
return None
def _safe_fetch(log_fetcher: LogFetcher, context: str) -> str | None:
try:
return log_fetcher(context)
except Exception as exc: # noqa: BLE001
logger.warning("log fetch raised for %s: %s", context, exc)
return None
def _no_parser_failure(context: str) -> dict:
return {
"parser_used": "(none)",
"parser_version": "n/a",
"error_class": "NoParserAvailable",
"summary_line": f"No parser available for {context!r}"[:200],
"findings": [],
"failing_locations": [],
"failed_assertions": [],
"raw_log_excerpt": "",
"log_excerpt_lines": 0,
"composite_findings": [],
}
def _log_fetch_failed_failure(parsers, context: str) -> dict:
parser_names = ", ".join(p.name for p in parsers)
return {
"parser_used": parser_names or "(unknown)",
"parser_version": "n/a",
"error_class": "log-fetch-failed",
"summary_line": f"Log fetch failed for {context!r}"[:200],
"findings": [],
"failing_locations": [],
"failed_assertions": [],
"raw_log_excerpt": "",
"log_excerpt_lines": 0,
"composite_findings": [],
}
def _build_failure(
parsers: list[ResolvedParser], log: str, context: str,
parser_versions: dict[str, str],
) -> dict:
"""Build the CIFailure dict from one or more parsers' results.
For a single-parser gate the result becomes the outer CIFailure.
For a composite gate (multi: prefix), the FIRST parser becomes
the outer + the rest go into composite_findings.
"""
results = []
for p in parsers:
try:
res = p.parse(log, context)
parser_versions[p.name] = p.version
results.append((p, res))
except Exception as exc: # noqa: BLE001
logger.warning(
"parser %s raised on gate %r: %s", p.name, context, exc,
)
# Fall through with no result for this parser.
if not results:
return _log_fetch_failed_failure(parsers, context)
head_parser, head_result = results[0]
failure = _result_to_failure(head_parser, head_result)
if len(results) > 1:
failure["composite_findings"] = [
_result_to_failure(p, r) for p, r in results[1:]
]
return failure
def _result_to_failure(parser: ResolvedParser, result) -> dict:
return {
"parser_used": parser.name,
"parser_version": parser.version,
"error_class": result.error_class,
"summary_line": result.summary_line,
"findings": [f.model_dump() for f in result.findings],
"failing_locations": [loc.model_dump() for loc in result.failing_locations],
"failed_assertions": [a.model_dump() for a in result.failed_assertions],
"raw_log_excerpt": result.log_excerpt,
"log_excerpt_lines": result.log_excerpt_lines,
"composite_findings": [],
}
def _count_gates(gates: list[dict]) -> dict[str, int]:
counts = {"total": len(gates), "passed": 0, "failed": 0,
"skipped": 0, "pending": 0}
for g in gates:
status = g.get("status")
if status == "passed":
counts["passed"] += 1
elif status in {"failed", "error"}:
counts["failed"] += 1
elif status == "skipped":
counts["skipped"] += 1
elif status == "pending":
counts["pending"] += 1
return counts
__all__ = ["LogFetcher", "summarize_ci_status"]