All checks were successful
check / gate (push) Successful in 28s
Gitea 1.24 lists only picked-up jobs, so a queued PR showed NOTHING on GitHub and lanes asked whether their push was lost (Windy Mind #131, Windy Cloud today). The bridge now reads waiting jobs from the gitea DB and posts pending where nothing newer was picked up; a queued re-run supersedes the stale failure it replaces. Only status 5 jobs whose runs-on labels a live runner has: blocked jobs often end skipped and label-unrunnable jobs are cancelled unpicked, and neither ever reaches /actions/tasks, so their pending would never resolve. Lookup is bounded (30 s) and non-fatal: the IO-stall lesson. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
343 lines
12 KiB
Python
343 lines
12 KiB
Python
"""Behavioral tests for scripts/pr_status_bridge.py.
|
|
|
|
The bridge is the ONLY CI signal the private platform repos get on GitHub, so
|
|
these drive its real functions against fake Gitea/GitHub APIs rather than
|
|
grepping its source: a status painted green that nobody tested is worse than
|
|
no status at all.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import importlib.util
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
_spec = importlib.util.spec_from_file_location(
|
|
"pr_status_bridge", ROOT / "scripts" / "pr_status_bridge.py"
|
|
)
|
|
bridge = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(bridge)
|
|
|
|
SHA = "a" * 40
|
|
|
|
|
|
def _run(i, wf, job, status, sha=SHA, n=1):
|
|
return {
|
|
"id": i,
|
|
"workflow_id": wf,
|
|
"name": job,
|
|
"status": status,
|
|
"head_sha": sha,
|
|
"run_number": n,
|
|
}
|
|
|
|
|
|
class Fake:
|
|
def __init__(self, runs=(), statuses=(), gh_prs=(), wg_prs=(), workflows=None):
|
|
self.runs, self.statuses = list(runs), list(statuses)
|
|
self.workflows = workflows or {} # {path: yaml text} at every commit
|
|
self.gh_prs, self.wg_prs = list(gh_prs), list(wg_prs)
|
|
self.posted, self.opened, self.closed = [], [], []
|
|
|
|
def gitea(self, method, path, body=None):
|
|
if "/contents/" in path:
|
|
want = path.split("/contents/", 1)[1].split("?", 1)[0]
|
|
if want in self.workflows:
|
|
return 200, {"content": base64.b64encode(self.workflows[want].encode()).decode()}
|
|
files = [
|
|
{"type": "file", "name": k.rsplit("/", 1)[1], "path": k}
|
|
for k in self.workflows
|
|
if k.rsplit("/", 1)[0] == want
|
|
]
|
|
return (200, files) if files else (404, None)
|
|
if "/actions/tasks" in path:
|
|
page = int(path.rsplit("page=", 1)[1])
|
|
return 200, {"workflow_runs": self.runs[(page - 1) * 50 : page * 50]}
|
|
if method == "GET" and path.endswith("/pulls?state=open&limit=50"):
|
|
return 200, self.wg_prs
|
|
if method == "POST" and path.endswith("/pulls"):
|
|
self.opened.append(body)
|
|
return 201, {}
|
|
if method == "PATCH":
|
|
self.closed.append(path)
|
|
return 201, {}
|
|
raise AssertionError(path)
|
|
|
|
def github(self, method, path, body=None):
|
|
if "/statuses" in path and method == "GET":
|
|
return 200, self.statuses
|
|
if "/statuses/" in path and method == "POST":
|
|
self.posted.append(body)
|
|
return 201, {}
|
|
if "/pulls?" in path:
|
|
return 200, self.gh_prs
|
|
raise AssertionError(path)
|
|
|
|
|
|
@pytest.fixture
|
|
def fake(monkeypatch):
|
|
def make(queued=(), **kw):
|
|
f = Fake(**kw)
|
|
monkeypatch.setattr(bridge, "gitea", f.gitea)
|
|
monkeypatch.setattr(bridge, "github", f.github)
|
|
monkeypatch.setattr(bridge, "queued_jobs", lambda repo, sha: list(queued))
|
|
return f
|
|
|
|
return make
|
|
|
|
|
|
def test_posts_latest_verdict_per_job(fake):
|
|
f = fake(runs=[_run(1, "ci.yml", "test", "failure"), _run(2, "ci.yml", "test", "success", n=2)])
|
|
bridge.post_statuses("r", SHA)
|
|
assert [(p["context"], p["state"]) for p in f.posted] == [("windy-git/ci/test", "success")]
|
|
assert f.posted[0]["target_url"].endswith("/actions/runs/2")
|
|
|
|
|
|
def test_unchanged_state_is_not_reposted(fake):
|
|
f = fake(
|
|
runs=[_run(1, "ci.yml", "test", "success")],
|
|
statuses=[{"context": "windy-git/ci/test", "state": "success"}],
|
|
)
|
|
bridge.post_statuses("r", SHA)
|
|
assert f.posted == []
|
|
|
|
|
|
def test_skipped_job_is_never_painted_green(fake):
|
|
f = fake(runs=[_run(1, "substrate-drift.yml", "check", "skipped")])
|
|
bridge.post_statuses("r", SHA)
|
|
assert f.posted == []
|
|
|
|
|
|
def test_other_commits_runs_are_ignored(fake):
|
|
f = fake(runs=[_run(1, "ci.yml", "test", "failure", sha="b" * 40)])
|
|
bridge.post_statuses("r", SHA)
|
|
assert f.posted == []
|
|
|
|
|
|
def test_runs_past_the_first_page_are_seen(fake):
|
|
noise = [_run(100 + i, "drift.yml", "x", "skipped", sha="c" * 40) for i in range(50)]
|
|
f = fake(runs=noise + [_run(1, "ci.yml", "test", "success")])
|
|
bridge.post_statuses("r", SHA)
|
|
assert [p["state"] for p in f.posted] == ["success"]
|
|
|
|
|
|
def _gh_pr(n, repo="sneakyfree/r"):
|
|
return {
|
|
"number": n,
|
|
"title": "t",
|
|
"html_url": "u",
|
|
"head": {"ref": f"b{n}", "sha": SHA, "repo": {"full_name": repo} if repo else None},
|
|
"base": {"ref": "main"},
|
|
}
|
|
|
|
|
|
def test_fork_prs_are_never_mirrored(fake, monkeypatch):
|
|
monkeypatch.setattr(bridge, "GH_OWNER", "sneakyfree")
|
|
f = fake(gh_prs=[_gh_pr(1, repo="stranger/r"), _gh_pr(2, repo=None)])
|
|
assert bridge.sync_prs("r") == []
|
|
assert f.opened == []
|
|
|
|
|
|
def test_pr_mirror_opened_once_and_closed_when_github_closes(fake, monkeypatch):
|
|
monkeypatch.setattr(bridge, "GH_OWNER", "sneakyfree")
|
|
f = fake(gh_prs=[_gh_pr(7)], wg_prs=[{"number": 3, "title": "[GH#5] gone"}])
|
|
assert bridge.sync_prs("r") == [SHA]
|
|
assert [o["head"] for o in f.opened] == ["b7"]
|
|
assert f.closed == ["/repos/windyadmin/r/pulls/3"]
|
|
|
|
f2 = fake(gh_prs=[_gh_pr(7)], wg_prs=[{"number": 4, "title": "[GH#7] t"}])
|
|
bridge.sync_prs("r")
|
|
assert f2.opened == [] and f2.closed == []
|
|
|
|
|
|
def test_image_build_jobs_are_not_posted(fake):
|
|
"""No Docker daemon in job containers (I-5): a build job's red is structural."""
|
|
f = fake(
|
|
runs=[_run(1, "ci.yml", "Docker Build", "failure"), _run(2, "ci.yml", "docker", "failure")]
|
|
)
|
|
bridge.post_statuses("r", SHA)
|
|
assert f.posted == []
|
|
|
|
|
|
def test_non_blocking_jobs_are_not_posted_for_that_repo_only(fake, monkeypatch):
|
|
"""Grant ruled windy-pro's desktop/installer jobs non-blocking: they must not
|
|
reach GitHub for windy-pro, and the rule must not leak to other repos."""
|
|
monkeypatch.setattr(bridge, "NON_BLOCKING", {"windy-pro": {"ci/build-desktop"}})
|
|
runs = [_run(1, "ci.yml", "build-desktop", "failure"), _run(2, "ci.yml", "test", "success")]
|
|
f = fake(runs=runs)
|
|
bridge.post_statuses("windy-pro", SHA)
|
|
assert [p["context"] for p in f.posted] == ["windy-git/ci/test"]
|
|
f2 = fake(runs=runs)
|
|
bridge.post_statuses("windy-chat", SHA)
|
|
assert sorted(p["context"] for p in f2.posted) == [
|
|
"windy-git/ci/build-desktop",
|
|
"windy-git/ci/test",
|
|
]
|
|
|
|
|
|
def test_default_non_blocking_is_grants_ruling():
|
|
assert bridge.NON_BLOCKING.get("windy-pro") == {
|
|
"ci/build-desktop",
|
|
"ci/test-installer",
|
|
"ci/reality-check",
|
|
}
|
|
|
|
|
|
def test_transport_blips_are_retried_but_http_errors_are_not(monkeypatch):
|
|
import urllib.error
|
|
|
|
calls = {"n": 0}
|
|
|
|
class _R:
|
|
status = 200
|
|
|
|
def read(self):
|
|
return b"{}"
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def flaky(req, timeout):
|
|
calls["n"] += 1
|
|
if calls["n"] < 3:
|
|
raise urllib.error.URLError("_ssl.c:983: The handshake operation timed out")
|
|
return _R()
|
|
|
|
monkeypatch.setattr(bridge.urllib.request, "urlopen", flaky)
|
|
monkeypatch.setattr(bridge.time, "sleep", lambda s: None)
|
|
assert bridge._call("http://x", "t", "GET", "/p") == (200, {})
|
|
assert calls["n"] == 3
|
|
|
|
def forbidden(req, timeout):
|
|
calls["n"] += 1
|
|
raise urllib.error.HTTPError("http://x/p", 403, "no", {}, None)
|
|
|
|
calls["n"] = 0
|
|
monkeypatch.setattr(bridge.urllib.request, "urlopen", forbidden)
|
|
assert bridge._call("http://x", "t", "GET", "/p") == (403, None)
|
|
assert calls["n"] == 1
|
|
|
|
|
|
GOOD = "on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps: []\n"
|
|
BROKEN = "on: push\njobs:\n test:\n runs-on: x\n steps: [\n"
|
|
|
|
|
|
def test_invalid_workflow_gets_an_error_status_even_with_no_runs(fake):
|
|
# Gitea fires NO run for an invalid file: without this the PR shows nothing.
|
|
f = fake(workflows={".github/workflows/ci.yml": BROKEN})
|
|
bridge.post_statuses("windy-chat", SHA)
|
|
assert [(p["context"], p["state"]) for p in f.posted] == [("windy-git/ci/workflow", "error")]
|
|
assert "invalid YAML at line 5" in f.posted[0]["description"]
|
|
assert f.posted[0]["target_url"].endswith(f"/src/commit/{SHA}/.github/workflows/ci.yml")
|
|
|
|
|
|
def test_valid_workflows_post_nothing_extra(fake):
|
|
f = fake(runs=[_run(1, "ci.yml", "test", "success")], workflows={".github/workflows/ci.yml": GOOD})
|
|
bridge.post_statuses("windy-chat", SHA)
|
|
assert [p["context"] for p in f.posted] == ["windy-git/ci/test"]
|
|
|
|
|
|
def test_workflow_error_is_not_reposted(fake):
|
|
f = fake(
|
|
workflows={".github/workflows/ci.yml": BROKEN},
|
|
statuses=[{"context": "windy-git/ci/workflow", "state": "error"}],
|
|
)
|
|
bridge.post_statuses("windy-chat", SHA)
|
|
assert f.posted == []
|
|
|
|
|
|
def test_gitea_dir_wins_over_github_dir(fake):
|
|
# Gitea runs .gitea/workflows when it has files and ignores .github/workflows.
|
|
f = fake(workflows={".gitea/workflows/ci.yml": GOOD, ".github/workflows/old.yml": BROKEN})
|
|
bridge.post_statuses("windy-chat", SHA)
|
|
assert f.posted == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"text, problem",
|
|
[
|
|
(GOOD, None),
|
|
("on: push\njobs:\n a:\n uses: ./x.yml\n", None),
|
|
(BROKEN, "invalid YAML at line 5"),
|
|
("jobs:\n a:\n runs-on: x\n", "no `on:` trigger"),
|
|
("on: push\n", "no `jobs:`"),
|
|
("on: push\njobs:\n a:\n steps: []\n", "job `a` has no `runs-on:`"),
|
|
("- a\n", "not a YAML mapping"),
|
|
],
|
|
)
|
|
def test_workflow_problem(text, problem):
|
|
assert bridge.workflow_problem(text) == problem
|
|
|
|
|
|
def _q(n, wf, job):
|
|
return {"run_number": n, "workflow_id": wf, "name": job}
|
|
|
|
|
|
def test_queued_job_shows_pending_instead_of_nothing(fake):
|
|
f = fake(queued=[_q(5, "ci.yml", "test")])
|
|
bridge.post_statuses("windy-chat", SHA)
|
|
assert [(p["context"], p["state"]) for p in f.posted] == [("windy-git/ci/test", "pending")]
|
|
assert f.posted[0]["target_url"].endswith("/actions/runs/5")
|
|
|
|
|
|
def test_queued_rerun_supersedes_the_stale_failure(fake):
|
|
f = fake(runs=[_run(1, "ci.yml", "test", "failure", n=4)], queued=[_q(7, "ci.yml", "test")])
|
|
bridge.post_statuses("windy-chat", SHA)
|
|
assert [(p["context"], p["state"]) for p in f.posted] == [("windy-git/ci/test", "pending")]
|
|
|
|
|
|
def test_older_queued_job_never_overrides_a_newer_verdict(fake):
|
|
f = fake(runs=[_run(1, "ci.yml", "test", "success", n=9)], queued=[_q(3, "ci.yml", "test")])
|
|
bridge.post_statuses("windy-chat", SHA)
|
|
assert [(p["context"], p["state"]) for p in f.posted] == [("windy-git/ci/test", "success")]
|
|
|
|
|
|
def test_queued_docker_and_non_blocking_jobs_stay_unposted(fake):
|
|
f = fake(queued=[_q(2, "ci.yml", "docker-build"), _q(2, "ci.yml", "build-desktop")])
|
|
bridge.post_statuses("windy-pro", SHA)
|
|
assert f.posted == []
|
|
|
|
|
|
def test_queued_lookup_refuses_unsafe_input():
|
|
assert bridge.queued_jobs("x'; drop table t;--", SHA) == []
|
|
assert bridge.queued_jobs("windy-chat", "not-a-sha") == []
|
|
|
|
|
|
def _db(monkeypatch, jobs, labels):
|
|
import json as _json
|
|
import subprocess as _sp
|
|
|
|
payload = _json.dumps({"jobs": jobs, "labels": [_json.dumps(x) for x in labels]})
|
|
monkeypatch.setattr(
|
|
bridge.subprocess, "run",
|
|
lambda *a, **k: _sp.CompletedProcess(a, 0, stdout=payload, stderr=""),
|
|
)
|
|
|
|
|
|
RUNNER = ["veron-1", "linux-x64", "self-hosted", "linux", "x64"]
|
|
|
|
|
|
def test_only_jobs_a_runner_can_take_are_pending(monkeypatch):
|
|
# macos-latest is cancelled unpicked by the janitor: pending would never resolve.
|
|
_db(monkeypatch, [
|
|
{"run_number": 3, "workflow_id": "ci.yml", "name": "test", "runs_on": '["self-hosted","linux","x64"]'},
|
|
{"run_number": 3, "workflow_id": "ci.yml", "name": "mac", "runs_on": '["macos-latest"]'},
|
|
], [RUNNER])
|
|
assert [j["name"] for j in bridge.queued_jobs("windy-chat", SHA)] == ["test"]
|
|
|
|
|
|
def test_lookup_failure_is_non_fatal(monkeypatch):
|
|
import subprocess as _sp
|
|
|
|
def boom(*a, **k):
|
|
raise _sp.TimeoutExpired("docker", 30)
|
|
|
|
monkeypatch.setattr(bridge.subprocess, "run", boom)
|
|
assert bridge.queued_jobs("windy-chat", SHA) == []
|