874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
272 lines
13 KiB
Python
272 lines
13 KiB
Python
import os
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from tools.ci import nightly_set as ns
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
NOW = "2026-10-28T12:00:00+00:00"
|
|
PATTERNS = ns.guarded_patterns((ROOT / ".github/CODEOWNERS").read_text())
|
|
|
|
|
|
def pr(n, head="h", state="open", files=("crates/horizon-world/src/lib.rs",), base="main"):
|
|
return ns.PullRequest(n, state, f"{head}{n}", f"Fix {n}", f"dev{n}", base, list(files))
|
|
|
|
|
|
def lookup(*prs):
|
|
known = {p.number: p for p in prs}
|
|
return lambda n: known.get(n)
|
|
|
|
|
|
def run(state, text, login="kacpherek", *prs):
|
|
return ns.apply(state, ns.parse(text), login, NOW, lookup(*prs), PATTERNS)
|
|
|
|
|
|
class ParseTests(unittest.TestCase):
|
|
def test_commands(self):
|
|
self.assertEqual(ns.parse("/nightly 2640 #2642, 2640"), ns.Command("set", [2640, 2642]))
|
|
self.assertEqual(ns.parse("/nightly add 2650"), ns.Command("add", [2650]))
|
|
self.assertEqual(ns.parse("/Nightly REMOVE 2640"), ns.Command("remove", [2640]))
|
|
self.assertEqual(ns.parse("/nightly clear"), ns.Command("clear", []))
|
|
self.assertEqual(ns.parse("/nightly status"), ns.Command("status", []))
|
|
self.assertEqual(ns.parse("/nightly"), ns.Command("status", []))
|
|
self.assertEqual(ns.parse("/nightly dry-run 1 2"), ns.Command("dry-run", [1, 2]))
|
|
self.assertEqual(ns.parse("\n /nightly 7\nthanks!"), ns.Command("set", [7]))
|
|
|
|
def test_other_comments_are_not_commands(self):
|
|
for body in ("", "LGTM", "please /nightly 2640", "/nightlyx 1", "> /nightly 1"):
|
|
self.assertIsNone(ns.parse(body), body)
|
|
|
|
def test_wrong_commands_are_answered(self):
|
|
for body in ("/nightly please", "/nightly add", "/nightly clear 5", "/nightly 1 `rm -rf`",
|
|
"/nightly " + " ".join(str(n) for n in range(1, 32))):
|
|
with self.assertRaises(ns.Refused, msg=body):
|
|
ns.parse(body)
|
|
|
|
def test_answers_never_quote_markdown_or_mentions(self):
|
|
with self.assertRaises(ns.Refused) as e:
|
|
ns.parse("/nightly @ludovic111`x`")
|
|
self.assertNotIn("@", str(e.exception).split("isn't")[0])
|
|
self.assertNotIn("`x`", str(e.exception))
|
|
|
|
|
|
class AllowTests(unittest.TestCase):
|
|
LIST = "# trusted\nkacpherek # added by Ludovic\nrdbtCVS\n\nnot a login!\n"
|
|
|
|
def test_listed_reviewers_and_ludovic(self):
|
|
reviewers = ns.read_reviewers(self.LIST)
|
|
ns.authorize("kacpherek", reviewers)
|
|
ns.authorize("RDBTcvs", reviewers)
|
|
ns.authorize("ludovic111", reviewers)
|
|
for login in ("levzzz5154", "someone", "not"):
|
|
with self.assertRaises(ns.Refused):
|
|
ns.authorize(login, reviewers)
|
|
|
|
def test_paused_stops_reviewers_not_ludovic(self):
|
|
reviewers = ns.read_reviewers(self.LIST + "paused # Ludovic, 2026-10-29\n")
|
|
self.assertTrue(reviewers.paused)
|
|
with self.assertRaises(ns.Refused):
|
|
ns.authorize("kacpherek", reviewers)
|
|
ns.authorize("ludovic111", reviewers)
|
|
|
|
def test_the_repos_list_names_the_three_reviewers(self):
|
|
reviewers = ns.read_reviewers((ROOT / ns.REVIEWERS).read_text())
|
|
self.assertEqual(reviewers.logins, {"kacpherek", "rdbtcvs", "online5277", "levzzz5154"})
|
|
self.assertFalse(reviewers.paused)
|
|
|
|
class RefusalTests(unittest.TestCase):
|
|
def test_what_codeowners_guards_is_refused(self):
|
|
for path in (".github/workflows/x.yml", "tools/ci/nightly.py", "crates/horizon-release-sign/src/lib.rs",
|
|
"crates/horizon-server/src/update.rs", "launcher/src/backend.rs", "launcher/links.json",
|
|
"launcher/src/signed.rs", "tools/release_train.py"):
|
|
self.assertTrue(ns.guarded(path, PATTERNS), path)
|
|
for path in ("crates/horizon-client/src/game.rs", "launcher/src/main.rs", "tools/ci.py", "docs/x.md",
|
|
"crates/horizon-server/src/main.rs"):
|
|
self.assertFalse(ns.guarded(path, PATTERNS), path)
|
|
self.assertTrue(ns.guarded("tools/ci/x.py", ns.guarded_patterns(""))) # even without CODEOWNERS
|
|
|
|
def test_a_set_with_a_refused_pull_request_changes_nothing(self):
|
|
state = {"prs": []}
|
|
bad = [pr(2, files=["tools/ci/nightly.py"]), pr(3, state="closed"), pr(4, state="merged"), pr(5, base="x")]
|
|
for p in bad:
|
|
with self.assertRaises(ns.Refused):
|
|
run(state, f"/nightly 1 {p.number}", "kacpherek", pr(1), p)
|
|
with self.assertRaises(ns.Refused):
|
|
run(state, "/nightly 1 9", "kacpherek", pr(1)) # 9 doesn't exist
|
|
|
|
def test_at_most_thirty(self):
|
|
state = {"prs": [ns.entry(pr(n), "a", NOW) for n in range(1, 31)]}
|
|
with self.assertRaises(ns.Refused):
|
|
run(state, "/nightly add 31", "kacpherek", pr(31))
|
|
|
|
|
|
class SetTests(unittest.TestCase):
|
|
def test_set_add_remove_clear(self):
|
|
r = run({}, "/nightly 1 2", "kacpherek", pr(1), pr(2))
|
|
self.assertTrue(r.build)
|
|
self.assertEqual([e["number"] for e in r.state["prs"]], [1, 2])
|
|
self.assertEqual(r.state["prs"][0]["asked_by"], "kacpherek")
|
|
# add takes the newest head again and keeps its place
|
|
r = run(r.state, "/nightly add 1 3", "rdbtCVS", pr(1, head="new"), pr(3))
|
|
self.assertEqual([(e["number"], e["head"]) for e in r.state["prs"]], [(1, "new1"), (2, "h2"), (3, "h3")])
|
|
r = run(r.state, "/nightly remove 2", "rdbtCVS")
|
|
self.assertEqual([e["number"] for e in r.state["prs"]], [1, 3])
|
|
r = run(r.state, "/nightly clear", "kacpherek")
|
|
self.assertEqual(r.state["prs"], [])
|
|
self.assertTrue(r.build)
|
|
|
|
def test_status_and_dry_run_change_nothing(self):
|
|
state = run({}, "/nightly 1", "kacpherek", pr(1)).state
|
|
status = run(state, "/nightly status", "levzzz5154")
|
|
self.assertIs(status.state, state)
|
|
self.assertFalse(status.build)
|
|
self.assertIn("#1 Fix 1", status.answer)
|
|
dry = run(state, "/nightly dry-run 2 3", "kacpherek", pr(2), pr(3))
|
|
self.assertIs(dry.state, state)
|
|
self.assertEqual([e["number"] for e in dry.dry_run], [2, 3])
|
|
|
|
def test_ludovics_remove_wins(self):
|
|
state = run({}, "/nightly 1 2", "kacpherek", pr(1), pr(2)).state
|
|
state = run(state, "/nightly remove 2", "ludovic111").state
|
|
self.assertEqual(state["blocked"], [2])
|
|
for text in ("/nightly add 2", "/nightly 1 2", "/nightly dry-run 2"):
|
|
with self.assertRaises(ns.Refused):
|
|
run(state, text, "kacpherek", pr(1), pr(2))
|
|
state = run(state, "/nightly add 2", "ludovic111", pr(2)).state
|
|
self.assertEqual(state["blocked"], [])
|
|
self.assertEqual([e["number"] for e in run(state, "/nightly add 2", "kacpherek", pr(2)).state["prs"]], [1, 2])
|
|
|
|
def test_titles_never_mention_anyone(self):
|
|
p = pr(1)
|
|
p.title = "Fix @everyone\nsecond line"
|
|
answer = run({}, "/nightly 1", "kacpherek", p).answer
|
|
self.assertNotIn("@everyone", answer)
|
|
self.assertNotIn("second line\n", answer)
|
|
|
|
def test_builds_are_numbered_per_day(self):
|
|
self.assertEqual(ns.next_build("2026-10-28", []), "nightly/build-2026-10-28-1")
|
|
self.assertEqual(ns.next_build("2026-10-28", ["nightly/build-2026-10-28-1", "nightly/build-2026-10-28-4"]),
|
|
"nightly/build-2026-10-28-5")
|
|
|
|
|
|
def sh(repo, *args, env=None):
|
|
return subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True,
|
|
env={**os.environ, **(env or {})}).stdout.strip()
|
|
|
|
|
|
class AssemblyTests(unittest.TestCase):
|
|
"""A fixture repository: main and three pull requests, two of them touching the same line."""
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.repo = self.tmp.name
|
|
ident = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t",
|
|
"GIT_COMMITTER_EMAIL": "t@t", "GIT_AUTHOR_DATE": "2026-10-28T10:00:00Z",
|
|
"GIT_COMMITTER_DATE": "2026-10-28T10:00:00Z"}
|
|
self.env = ident
|
|
sh(self.repo, "init", "-q", "-b", "main")
|
|
self.write({"a.txt": "a\n", "b.txt": "b\n", "c.txt": "c\n"})
|
|
self.main = self.commit("main")
|
|
self.p1 = self.branch("p1", {"a.txt": "a from p1\n"})
|
|
self.p2 = self.branch("p2", {"b.txt": "b from p2\n", ".gitattributes": "* filter=evil\n"})
|
|
self.p3 = self.branch("p3", {"a.txt": "a from p3\n"})
|
|
sh(self.repo, "checkout", "-q", "main")
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def write(self, files):
|
|
for name, text in files.items():
|
|
Path(self.repo, name).write_text(text)
|
|
|
|
def commit(self, message):
|
|
sh(self.repo, "add", "-A")
|
|
sh(self.repo, "commit", "-q", "-m", message, env=self.env)
|
|
return sh(self.repo, "rev-parse", "HEAD")
|
|
|
|
def branch(self, name, files):
|
|
sh(self.repo, "checkout", "-q", "-b", name, self.main)
|
|
self.write(files)
|
|
return self.commit(name)
|
|
|
|
def test_clean_merges_in_order_and_the_same_inputs_give_the_same_commit(self):
|
|
built = ns.assemble(self.main, [(1, self.p1), (2, self.p2)], cwd=self.repo)
|
|
self.assertEqual(sh(self.repo, "show", f"{built}:a.txt"), "a from p1")
|
|
self.assertEqual(sh(self.repo, "show", f"{built}:b.txt"), "b from p2")
|
|
self.assertEqual(sh(self.repo, "rev-list", "--count", f"{self.main}..{built}", "--first-parent"), "2")
|
|
self.assertEqual(ns.assemble(self.main, [(1, self.p1), (2, self.p2)], cwd=self.repo), built)
|
|
# nothing was checked out: the working tree is still main's
|
|
self.assertEqual(Path(self.repo, "a.txt").read_text(), "a\n")
|
|
self.assertFalse(Path(self.repo, ".gitattributes").exists())
|
|
|
|
def test_two_pull_requests_that_conflict_are_named(self):
|
|
with self.assertRaises(ns.Conflict) as e:
|
|
ns.assemble(self.main, [(1, self.p1), (2, self.p2), (3, self.p3)], cwd=self.repo)
|
|
self.assertEqual((e.exception.number, e.exception.other, e.exception.files), (3, 1, ["a.txt"]))
|
|
self.assertIn("#1 and #3 conflict in `a.txt`", e.exception.text())
|
|
|
|
def test_a_conflict_with_main_says_so(self):
|
|
sh(self.repo, "checkout", "-q", "main")
|
|
self.write({"a.txt": "a from main\n"})
|
|
main = self.commit("main moves")
|
|
with self.assertRaises(ns.Conflict) as e:
|
|
ns.assemble(main, [(1, self.p1)], cwd=self.repo)
|
|
self.assertIsNone(e.exception.other)
|
|
self.assertIn("conflicts with main", e.exception.text())
|
|
|
|
def test_changed_files_finds_guarded_paths(self):
|
|
self.assertEqual(sorted(ns.changed_files(self.main, self.p2, cwd=self.repo)), [".gitattributes", "b.txt"])
|
|
|
|
|
|
class StateTests(unittest.TestCase):
|
|
"""The nightly-state branch on a local bare repository standing in for origin."""
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.origin, self.repo = Path(self.tmp.name, "origin.git"), Path(self.tmp.name, "work")
|
|
subprocess.run(["git", "init", "-q", "--bare", str(self.origin)], check=True)
|
|
subprocess.run(["git", "init", "-q", str(self.repo)], check=True)
|
|
sh(self.repo, "remote", "add", "origin", str(self.origin))
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def test_first_save_then_load(self):
|
|
self.assertEqual(ns.load_state(str(self.repo)), ({}, "", None))
|
|
ns.update_state(lambda st, b: ({"prs": [1]}, b + "{}\n", "first"), "t", cwd=str(self.repo))
|
|
state, builds, head = ns.load_state(str(self.repo))
|
|
self.assertEqual((state, builds), ({"prs": [1]}, "{}\n"))
|
|
self.assertIn("first", sh(self.repo, "log", "-1", "--format=%s", head))
|
|
|
|
def test_a_run_that_pushed_first_is_kept(self):
|
|
ns.update_state(lambda st, b: ({"prs": [1]}, b, "one"), "t", cwd=str(self.repo))
|
|
other = Path(self.tmp.name, "other")
|
|
subprocess.run(["git", "init", "-q", str(other)], check=True)
|
|
sh(other, "remote", "add", "origin", str(self.origin))
|
|
seen = []
|
|
|
|
def add_two(st, b):
|
|
seen.append(list(st.get("prs", [])))
|
|
if len(seen) == 1: # meanwhile another run saves first
|
|
ns.update_state(lambda s2, b2: ({"prs": s2["prs"] + [3]}, b2, "other"), "t", cwd=str(other))
|
|
return {"prs": st["prs"] + [2]}, b, "two"
|
|
final = ns.update_state(add_two, "t", cwd=str(self.repo))
|
|
self.assertEqual(seen, [[1], [1, 3]])
|
|
self.assertEqual(final["prs"], [1, 3, 2])
|
|
self.assertEqual(ns.load_state(str(self.repo))[0]["prs"], [1, 3, 2])
|
|
|
|
def test_builds_today_lists_the_days_branches(self):
|
|
sh(self.repo, "commit", "-q", "--allow-empty", "-m", "x", env={
|
|
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"})
|
|
head = sh(self.repo, "rev-parse", "HEAD")
|
|
for name in ("nightly/build-2026-10-28-1", "nightly/build-2026-10-28-2", "nightly/build-2026-10-27-1"):
|
|
self.assertTrue(ns.push(head, name, "t", cwd=str(self.repo)))
|
|
self.assertEqual(sorted(ns.builds_today("2026-10-28", cwd=str(self.repo))),
|
|
["nightly/build-2026-10-28-1", "nightly/build-2026-10-28-2"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|