fix(infra): status-reaper + watchdog read per-entry status not state (Gitea 1.22.6) #654

Closed
core-devops wants to merge 1 commits from ci/652-gitea-1-22-status-key into main
4 changed files with 211 additions and 4 deletions

View File

@ -224,7 +224,7 @@ def is_red(status: dict) -> tuple[bool, list[dict]]:
red_states = {"failure", "error"}
failed = [
s for s in statuses
if isinstance(s, dict) and s.get("state") in red_states
if isinstance(s, dict) and (s.get("status") or s.get("state") or "") in red_states
]
return (combined in red_states or bool(failed), failed)
@ -313,7 +313,7 @@ def render_body(sha: str, failed: list[dict], debug: dict) -> str:
else:
for s in failed:
ctx = s.get("context", "(no context)")
state = s.get("state", "(no state)")
state = s.get("status") or s.get("state") or "(no state)"
url = s.get("target_url") or ""
desc = (s.get("description") or "").strip()
entry = f"- **{ctx}** — `{state}`"
@ -546,7 +546,7 @@ def run_once(*, dry_run: bool = False) -> int:
"combined_state": status.get("state"),
"failed_contexts": [s.get("context") for s in failed],
"all_contexts": [
{"context": s.get("context"), "state": s.get("state")}
{"context": s.get("context"), "state": s.get("status") or s.get("state")}
for s in (status.get("statuses") or [])
if isinstance(s, dict)
],

View File

