fix(web): Cloudflare UA block, Gitea path@ref, attempt 0

Three bugs the real API surfaced, none visible against a stubbed payload:

- Gitea here is proxied by Cloudflare, which answers urllib's default
  `Python-urllib/3.x` agent with 403 + "error code: 1010" before the request
  reaches Gitea — every endpoint 403s, including unauthenticated `/version`,
  so it reads exactly like a token problem. Send an explicit User-Agent.
- Gitea reports `path` as either `.gitea/workflows/lint.yml` or the
  `lint.yml@refs/heads/main` form; splitting on "/" alone rendered the latter
  as the workflow name "main". Drop the ref first.
- Gitea reports `run_attempt: 0` for a never-retried run while the job id the
  workflow builds uses github.run_attempt, which starts at 1 — so 0 must read
  as 1 or no VM would ever correlate to its run. (The existing `or 1` already
  did this; it now has a test naming why, against the real payload shape.)

HTTP failures now carry the server's own message (Gitea names the missing
scope; a Cloudflare interception says something else entirely) instead of a
bare status code that sent this investigation the wrong way once already.
This commit is contained in:
2026-07-25 15:27:01 +02:00
parent d0ea7f661d
commit 91ea90348c
3 changed files with 158 additions and 6 deletions
+53 -3
View File
@@ -47,6 +47,13 @@ logger = logging.getLogger(__name__)
# request open long enough to pile polls up on top of each other.
_TIMEOUT_SEC = 6.0
# MUST be set explicitly. The Gitea instance this talks to sits behind
# Cloudflare, which answers urllib's default ``Python-urllib/3.x`` agent with a
# 403 ("error code: 1010") before the request ever reaches Gitea -- an opaque
# failure that looks exactly like a token problem. Any ordinary agent string
# passes.
_USER_AGENT = "ci-orchestrator (+local CI dashboard)"
# One API call per window at most, however many pollers ask.
_TTL_SEC = 10.0
@@ -178,6 +185,16 @@ def _timestamp(payload: dict[str, Any], key: str) -> datetime | None:
return parsed
def _workflow_name(path: str) -> str:
"""Workflow file name out of Gitea's ``path``.
Gitea sends either a plain path (``.gitea/workflows/lint.yml``) or the
``<file>@<ref>`` form (``lint.yml@refs/heads/main``). Splitting on ``/``
alone would turn the latter into ``"main"``, so the ref is dropped first.
"""
return path.split("@", 1)[0].rsplit("/", 1)[-1]
def _map_status(status: str, conclusion: str) -> str:
"""Collapse Gitea's ``status`` + ``conclusion`` pair into one card state."""
mapped = _STATUS_MAP.get(status)
@@ -211,8 +228,11 @@ def _to_execution(payload: dict[str, Any], now: datetime) -> Execution:
return Execution(
run_id=_int(payload, "id"),
number=_int(payload, "run_number"),
# Gitea reports ``run_attempt = 0`` for a first (never-retried) run,
# while the job id the workflow builds uses github.run_attempt, which
# starts at 1 -- so 0 must read as 1 or no VM would ever match.
attempt=_int(payload, "run_attempt", 1) or 1,
workflow=_get(payload, "path").rsplit("/", 1)[-1],
workflow=_workflow_name(_get(payload, "path")),
repo=repo,
branch=_get(payload, "head_branch"),
commit=_get(payload, "head_sha"),
@@ -230,7 +250,12 @@ def _fetch(url: str, token: str) -> list[dict[str, Any]]:
"""GET one runs endpoint. Raises on transport/HTTP/payload trouble."""
# URL is built from the configured Gitea base URL, never user input.
request = urllib.request.Request(
url, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}
url,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"User-Agent": _USER_AGENT,
},
)
with urllib.request.urlopen(request, timeout=_TIMEOUT_SEC) as response:
body = json.loads(response.read().decode("utf-8"))
@@ -240,6 +265,31 @@ def _fetch(url: str, token: str) -> list[dict[str, Any]]:
return [item for item in runs if isinstance(item, dict)]
def _http_detail(exc: urllib.error.HTTPError, *, limit: int = 160) -> str:
"""Whatever the server said about the refusal, trimmed for a card notice.
Worth surfacing: Gitea's own body names the missing scope
(``required=[read:user], token scope=...``), and a Cloudflare interception
in front of it says something else entirely. Guessing from the status code
alone sent this exact investigation down the wrong path once already.
Unreadable bodies degrade to ``""`` -- this must never mask the real error.
"""
try:
raw = exc.read().decode("utf-8", errors="replace").strip()
except Exception:
return ""
if not raw:
return ""
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
detail = " ".join(raw.split())
else:
message = parsed.get("message") if isinstance(parsed, dict) else None
detail = str(message) if message else " ".join(raw.split())
return detail[:limit]
def _fetch_runs(
base_url: str, token: str, limit: int
) -> tuple[list[dict[str, Any]], str, str | None, bool, bool]:
@@ -258,7 +308,7 @@ def _fetch_runs(
try:
return _fetch(url, token), source, None, False, False
except urllib.error.HTTPError as exc:
last_error = f"Gitea API returned HTTP {exc.code}."
last_error = f"Gitea API returned HTTP {exc.code}. {_http_detail(exc)}".strip()
if exc.code == 401:
auth_failed = True
refused = True