6ab6df319c
Diagnosed live on PR 39 (STUCK 2026-05-20): both CI gates ran 17 minutes, the captured log was <3 minutes and ended mid-execution (`still running` / a just-launched runner) with ZERO error markers. A hard process kill (OOM-killer / pod eviction) cannot flush a buffer, print a traceback or emit an exit code — it leaves a *hole*, not a phrase. The classifier called this `fresh_real`, the implementer was sent to "fix" a failure with nothing to fix, and the PR dead-ended at blocked → STUCK. New `indeterminate` verdict: a failing gate whose FULL log carries no terminal verdict marker (`##[error]`, test summary, Traceback, exit code) AND ends mid-execution. Both conditions required — "no marker" alone over-fires on a real failure whose tool output isn't in the marker set (e.g. `ruff format`). Routed to a bounded rerun, same as `infra_broken`/`stale`, via `ci_status_poll` and `ci_gate`. Also fixes an infra-signature regression exposed by the full-log change: the bare step-name signatures (`git fetch`, `Set up job`, `actions/checkout`) matched the checkout/setup preamble of EVERY job log — against a full untruncated log they turned every failing run into `infra_broken`. Replaced with genuine error-text signatures (`could not read from remote`, `download action repository failed`, `unable to access`, `failed to connect`). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
643 lines
27 KiB
Python
643 lines
27 KiB
Python
"""Deterministic CI-freshness classifier.
|
|
|
|
Pure functions, no I/O — fully unit-testable. Consumes the Forgejo
|
|
combined-CI-status data the controller already fetches via
|
|
``get_ci_status`` (``{"state": "...", "sha": "...", "statuses":
|
|
[{"context": "...", "state": "...", "description": "...",
|
|
"target_url": "..."}]}``) for the *structural* decision (pass / fail /
|
|
pending / SHA-match), and the failing jobs' actual **CI log text** for
|
|
the infra-vs-real classification. It decides whether a Discovered PR's
|
|
CI is a real verdict, infrastructure-broken, missing, or still
|
|
running.
|
|
|
|
THE INCIDENT this addresses
|
|
---------------------------
|
|
A PR was discovered with 2-day-stale CI where every job failed at the
|
|
*checkout* step (a ``git fetch`` connection-reset — pure infra, no
|
|
code ever ran). Nothing in the pipeline distinguished that from a
|
|
real code failure: the implementer correctly said "nothing to fix,
|
|
rerun CI", emitted ``outcome=blocked``, and dead-ended at STUCK after
|
|
burning an estimator + a tier-2 implementer attempt.
|
|
|
|
This classifier is the missing signal. It is timestamp-light: the
|
|
load-bearing detection is (1) infra-class failure-signature matching
|
|
on the failing jobs' **log content**, and (2) head-SHA matching (CI
|
|
that ran on a different/older commit is not a verdict for the current
|
|
HEAD).
|
|
|
|
Why log content, not status descriptions
|
|
----------------------------------------
|
|
Real Forgejo commit-status ``description`` fields are generic — the
|
|
incident's failed jobs reported literally ``"Failing after 1m47s"``,
|
|
``"Failing after 2s"``, etc. The actual infra error (``curl 56 Recv
|
|
failure: Connection reset by peer``, ``fatal: expected 'packfile'``,
|
|
``RPC failed``) lives ONLY in the CI **job logs**. So the
|
|
infra-signature scan runs against the ``failure_logs`` text the caller
|
|
fetches via ``_ci_logs`` (the controller's existing CI-log
|
|
machinery), not against the status ``description``/``context``
|
|
strings.
|
|
|
|
Verdicts
|
|
--------
|
|
- ``infra_broken`` — CI ran and failed but the failure is
|
|
checkout/setup-class: ZERO structured code findings AND the failing
|
|
jobs' log content contains a setup/checkout failure signature
|
|
(``curl 56``, ``expected 'packfile'``, ``could not read from
|
|
remote``, connection reset, ...). Precedent:
|
|
``_is_infrastructure_error()`` in the Robot listener and the
|
|
``NoParserAvailable`` path in ``ci_summary_parsers``.
|
|
- ``stale`` — CI ran on the current head SHA and FAILED, but its
|
|
newest status is older than ``CONTROLLER_CI_MAX_AGE_S`` (default 6h)
|
|
and no infra signature could be confirmed. An old failure cannot be
|
|
trusted — Forgejo may have purged its logs, and an old infra blip is
|
|
indistinguishable from an old real failure. Timestamp-based, so it
|
|
works even when log fetching is unavailable. Routed to a rerun, same
|
|
as ``infra_broken``.
|
|
- ``indeterminate`` — CI ran on the current head SHA and FAILED, but
|
|
the failing jobs' FULL log content carries **no terminal verdict
|
|
marker** at all: no ``##[error]``, no test-runner result summary, no
|
|
``Traceback``, no ``nox`` failure line, no exit-code line. The log
|
|
simply stops mid-execution. That is the signature of a hard process
|
|
kill (OOM-killer / pod eviction / SIGKILL) — a killed process cannot
|
|
flush a buffer, print a stack trace, or emit an exit code, so it
|
|
leaves a *hole*, not a phrase. Diagnosed live on PR 39 (2026-05-20):
|
|
both gates ran 17 minutes, the log captured <3 minutes and ended on
|
|
``still running`` / a just-launched runner, with zero error markers.
|
|
This is NOT a real code failure (there is nothing to fix — sending an
|
|
implementer dead-ends it at ``blocked`` → STUCK) and NOT a confirmed
|
|
``infra_broken`` (no network signature). Routed to a *bounded* rerun,
|
|
same as ``infra_broken``/``stale``. Detection requires the FULL
|
|
untruncated log (the unified ``get_ci_logs`` bundle) — a head-
|
|
truncated log would spuriously look marker-less.
|
|
- ``no_ci`` — no CI status exists for the current head SHA at all
|
|
(empty statuses), or every status is for a different/older commit
|
|
(SHA mismatch).
|
|
- ``pending`` — CI is currently running/queued.
|
|
- ``fresh_real`` — CI ran on the current head SHA with a real result
|
|
(success, or a failure whose logs carry no infra signature, or a
|
|
failure with real structured code findings). The normal case; the
|
|
regular promote-to-ANALYZING path handles it. Conservative default:
|
|
an unrecognized failure is ``fresh_real`` so a genuine bug is never
|
|
rerun-looped.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
|
|
# ─── infra-class failure signatures ──────────────────────────────────
|
|
|
|
# Substrings that, when present in a failing job's LOG TEXT, indicate
|
|
# a checkout / job-setup class failure rather than a code failure.
|
|
# Matched case-insensitively. Kept deliberately conservative: only
|
|
# well-known infra indicators — an unknown failure is assumed to be a
|
|
# real code failure (and classified ``fresh_real``).
|
|
#
|
|
# These are scanned against the CI job-log content the caller fetches
|
|
# (via ``_ci_logs``), NOT the Forgejo status description (which is a
|
|
# generic duration string like "Failing after 1m47s" and never
|
|
# carries the error text).
|
|
#
|
|
# Every entry is genuine ERROR TEXT — a string that appears ONLY when
|
|
# something actually failed. Bare step *names* (``git fetch``, ``Set
|
|
# up job``, ``actions/checkout``) were removed (2026-05-20): they
|
|
# appear in the checkout/setup preamble of EVERY job log, pass or
|
|
# fail, so against a full untruncated log (the unified ``get_ci_logs``
|
|
# bundle) they matched every failing run indiscriminately — turning
|
|
# genuine code failures into ``infra_broken`` rerun loops. A checkout
|
|
# that fails for an infra reason always emits one of the actual
|
|
# transport errors below.
|
|
#
|
|
# Sources:
|
|
# - ``curl 56`` / ``expected 'packfile'`` / ``could not read from
|
|
# remote`` / ``unable to access`` — the exact git-over-HTTPS
|
|
# transport errors a reset connection produces during a clone/fetch.
|
|
# - ``download action repository failed`` — Forgejo failed to fetch a
|
|
# composite action before any user code ran.
|
|
# - connection reset/refused/timeout/resolve — generic network infra.
|
|
INFRA_FAILURE_SIGNATURES: tuple[str, ...] = (
|
|
"curl 56",
|
|
"expected 'packfile'",
|
|
"could not read from remote",
|
|
"unable to access",
|
|
"download action repository failed",
|
|
"checkout failed",
|
|
"connection reset",
|
|
"connection refused",
|
|
"connection timed out",
|
|
"could not resolve host",
|
|
"failed to connect",
|
|
"early eof",
|
|
"rpc failed",
|
|
"the remote end hung up",
|
|
"tls handshake",
|
|
"runner is offline",
|
|
"no space left on device",
|
|
)
|
|
|
|
# ─── terminal verdict markers ────────────────────────────────────────
|
|
#
|
|
# A failing CI job whose FULL log contains AT LEAST ONE of these has
|
|
# produced a real verdict — a test runner, a linter, or the Forgejo
|
|
# runner itself reported an error. A failing job whose full log
|
|
# contains NONE of them never reached a verdict: its process was
|
|
# hard-killed (OOM-killer / pod eviction / SIGKILL), which cannot
|
|
# flush a buffer, print a stack trace, or emit an exit code. That is
|
|
# the ``indeterminate`` case (diagnosed live on PR 39, 2026-05-20).
|
|
#
|
|
# This is an ABSENCE test, so it is only sound against a FULL,
|
|
# untruncated log (the unified ``get_ci_logs`` bundle). A head-
|
|
# truncated log would drop early markers — but the markers below all
|
|
# live at the TAIL of a real failure, exactly the region tail-
|
|
# truncation keeps, so even a legacy tail is reasonably safe.
|
|
_TERMINAL_MARKER_SUBSTRINGS: tuple[str, ...] = (
|
|
"##[error]", # Forgejo Actions step-failure annotation
|
|
"traceback (most recent call last)", # Python exception
|
|
"short test summary info", # pytest summary header
|
|
"process completed with exit code", # runner step result line
|
|
"the operation was canceled", # step timeout / cancellation
|
|
"error:", # mypy / compiler / linter error line
|
|
"assertionerror", # an assertion fired
|
|
)
|
|
|
|
# Regex forms of the same idea — compiled case-insensitive.
|
|
_TERMINAL_MARKER_PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
re.compile(r"nox > command .+ failed", re.I), # nox session failed
|
|
re.compile( # behave summary line ("1 feature passed, 0 failed")
|
|
r"\b\d+\s+(feature|scenario|step)s?\s+(passed|failed)", re.I
|
|
),
|
|
re.compile(r"\bfailed\s+robot\.", re.I), # robot test-failure line
|
|
re.compile( # robot / pabot run summary ("10 tests, 8 passed")
|
|
r"\b\d+\s+tests?,\s*\d+\s+(passed|failed)", re.I
|
|
),
|
|
re.compile(r"\bexit code\s+[1-9]", re.I), # non-zero exit code
|
|
re.compile(r"=+\s*\d+\s+(failed|error)", re.I), # pytest "=== N failed"
|
|
re.compile(r"\b\d+\s+failed\b", re.I), # test-count summary ("1 failed")
|
|
re.compile(r"\bfound\s+\d+\s+error", re.I), # ruff ("Found 3 errors")
|
|
)
|
|
|
|
|
|
def _log_has_terminal_marker(text: str) -> bool:
|
|
"""True when ``text`` contains any terminal verdict marker — i.e.
|
|
CI actually reported a result. ``False`` means the log carries no
|
|
verdict at all (a candidate for the ``indeterminate`` case)."""
|
|
if not text:
|
|
return False
|
|
lowered = text.lower()
|
|
if any(sub in lowered for sub in _TERMINAL_MARKER_SUBSTRINGS):
|
|
return True
|
|
return any(pat.search(text) for pat in _TERMINAL_MARKER_PATTERNS)
|
|
|
|
|
|
# Forgejo prepends every log line with an RFC-3339 timestamp + space
|
|
# (``2026-05-20T18:02:38.8098496Z ``). Stripped before matching the
|
|
# in-progress patterns below so ``^nox >`` anchors correctly.
|
|
_TS_PREFIX_RE = re.compile(r"^\S*Z\s+")
|
|
|
|
|
|
def _log_ends_mid_execution(text: str) -> bool:
|
|
"""True when the log's TAIL shows work still IN PROGRESS — a test
|
|
still running, a session/command only just launched — rather than
|
|
a finished result.
|
|
|
|
Combined with the absence of a terminal marker, this is the
|
|
hard-kill (OOM-killer / pod eviction) signature: the process was
|
|
reaped mid-run and could not print a verdict. Requiring this
|
|
POSITIVE evidence — not merely "no marker" — keeps a real failure
|
|
whose tool output simply isn't in the marker set (e.g. a niche
|
|
linter) from being mislabelled ``indeterminate``: such a log ends
|
|
on a result line, not mid-execution."""
|
|
lines = [ln for ln in (text or "").splitlines() if ln.strip()]
|
|
if not lines:
|
|
return False
|
|
for raw in lines[-3:]:
|
|
ln = _TS_PREFIX_RE.sub("", raw).strip()
|
|
low = ln.lower()
|
|
# A parallel test runner (robot/pabot, behave) printing live
|
|
# progress — the job was alive and working when the log ended.
|
|
if "still running" in low or "executing " in low:
|
|
return True
|
|
# A nox action line as one of the LAST lines: nox launched a
|
|
# session/command and the log stopped before its verdict.
|
|
# (A nox FAILURE line is a terminal marker, caught upstream.)
|
|
if low.startswith("nox > ") and "success" not in low:
|
|
return True
|
|
return False
|
|
|
|
|
|
# Forgejo combined-status states that count as "still running".
|
|
_PENDING_STATES: frozenset[str] = frozenset(
|
|
{
|
|
"pending",
|
|
"queued",
|
|
"in_progress",
|
|
"running",
|
|
}
|
|
)
|
|
|
|
# Forgejo combined-status states that count as a failure.
|
|
_FAILURE_STATES: frozenset[str] = frozenset(
|
|
{
|
|
"failure",
|
|
"error",
|
|
"timed_out",
|
|
}
|
|
)
|
|
|
|
# Per-status states that count as a failed gate.
|
|
_GATE_FAILURE_STATES: frozenset[str] = frozenset(
|
|
{
|
|
"failure",
|
|
"error",
|
|
"timed_out",
|
|
}
|
|
)
|
|
|
|
# A failed CI run whose newest status is older than this is "stale":
|
|
# Forgejo may have purged its job logs, and an old infra blip is
|
|
# indistinguishable from an old real failure — rerun for a current
|
|
# verdict. Env-tunable; default 6h (CI on the fork takes ~25min, so a
|
|
# failed run untouched for 6h is well past any in-progress window).
|
|
DEFAULT_CI_MAX_AGE_S: float = float(os.environ.get("CONTROLLER_CI_MAX_AGE_S", "21600"))
|
|
|
|
VERDICTS: frozenset[str] = frozenset(
|
|
{
|
|
"infra_broken",
|
|
"indeterminate",
|
|
"no_ci",
|
|
"pending",
|
|
"fresh_real",
|
|
"stale",
|
|
}
|
|
)
|
|
|
|
|
|
# get_failure_logs(owner, repo, head_sha) -> concatenated CI job-log
|
|
# text for the failing jobs (empty string when none / unreachable).
|
|
# Production wires this to ``_ci_logs.fetch_pr_failure_logs``; tests
|
|
# inject a fake. The classifier scans the returned text for
|
|
# infra-class signatures — the load-bearing input for the
|
|
# infra-vs-real decision.
|
|
GetFailureLogsCallback = Callable[[str, str, str], str]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CIClassification:
|
|
"""Result of ``classify_ci_result``.
|
|
|
|
Attributes:
|
|
verdict: one of ``infra_broken`` / ``indeterminate`` /
|
|
``stale`` / ``no_ci`` / ``pending`` / ``fresh_real``.
|
|
reason: a short human-readable explanation (stored in the
|
|
controller_events payload for operator post-mortems).
|
|
head_sha: the head SHA the classification was made against.
|
|
matched_signatures: the infra signatures that fired (empty
|
|
unless ``verdict == 'infra_broken'``). Matched against the
|
|
failing jobs' CI log content.
|
|
failing_contexts: the gate contexts that were failing.
|
|
"""
|
|
|
|
verdict: str
|
|
reason: str
|
|
head_sha: str
|
|
matched_signatures: list[str] = field(default_factory=list)
|
|
failing_contexts: list[str] = field(default_factory=list)
|
|
|
|
|
|
def _norm(value: object) -> str:
|
|
"""Lower-cased string coercion; ``None`` → empty string."""
|
|
if value is None:
|
|
return ""
|
|
return str(value).strip().lower()
|
|
|
|
|
|
def _infra_signatures_in(text: str) -> list[str]:
|
|
"""Return every infra signature found in ``text`` (lower-cased)."""
|
|
lowered = text.lower()
|
|
return [sig for sig in INFRA_FAILURE_SIGNATURES if sig in lowered]
|
|
|
|
|
|
def _parse_ts(value: object) -> datetime | None:
|
|
"""Parse a Forgejo ISO-8601 timestamp (``2026-05-17T17:40:10Z`` or
|
|
with an explicit offset). Returns a tz-aware UTC datetime, or
|
|
``None`` for anything unparseable."""
|
|
if not isinstance(value, str) or not value.strip():
|
|
return None
|
|
raw = value.strip()
|
|
if raw.endswith(("Z", "z")):
|
|
raw = raw[:-1] + "+00:00"
|
|
try:
|
|
dt = datetime.fromisoformat(raw)
|
|
except ValueError:
|
|
return None
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return dt
|
|
|
|
|
|
def _newest_status_ts(statuses: list[dict]) -> datetime | None:
|
|
"""Return the most recent ``updated_at`` (falling back to
|
|
``created_at``) across ``statuses``, or ``None`` when none carry a
|
|
parseable timestamp — i.e. CI age cannot be determined."""
|
|
times: list[datetime] = []
|
|
for s in statuses:
|
|
for key in ("updated_at", "created_at"):
|
|
dt = _parse_ts(s.get(key))
|
|
if dt is not None:
|
|
times.append(dt)
|
|
break
|
|
return max(times) if times else None
|
|
|
|
|
|
def combined_status_is_failure(forgejo_status: dict | None) -> bool:
|
|
"""True when a Forgejo combined-CI-status reports a failure —
|
|
either the overall ``state`` or any per-gate ``state`` is a
|
|
failure state. Callers use this to decide whether a CI job-log
|
|
fetch is worth doing (no point fetching logs for a green run).
|
|
"""
|
|
if not isinstance(forgejo_status, dict):
|
|
return False
|
|
if _norm(forgejo_status.get("state")) in _FAILURE_STATES:
|
|
return True
|
|
for s in forgejo_status.get("statuses") or []:
|
|
if isinstance(s, dict) and _norm(s.get("state")) in _GATE_FAILURE_STATES:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _status_is_for_sha(status: dict, head_sha: str) -> bool:
|
|
"""A per-status entry matches the current head when it carries no
|
|
SHA (Forgejo combined-status sub-entries usually omit it — they're
|
|
already scoped to the queried commit) OR its SHA matches.
|
|
|
|
Only a status that explicitly carries a *different* SHA is treated
|
|
as a mismatch.
|
|
"""
|
|
sha = _norm(status.get("sha"))
|
|
if not sha:
|
|
return True
|
|
return sha == head_sha.strip().lower()
|
|
|
|
|
|
def classify_ci_result(
|
|
*,
|
|
head_sha: str | None,
|
|
forgejo_status: dict | None,
|
|
failure_logs: str | None = None,
|
|
has_structured_findings: bool = False,
|
|
now: datetime | None = None,
|
|
max_age_s: float | None = None,
|
|
) -> CIClassification:
|
|
"""Classify the freshness/health of a PR's CI.
|
|
|
|
The Forgejo combined status drives the *structural* decision
|
|
(pass / fail / pending / SHA-match). The infra-vs-real decision
|
|
for a failure is made by scanning ``failure_logs`` — the actual
|
|
CI job-log text — for checkout/setup-class signatures. Status
|
|
descriptions are NOT scanned: real Forgejo descriptions are
|
|
generic duration strings ("Failing after 1m47s") that never carry
|
|
the error text.
|
|
|
|
Args:
|
|
head_sha: the PR's current head commit SHA. ``None`` / empty
|
|
means we couldn't determine HEAD — treated as ``no_ci``
|
|
(the gate cannot make a freshness decision without it).
|
|
forgejo_status: the response body from
|
|
``GET /repos/{owner}/{repo}/commits/{sha}/status`` —
|
|
``{"state": str, "sha": str | None, "statuses":
|
|
[{"context": str, "state": str, "description": str,
|
|
"target_url": str}]}``. ``None`` means the fetch failed.
|
|
failure_logs: the concatenated CI **job-log** text for the
|
|
failing jobs (fetched by the caller via ``_ci_logs``).
|
|
The infra-signature scan runs against this. ``None`` /
|
|
empty means the caller could not fetch logs — an
|
|
otherwise-infra-looking failure then degrades to
|
|
``fresh_real`` (conservative: never rerun-loop a failure
|
|
we can't actually confirm is infra).
|
|
has_structured_findings: True when the controller already has
|
|
structured code findings for this CI run (e.g. a populated
|
|
``ci_summary`` with non-empty ``findings``). When True, an
|
|
otherwise infra-looking failure is treated as ``fresh_real``
|
|
— real findings outrank a signature match. The gate
|
|
normally passes ``False`` (it works off the raw combined
|
|
status before any parser runs).
|
|
now: the reference time for the staleness check. Defaults to
|
|
``datetime.now(timezone.utc)``; tests inject a fixed value.
|
|
max_age_s: a FAILED CI run whose newest status is older than
|
|
this is classified ``stale``. Defaults to
|
|
:data:`DEFAULT_CI_MAX_AGE_S` (``CONTROLLER_CI_MAX_AGE_S``
|
|
env, 6h).
|
|
|
|
Returns a :class:`CIClassification`. Never raises.
|
|
"""
|
|
sha = (head_sha or "").strip()
|
|
if not sha:
|
|
return CIClassification(
|
|
verdict="no_ci",
|
|
reason="no head SHA available for the PR; cannot poll CI",
|
|
head_sha="",
|
|
)
|
|
|
|
# No combined status at all (fetch failed OR commit has no CI).
|
|
if forgejo_status is None:
|
|
return CIClassification(
|
|
verdict="no_ci",
|
|
reason="no CI combined-status returned for the head SHA",
|
|
head_sha=sha,
|
|
)
|
|
|
|
raw_statuses = forgejo_status.get("statuses") or []
|
|
statuses = [s for s in raw_statuses if isinstance(s, dict)]
|
|
|
|
# SHA-match guard: if the combined status reports a SHA and it
|
|
# disagrees with the PR's HEAD, the status is for an older/other
|
|
# commit — it is NOT a verdict for the current code.
|
|
combined_sha = _norm(forgejo_status.get("sha"))
|
|
if combined_sha and combined_sha != sha.lower():
|
|
return CIClassification(
|
|
verdict="no_ci",
|
|
reason=(
|
|
f"CI combined-status is for SHA {combined_sha[:12]} but "
|
|
f"PR HEAD is {sha[:12]} — stale/mismatched run"
|
|
),
|
|
head_sha=sha,
|
|
)
|
|
|
|
# Keep only statuses that belong to the current HEAD.
|
|
current = [s for s in statuses if _status_is_for_sha(s, sha)]
|
|
if not current:
|
|
# Either zero statuses, or every status was for a different
|
|
# commit. Both mean "no CI for the current code".
|
|
reason = (
|
|
"no CI status exists for the current head SHA"
|
|
if not statuses
|
|
else "every CI status is for a different/older commit"
|
|
)
|
|
return CIClassification(
|
|
verdict="no_ci",
|
|
reason=reason,
|
|
head_sha=sha,
|
|
)
|
|
|
|
overall = _norm(forgejo_status.get("state"))
|
|
gate_states = [_norm(s.get("state")) for s in current]
|
|
|
|
# Anything still running → pending. Check per-gate AND the overall
|
|
# state so a combined "pending" with no sub-statuses is caught too.
|
|
if overall in _PENDING_STATES or any(gs in _PENDING_STATES for gs in gate_states):
|
|
return CIClassification(
|
|
verdict="pending",
|
|
reason="CI is still running/queued for the head SHA",
|
|
head_sha=sha,
|
|
)
|
|
|
|
failing = [s for s in current if _norm(s.get("state")) in _GATE_FAILURE_STATES]
|
|
overall_failed = overall in _FAILURE_STATES
|
|
|
|
# No failing gate and the overall state isn't a failure → the CI
|
|
# ran and passed (or is advisory-only). That's a real verdict.
|
|
if not failing and not overall_failed:
|
|
return CIClassification(
|
|
verdict="fresh_real",
|
|
reason="CI ran on the head SHA and did not fail",
|
|
head_sha=sha,
|
|
)
|
|
|
|
# There is a failure. If the controller already has structured
|
|
# code findings, the failure is real — findings outrank signatures.
|
|
if has_structured_findings:
|
|
return CIClassification(
|
|
verdict="fresh_real",
|
|
reason=("CI failed with structured code findings — a real code failure"),
|
|
head_sha=sha,
|
|
failing_contexts=[str(s.get("context") or "(unknown)") for s in failing],
|
|
)
|
|
|
|
failing_contexts = [str(s.get("context") or "(unknown)") for s in failing] or (
|
|
["(combined)"] if overall_failed else []
|
|
)
|
|
|
|
# Age of the CI run, from the newest status timestamp. A failed run
|
|
# that is old cannot be trusted: Forgejo may have purged its job
|
|
# logs, and an old infra blip is indistinguishable from an old real
|
|
# failure — the "don't assume an old stale run means it is down"
|
|
# case the gate exists for.
|
|
eff_now = now or datetime.now(timezone.utc)
|
|
eff_max_age = DEFAULT_CI_MAX_AGE_S if max_age_s is None else max_age_s
|
|
newest_ts = _newest_status_ts(current)
|
|
is_stale = (
|
|
newest_ts is not None and (eff_now - newest_ts).total_seconds() > eff_max_age
|
|
)
|
|
|
|
# Scan the failing jobs' LOG CONTENT for infra-class signatures.
|
|
# ``infra_broken`` requires ZERO structured code findings
|
|
# (has_structured_findings is False here) AND a setup/checkout
|
|
# signature in the actual log text. Status descriptions are NOT
|
|
# scanned — they're generic ("Failing after 1m47s") and never
|
|
# carry the error.
|
|
matched: list[str] = []
|
|
if failure_logs and str(failure_logs).strip():
|
|
for sig in _infra_signatures_in(str(failure_logs)):
|
|
if sig not in matched:
|
|
matched.append(sig)
|
|
|
|
if matched:
|
|
return CIClassification(
|
|
verdict="infra_broken",
|
|
reason=(
|
|
"CI failed at a checkout/setup-class step with no "
|
|
f"structured code findings (log signatures: {matched})"
|
|
),
|
|
head_sha=sha,
|
|
matched_signatures=matched,
|
|
failing_contexts=failing_contexts,
|
|
)
|
|
|
|
# No infra signature. Before the stale/fresh_real fallbacks: a
|
|
# failing run whose FULL job log carries NO terminal verdict
|
|
# marker (no ``##[error]``, no test-runner summary, no
|
|
# ``Traceback``, no exit-code line) AND whose tail shows work
|
|
# still in progress never actually produced a verdict — its
|
|
# process was hard-killed (OOM-killer / pod eviction). Handing
|
|
# that to an implementer as a "real" failure dead-ends it at
|
|
# ``blocked`` → STUCK (the PR-39 incident). Class it
|
|
# ``indeterminate`` so it is rerun for a verdict we can trust.
|
|
#
|
|
# BOTH conditions are required. "No marker" alone over-fires on a
|
|
# real failure whose tool output simply isn't in the marker set;
|
|
# demanding the log also END mid-execution restricts the verdict
|
|
# to a genuinely truncated (killed) run. Requires non-empty log
|
|
# text: an ABSENT log is "unfetchable" (handled by the
|
|
# stale/fresh_real fallbacks below).
|
|
log_text = str(failure_logs) if failure_logs else ""
|
|
if (
|
|
log_text.strip()
|
|
and not _log_has_terminal_marker(log_text)
|
|
and _log_ends_mid_execution(log_text)
|
|
):
|
|
return CIClassification(
|
|
verdict="indeterminate",
|
|
reason=(
|
|
"CI failed but the full job logs carry no terminal "
|
|
"verdict marker (no ##[error] / test summary / "
|
|
"Traceback / exit code) and end mid-execution — the "
|
|
"run was hard-killed (OOM / pod eviction) and never "
|
|
"produced a verdict; rerun for a real one"
|
|
),
|
|
head_sha=sha,
|
|
failing_contexts=failing_contexts,
|
|
)
|
|
|
|
# No infra signature — logs were absent/purged, or carried no
|
|
# signature. If the failing run is STALE, rerun it: an old failure
|
|
# whose logs we cannot inspect is exactly the incident case (an
|
|
# infra blip frozen as a 2-day-old red verdict). Timestamp-based,
|
|
# so it works even when log fetching is unavailable.
|
|
if is_stale:
|
|
age_h = (eff_now - newest_ts).total_seconds() / 3600.0
|
|
return CIClassification(
|
|
verdict="stale",
|
|
reason=(
|
|
f"CI failed and its newest status is {age_h:.1f}h old "
|
|
f"(> {eff_max_age / 3600.0:.0f}h threshold) — stale; "
|
|
"rerun for a current verdict"
|
|
),
|
|
head_sha=sha,
|
|
failing_contexts=failing_contexts,
|
|
)
|
|
|
|
# Recent failure, no infra signature, no structured findings →
|
|
# assume a real code failure. Conservative: an unrecognized recent
|
|
# failure is NOT treated as infra/stale (never rerun-loop a bug).
|
|
if not failure_logs or not str(failure_logs).strip():
|
|
reason = (
|
|
"CI failed recently but no job-log content was available "
|
|
"to classify it — treated as a real code failure"
|
|
)
|
|
else:
|
|
reason = (
|
|
"CI failed but the job logs carry no checkout/setup-class "
|
|
"signature — treated as a real code failure"
|
|
)
|
|
return CIClassification(
|
|
verdict="fresh_real",
|
|
reason=reason,
|
|
head_sha=sha,
|
|
failing_contexts=failing_contexts,
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"CIClassification",
|
|
"DEFAULT_CI_MAX_AGE_S",
|
|
"GetFailureLogsCallback",
|
|
"INFRA_FAILURE_SIGNATURES",
|
|
"VERDICTS",
|
|
"classify_ci_result",
|
|
"combined_status_is_failure",
|
|
]
|