874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
240 lines
9.8 KiB
Python
240 lines
9.8 KiB
Python
import unittest
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from tools.ci import nightly_set as ns
|
|
from tools.ci import triage_command as tc
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
NOW = datetime(2026, 10, 28, 12, tzinfo=timezone.utc)
|
|
URL = "https://github.com/horizon-rust/horizon/issues/5#issuecomment-1"
|
|
LIST = ns.read_reviewers("kacpherek\nrdbtCVS\n")
|
|
|
|
|
|
def thread(state="open", user="dev", labels=("bug",), pr=False, merged=False, locked=False):
|
|
t = {"state": state, "user": {"login": user}, "labels": [{"name": n} for n in labels], "locked": locked}
|
|
if pr:
|
|
t["pull_request"] = {"merged_at": "2026-10-27T00:00:00Z" if merged else None}
|
|
return t
|
|
|
|
|
|
class Fake:
|
|
def __init__(self, threads=None, labels=("bug", "good first issue", "release-now", "skip-release"), past=()):
|
|
self.threads = {5: thread()} if threads is None else threads
|
|
self.labels, self.past, self.calls = list(labels), list(past), []
|
|
|
|
def issue(self, n):
|
|
return self.threads.get(n)
|
|
|
|
def label(self, name):
|
|
return next((l for l in self.labels if l.lower() == name.lower()), None)
|
|
|
|
def add_labels(self, n, names):
|
|
self.calls.append(("add", n, names))
|
|
|
|
def remove_label(self, n, name):
|
|
self.calls.append(("remove", n, name))
|
|
|
|
def close(self, n, reason):
|
|
self.calls.append(("close", n, reason))
|
|
|
|
def reopen(self, n):
|
|
self.calls.append(("reopen", n))
|
|
|
|
def comment(self, n, text):
|
|
self.calls.append(("comment", n, text))
|
|
|
|
def recent_comments(self, since):
|
|
return self.past
|
|
|
|
|
|
def do(text, api=None, login="kacpherek", reviewers=LIST, n=5):
|
|
api = api or Fake()
|
|
return tc.run(api, tc.parse(text), login, n, URL, reviewers, NOW), api
|
|
|
|
|
|
class ParseTests(unittest.TestCase):
|
|
def test_commands_and_case(self):
|
|
self.assertEqual(tc.parse("/close"), tc.Command("close", "completed"))
|
|
self.assertEqual(tc.parse(" /CLOSE Not Planned "), tc.Command("close", "not_planned"))
|
|
self.assertEqual(tc.parse("/close completed"), tc.Command("close", "completed"))
|
|
self.assertEqual(tc.parse("/close duplicate of #123"), tc.Command("close", "duplicate", 123))
|
|
self.assertEqual(tc.parse("/Close duplicate of 7"), tc.Command("close", "duplicate", 7))
|
|
self.assertEqual(tc.parse("/reopen"), tc.Command("reopen"))
|
|
self.assertEqual(tc.parse("/label bug, good first issue ,bug"), tc.Command("label", labels=("bug", "good first issue")))
|
|
self.assertEqual(tc.parse("/unlabel bug"), tc.Command("unlabel", labels=("bug",)))
|
|
|
|
def test_first_line_only(self):
|
|
self.assertEqual(tc.parse("/close\n/label release-now"), tc.Command("close", "completed"))
|
|
self.assertIsNone(tc.parse("thanks\n/close"))
|
|
self.assertEqual(tc.parse("\n\n/reopen\nwhy"), tc.Command("reopen"))
|
|
|
|
def test_not_commands(self):
|
|
for text in ("", "hello", "/nightly 5", "/closed", "/labels bug", "/closer", "close", "/unlabeled x"):
|
|
self.assertIsNone(tc.parse(text), text)
|
|
|
|
def test_malformed(self):
|
|
for text in ("/close maybe", "/close duplicate of", "/reopen now", "/label", "/label ,", "/unlabel a, b",
|
|
"/label " + ",".join(str(i) for i in range(11))):
|
|
with self.assertRaises(tc.Refused, msg=text):
|
|
tc.parse(text)
|
|
|
|
def test_quoting_is_safe(self):
|
|
with self.assertRaises(tc.Refused) as e:
|
|
tc.parse("/close @ludovic111 [x](http://evil)")
|
|
self.assertNotIn("@", str(e.exception))
|
|
self.assertNotIn("](", str(e.exception))
|
|
|
|
|
|
class AllowTests(unittest.TestCase):
|
|
def test_listed_and_ludovic(self):
|
|
do("/close", login="RDBTcvs")
|
|
do("/close", login="ludovic111")
|
|
|
|
def test_stranger_refused(self):
|
|
with self.assertRaisesRegex(tc.Refused, "Only Ludovic"):
|
|
do("/close", login="someone")
|
|
|
|
def test_paused(self):
|
|
paused = ns.read_reviewers("kacpherek\npaused\n")
|
|
with self.assertRaisesRegex(tc.Refused, "paused"):
|
|
do("/close", reviewers=paused)
|
|
do("/close", reviewers=paused, login="ludovic111")
|
|
|
|
def test_the_repos_list_is_the_nightlys(self):
|
|
self.assertEqual(ns.REVIEWERS, ".github/trusted-reviewers.txt")
|
|
listed = ns.read_reviewers((ROOT / ns.REVIEWERS).read_text())
|
|
self.assertEqual(listed.logins, {"kacpherek", "rdbtcvs", "online5277", "levzzz5154"})
|
|
self.assertFalse(listed.paused)
|
|
|
|
|
|
class ActionTests(unittest.TestCase):
|
|
def test_close_issue(self):
|
|
text, api = do("/close not planned")
|
|
self.assertEqual(api.calls, [("close", 5, "not_planned")])
|
|
self.assertIn("on behalf of @kacpherek", text)
|
|
self.assertIn(URL, text)
|
|
self.assertTrue(text.startswith("🤖 Triage: "))
|
|
|
|
def test_close_pr_has_no_reason(self):
|
|
api = Fake({5: thread(pr=True)})
|
|
text, _ = do("/close", api)
|
|
self.assertEqual(api.calls, [("close", 5, None)])
|
|
self.assertIn("without merging", text)
|
|
|
|
def test_duplicate(self):
|
|
api = Fake({5: thread(), 9: thread()})
|
|
text, _ = do("/close duplicate of #9", api)
|
|
self.assertEqual(api.calls, [("comment", 5, "Duplicate of #9"), ("close", 5, "duplicate")])
|
|
self.assertIn("#9", text)
|
|
|
|
def test_duplicate_of_missing_or_itself(self):
|
|
with self.assertRaisesRegex(tc.Refused, "no #9"):
|
|
do("/close duplicate of #9")
|
|
with self.assertRaisesRegex(tc.Refused, "itself"):
|
|
do("/close duplicate of #5")
|
|
|
|
def test_reopen(self):
|
|
_, api = do("/reopen", Fake({5: thread("closed")}))
|
|
self.assertEqual(api.calls, [("reopen", 5)])
|
|
with self.assertRaisesRegex(tc.Refused, "already open"):
|
|
do("/reopen")
|
|
with self.assertRaisesRegex(tc.Refused, "already closed"):
|
|
do("/close", Fake({5: thread("closed")}))
|
|
|
|
def test_label_uses_the_real_name_and_skips_present(self):
|
|
_, api = do("/label Good First Issue, BUG")
|
|
self.assertEqual(api.calls, [("add", 5, ["good first issue"])])
|
|
with self.assertRaisesRegex(tc.Refused, "already has"):
|
|
do("/label bug")
|
|
|
|
def test_label_must_exist(self):
|
|
with self.assertRaisesRegex(tc.Refused, "no label"):
|
|
do("/label nonsense")
|
|
|
|
def test_unlabel(self):
|
|
_, api = do("/unlabel BUG")
|
|
self.assertEqual(api.calls, [("remove", 5, "bug")])
|
|
with self.assertRaisesRegex(tc.Refused, "doesn't have"):
|
|
do("/unlabel good first issue")
|
|
|
|
|
|
class RefusalTests(unittest.TestCase):
|
|
def test_release_and_parity_labels(self):
|
|
for name in ("release-now", "Release-Blocker", "skip-release", "full-parity", "prerelease"):
|
|
for verb in ("/label", "/unlabel"):
|
|
with self.assertRaisesRegex(tc.Refused, "release or parity", msg=name):
|
|
do(f"{verb} {name}", Fake(labels=(name,)))
|
|
with self.assertRaises(tc.Refused):
|
|
do("/label bug, release-now")
|
|
|
|
def test_ludovics_threads(self):
|
|
for text in ("/close", "/label bug", "/unlabel bug", "/reopen"):
|
|
for t in (thread(user="ludovic111", state="closed" if text == "/reopen" else "open"),
|
|
thread(user="Ludovic111", pr=True)):
|
|
with self.assertRaisesRegex(tc.Refused, "Ludovic's"):
|
|
do(text, Fake({5: t}))
|
|
|
|
def test_merged_pr(self):
|
|
for text in ("/close", "/reopen", "/label bug", "/unlabel bug"):
|
|
with self.assertRaisesRegex(tc.Refused, "merged"):
|
|
do(text, Fake({5: thread("closed", pr=True, merged=True)}))
|
|
|
|
def test_locked(self):
|
|
for text in ("/close", "/label good first issue", "/unlabel bug"):
|
|
with self.assertRaisesRegex(tc.Refused, "locked"):
|
|
do(text, Fake({5: thread(locked=True)}))
|
|
|
|
def test_missing_thread(self):
|
|
with self.assertRaisesRegex(tc.Refused, "no such"):
|
|
do("/close", Fake({}))
|
|
|
|
def test_nothing_written_on_refusal(self):
|
|
api = Fake({5: thread(user="ludovic111")})
|
|
with self.assertRaises(tc.Refused):
|
|
do("/close", api)
|
|
self.assertEqual(api.calls, [])
|
|
|
|
|
|
def past(login, n, who="github-actions[bot]"):
|
|
return [{"user": who, "body": f"🤖 Triage: closed as completed, on behalf of @{login} ([their comment](u))."}
|
|
for _ in range(n)]
|
|
|
|
|
|
class LimitTests(unittest.TestCase):
|
|
def test_sixty_a_day(self):
|
|
with self.assertRaisesRegex(tc.Refused, "60"):
|
|
do("/close", Fake(past=past("kacpherek", 60)))
|
|
do("/close", Fake(past=past("kacpherek", 59)))
|
|
|
|
def test_other_reviewers_and_forged_comments_dont_count(self):
|
|
do("/close", Fake(past=past("rdbtcvs", 80)))
|
|
do("/close", Fake(past=past("kacpherek", 80, who="someone")))
|
|
|
|
def test_refusals_dont_count(self):
|
|
refusals = [{"user": "github-actions[bot]", "body": "🤖 Triage: refused, nope (asked by @kacpherek)"}] * 70
|
|
do("/close", Fake(past=refusals))
|
|
|
|
def test_counts_case_insensitively(self):
|
|
with self.assertRaises(tc.Refused):
|
|
do("/close", Fake(past=past("kacpherek", 60)), login="Kacpherek")
|
|
|
|
def test_ludovic_is_unlimited(self):
|
|
do("/close", Fake(past=past("ludovic111", 100)), login="ludovic111")
|
|
|
|
|
|
class WorkflowTests(unittest.TestCase):
|
|
def test_workflow_is_safe(self):
|
|
text = (ROOT / ".github/workflows/triage-command.yml").read_text()
|
|
run_lines = [l for l in text.splitlines() if l.strip().startswith("run:")]
|
|
self.assertEqual(run_lines, [" run: python3 tools/ci/triage_command.py command"])
|
|
self.assertIn("persist-credentials: false", text)
|
|
self.assertNotIn("pull_request_target", text)
|
|
for perm in ("issues: write", "pull-requests: write", "contents: read"):
|
|
self.assertIn(perm, text)
|
|
self.assertNotIn("contents: write", text)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|