Compare commits
2 Commits
c83f808a60
...
4acf50d9ef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4acf50d9ef | ||
|
|
b7a7e94df0 |
@@ -8,6 +8,7 @@ no status at all.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import importlib.util
|
import importlib.util
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -35,12 +36,23 @@ def _run(i, wf, job, status, sha=SHA, n=1):
|
|||||||
|
|
||||||
|
|
||||||
class Fake:
|
class Fake:
|
||||||
def __init__(self, runs=(), statuses=(), gh_prs=(), wg_prs=()):
|
def __init__(self, runs=(), statuses=(), gh_prs=(), wg_prs=(), workflows=None):
|
||||||
self.runs, self.statuses = list(runs), list(statuses)
|
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.gh_prs, self.wg_prs = list(gh_prs), list(wg_prs)
|
||||||
self.posted, self.opened, self.closed = [], [], []
|
self.posted, self.opened, self.closed = [], [], []
|
||||||
|
|
||||||
def gitea(self, method, path, body=None):
|
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:
|
if "/actions/tasks" in path:
|
||||||
page = int(path.rsplit("page=", 1)[1])
|
page = int(path.rsplit("page=", 1)[1])
|
||||||
return 200, {"workflow_runs": self.runs[(page - 1) * 50 : page * 50]}
|
return 200, {"workflow_runs": self.runs[(page - 1) * 50 : page * 50]}
|
||||||
@@ -209,3 +221,54 @@ def test_transport_blips_are_retried_but_http_errors_are_not(monkeypatch):
|
|||||||
monkeypatch.setattr(bridge.urllib.request, "urlopen", forbidden)
|
monkeypatch.setattr(bridge.urllib.request, "urlopen", forbidden)
|
||||||
assert bridge._call("http://x", "t", "GET", "/p") == (403, None)
|
assert bridge._call("http://x", "t", "GET", "/p") == (403, None)
|
||||||
assert calls["n"] == 1
|
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
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest>=8.3", "pytest-asyncio>=0.24", "ruff>=0.7", "mypy>=1.13"]
|
dev = ["pytest>=8.3", "pyyaml>=6.0", "pytest-asyncio>=0.24", "ruff>=0.7", "mypy>=1.13"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ repo code, and no secret is handed to any repo.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -36,6 +37,8 @@ import time
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
GITEA = os.environ.get("BRIDGE_GITEA_URL", "http://localhost:3080").rstrip("/")
|
GITEA = os.environ.get("BRIDGE_GITEA_URL", "http://localhost:3080").rstrip("/")
|
||||||
PUBLIC = "https://app.windygit.com"
|
PUBLIC = "https://app.windygit.com"
|
||||||
GITEA_TOKEN = os.environ.get("GITEA_ADMIN_TOKEN", "")
|
GITEA_TOKEN = os.environ.get("GITEA_ADMIN_TOKEN", "")
|
||||||
@@ -86,6 +89,62 @@ for _entry in os.environ.get(
|
|||||||
NON_BLOCKING[_repo.strip()] = {j.strip() for j in _jobs.split(",") if j.strip()}
|
NON_BLOCKING[_repo.strip()] = {j.strip() for j in _jobs.split(",") if j.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
# Gitea reads the FIRST of these dirs that has workflow files at a commit (1.24).
|
||||||
|
WORKFLOW_DIRS = (".gitea/workflows", ".github/workflows")
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_problem(text: str) -> str | None:
|
||||||
|
"""Why Gitea would drop this workflow file, or None if it looks runnable.
|
||||||
|
|
||||||
|
Gitea skips an invalid workflow with one log line and fires no run at all,
|
||||||
|
so on GitHub the PR just shows nothing, and people wait for CI that is never
|
||||||
|
coming. These are the shapes we have actually hit, not a full schema.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
doc = yaml.safe_load(text)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
mark = getattr(e, "problem_mark", None)
|
||||||
|
return f"invalid YAML at line {mark.line + 1}" if mark else "invalid YAML"
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
return "not a YAML mapping"
|
||||||
|
if "on" not in doc and True not in doc: # YAML 1.1 reads a bare `on` as True
|
||||||
|
return "no `on:` trigger"
|
||||||
|
jobs = doc.get("jobs")
|
||||||
|
if not isinstance(jobs, dict) or not jobs:
|
||||||
|
return "no `jobs:`"
|
||||||
|
for name, job in jobs.items():
|
||||||
|
if not isinstance(job, dict):
|
||||||
|
return f"job `{name}` is not a mapping"
|
||||||
|
if "runs-on" not in job and "uses" not in job:
|
||||||
|
return f"job `{name}` has no `runs-on:`"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def invalid_workflows(repo: str, sha: str) -> dict[str, tuple[str, str]]:
|
||||||
|
"""{context: (path, problem)} for each workflow file at `sha` that won't run."""
|
||||||
|
for d in WORKFLOW_DIRS:
|
||||||
|
st, entries = gitea("GET", f"/repos/{WG_OWNER}/{repo}/contents/{d}?ref={sha}")
|
||||||
|
if st == 404:
|
||||||
|
continue
|
||||||
|
if st != 200:
|
||||||
|
raise RuntimeError(f"{repo}: Windy Git {d}@{sha[:7]} -> {st}")
|
||||||
|
files = [e for e in entries or [] if e.get("type") == "file"
|
||||||
|
and e["name"].endswith((".yml", ".yaml"))]
|
||||||
|
if not files:
|
||||||
|
continue
|
||||||
|
bad = {}
|
||||||
|
for e in files:
|
||||||
|
st, f = gitea("GET", f"/repos/{WG_OWNER}/{repo}/contents/{e['path']}?ref={sha}")
|
||||||
|
if st != 200:
|
||||||
|
raise RuntimeError(f"{repo}: Windy Git {e['path']}@{sha[:7]} -> {st}")
|
||||||
|
problem = workflow_problem(base64.b64decode(f["content"]).decode("utf-8", "replace"))
|
||||||
|
if problem:
|
||||||
|
stem = re.sub(r"\.ya?ml$", "", e["name"])
|
||||||
|
bad[f"windy-git/{stem}/workflow"] = (e["path"], problem)
|
||||||
|
return bad
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _call(base: str, token_header: str, method: str, path: str, body=None):
|
def _call(base: str, token_header: str, method: str, path: str, body=None):
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
base + path,
|
base + path,
|
||||||
@@ -183,7 +242,8 @@ def post_statuses(repo: str, sha: str) -> None:
|
|||||||
ctx = f"windy-git/{r['workflow_id'].removesuffix('.yml')}/{r['name']}"
|
ctx = f"windy-git/{r['workflow_id'].removesuffix('.yml')}/{r['name']}"
|
||||||
if ctx not in latest or r["id"] > latest[ctx]["id"]:
|
if ctx not in latest or r["id"] > latest[ctx]["id"]:
|
||||||
latest[ctx] = r
|
latest[ctx] = r
|
||||||
if not latest:
|
bad = invalid_workflows(repo, sha)
|
||||||
|
if not (latest or bad):
|
||||||
return
|
return
|
||||||
|
|
||||||
st, existing = github("GET", f"/repos/{GH_OWNER}/{repo}/commits/{sha}/statuses?per_page=100")
|
st, existing = github("GET", f"/repos/{GH_OWNER}/{repo}/commits/{sha}/statuses?per_page=100")
|
||||||
@@ -191,6 +251,21 @@ def post_statuses(repo: str, sha: str) -> None:
|
|||||||
for s in existing or []: # newest first
|
for s in existing or []: # newest first
|
||||||
current.setdefault(s["context"], s["state"])
|
current.setdefault(s["context"], s["state"])
|
||||||
|
|
||||||
|
for ctx, (path, problem) in sorted(bad.items()):
|
||||||
|
if current.get(ctx) == "error":
|
||||||
|
continue
|
||||||
|
st, _ = github(
|
||||||
|
"POST",
|
||||||
|
f"/repos/{GH_OWNER}/{repo}/statuses/{sha}",
|
||||||
|
{
|
||||||
|
"state": "error",
|
||||||
|
"context": ctx,
|
||||||
|
"description": f"Windy Git ignored this workflow, no CI ran: {problem}"[:140],
|
||||||
|
"target_url": f"{PUBLIC}/{WG_OWNER}/{repo}/src/commit/{sha}/{path}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(f" {repo}@{sha[:7]} {ctx} = error ({problem}) -> {st}")
|
||||||
|
|
||||||
for ctx, r in sorted(latest.items()):
|
for ctx, r in sorted(latest.items()):
|
||||||
state = STATE.get(r["status"])
|
state = STATE.get(r["status"])
|
||||||
if state is None or current.get(ctx) == state:
|
if state is None or current.get(ctx) == state:
|
||||||
|
|||||||
Reference in New Issue
Block a user