@ -447,7 +447,7 @@ def reap(
if not isinstance(s, dict):
continue
context = s.get("context") or ""
state = s.get("state") or ""
state = s.get("status") or s.get("state") or ""
# Only `failure` is the bug shape. `error`/`pending`/`success`
# left alone — they have other meanings.

View File

@ -624,3 +624,83 @@ def test_require_runtime_env_exits_when_missing(wd_module, monkeypatch):
with pytest.raises(SystemExit) as excinfo:
wd_module._require_runtime_env()
assert excinfo.value.code == 2
# --------------------------------------------------------------------------
# Gitea 1.22.6 per-entry `status` key (not `state`) — issue #652
# --------------------------------------------------------------------------
# Gitea 1.22.6 combined-status API returns per-entry objects with a
# `status` key (NOT `state`). is_red's per-entry check read
# `s.get("state")` which returned None for every entry, making the
# per-context red-detection dead code. Fix: `status or state` fallback.
#
# Canonical Gitea 1.22.6 per-entry shape:
# {"context": "...", "status": "failure", "target_url": "..."}
# (no `"state"` key per-entry; `"state"` exists only at top-level.)
def test_is_red_detects_canonical_status_key_per_entry(wd_module):
"""is_red must detect failure via the canonical `status` key
(not `state`) for per-entry statuses."""
red, failed = wd_module.is_red(_combined_status("failure", [
{"context": "ci/test", "status": "failure"},
]))
assert red is True
assert len(failed) == 1
assert failed[0]["context"] == "ci/test"
def test_is_red_status_key_takes_precedence_over_state_per_entry(wd_module):
"""If both `status` and `state` are present per-entry, `status`
(canonical) wins. A watchdog reading `state` would miss this."""
red, failed = wd_module.is_red(_combined_status("failure", [
{
"context": "ci/test",
"status": "failure", # canonical — should trigger
"state": "success", # old key — should be IGNORED
},
]))
assert red is True
assert failed[0]["context"] == "ci/test"
assert failed[0].get("status") == "failure"
def test_is_red_backward_compat_state_key_per_entry(wd_module):
"""Old-style per-entry objects using `state` key still work.
Fallback `state` must not be broken by the `status`-key fix."""
red, failed = wd_module.is_red(_combined_status("failure", [
{"context": "ci/test", "state": "failure"}, # old key — must still work
]))
assert red is True
assert len(failed) == 1
def test_render_body_uses_status_key_for_entry_state(wd_module):
"""render_body reads per-entry `s.get("status") or s.get("state")`
so the issue body correctly shows the state for Gitea-1.22.6-style
entries that only have `status` (no `state` key)."""
entries = [
{
"context": "ci/test",
"status": "failure",
"target_url": "https://ci.example/run/42",
"description": "tests failed",
},
{
"context": "ci/lint",
"status": "error",
"description": "linter crashed",
},
]
body = wd_module.render_body(
SHA_RED,
failed=entries,
debug={},
)
assert "ci/test" in body
assert "ci/lint" in body
assert "failure" in body
assert "error" in body
assert "tests failed" in body
assert "linter crashed" in body

View File

@ -773,3 +773,130 @@ def test_reap_continues_on_per_sha_apierror(sr_module, monkeypatch, capsys):
captured = capsys.readouterr()
assert "::warning::" in captured.out or "::notice::" in captured.out
assert SHA_A[:10] in captured.out
# --------------------------------------------------------------------------
# rev4: Gitea 1.22.6 per-entry `status` key (not `state`)
# --------------------------------------------------------------------------
# Gitea 1.22.6 combined-status API returns per-entry objects with a
# `status` key (NOT `state`). All prior revisions (rev1rev3) read
# `s.get("state")` per-entry and got None for every entry — their
# compensation code was unreachable. This test group exercises the fix:
# `state = s.get("status") or s.get("state") or ""`.
#
# Canonical Gitea 1.22.6 per-entry shape:
# {"context": "...", "status": "failure", "target_url": "..."}
# (no `"state"` key per-entry; `"state"` exists only at the top level.)
def test_reap_compensates_canonical_status_key(sr_module, monkeypatch):
"""Gitea 1.22.6: per-entry object has `status` key (not `state`).
reap() must detect failure via the canonical `status` key and
compensate schedule-only workflows."""
calls = []
def fake_api(method, path, *, body=None, query=None, expect_json=True):
calls.append((method, path, body))
return (201, {})
monkeypatch.setattr(sr_module, "api", fake_api)
workflow_map = {"drift": False} # schedule-only → compensable
# Gitea 1.22.6 canonical shape: `status` key, NO `state` key per-entry.
combined = {
"state": "failure", # top-level aggregate
"statuses": [
{
"context": "drift / drift (push)",
"status": "failure", # canonical key (no `state` key)
"target_url": "https://example.test/run/42",
}
],
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
assert counters["compensated"] == 1
assert counters["compensated_contexts"] == ["drift / drift (push)"]
assert len(calls) == 1
assert calls[0][0] == "POST"
def test_reap_status_key_takes_precedence_over_state_key(sr_module, monkeypatch):
"""If both `status` and `state` are present per-entry, `status`
(canonical) wins. This catches a reaper that reads the wrong key."""
calls = []
def fake_api(method, path, *, body=None, query=None, expect_json=True):
calls.append((method, path, body))
return (201, {})
monkeypatch.setattr(sr_module, "api", fake_api)
workflow_map = {"sweep-cf-tunnels": False}
# Both keys present; `status=failure` is the canonical value.
combined = {
"state": "failure",
"statuses": [
{
"context": "sweep-cf-tunnels / sweep (push)",
"status": "failure", # canonical — should trigger comp.
"state": "success", # old-key — should be IGNORED
"target_url": "https://x/y",
}
],
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
# MUST compensate (canonical status=failure takes precedence).
assert counters["compensated"] == 1
assert len(calls) == 1
def test_reap_backward_compat_state_key_still_works(sr_module, monkeypatch):
"""Old-style per-entry objects using `state` key (pre-1.22.6 or
mixed-API responses) still compensate. Fallback `s.get("state")`
must not be broken by the `status`-key fix."""
calls = []
def fake_api(method, path, *, body=None, query=None, expect_json=True):
calls.append((method, path, body))
return (201, {})
monkeypatch.setattr(sr_module, "api", fake_api)
workflow_map = {"drift": False}
# Old shape: only `state` key per-entry (no `status`).
combined = {
"state": "failure",
"statuses": [
{
"context": "drift / drift (push)",
"state": "failure", # old key — must still work
"target_url": "https://x/y",
}
],
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
assert counters["compensated"] == 1
assert len(calls) == 1
def test_reap_ignores_canonical_status_non_failure(sr_module, monkeypatch):
"""`status: pending/success/error` per-entry must NOT be compensated.
Only `status: failure` (the Gitea 1.22.6 bug shape) triggers comp."""
monkeypatch.setattr(
sr_module, "api",
lambda *a, **kw: (_ for _ in ()).throw(
AssertionError("api should not be called for non-failure")
),
)
workflow_map = {"x": False}
combined = {
"state": "failure",
"statuses": [
{"context": "x / job (push)", "status": "pending"},
{"context": "x / job (push)", "status": "success"},
{"context": "x / job (push)", "status": "error"},
],
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
assert counters["compensated"] == 0
assert counters["preserved_non_failure"] == 3