6ba1926c52
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>
327 lines
11 KiB
Python
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"]
|