mirror of
https://github.com/radixark/miles.git
synced 2026-11-08 09:04:08 +08:00
2529 lines
92 KiB
Python
2529 lines
92 KiB
Python
import datetime
|
|
import importlib.util
|
|
import json
|
|
import urllib.error
|
|
import urllib.parse
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from tests.ci.ci_register import register_cpu_ci
|
|
from tests.ci.labels import KNOWN_LABELS
|
|
|
|
register_cpu_ci(est_time=1, suite="stage-a-cpu", labels=[])
|
|
|
|
ROOT = Path(__file__).parents[3]
|
|
HANDLER_PATH = ROOT / ".github/workflows/scripts/comment_ci_command.py"
|
|
POLICY_PATH = ROOT / ".github/workflows/policies/comment-command-access.json"
|
|
WORKFLOW_PATH = ROOT / ".github/workflows/comment-ci-command.yml"
|
|
|
|
|
|
def load_module(name, path):
|
|
spec = importlib.util.spec_from_file_location(name, path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
HANDLER = load_module("comment_ci_command", HANDLER_PATH)
|
|
ACTOR_ID = 1234
|
|
HEAD_SHA = "a" * 40
|
|
HEAD_REF = "feature/test"
|
|
WRITE_PERMISSIONS = frozenset({"write", "admin"})
|
|
RUN_FILE_BODY = "/rerun-test tests/e2e/precision/test_hf_attention_cp_relayout.py"
|
|
RUN_FILE_PATH = "tests/e2e/precision/test_hf_attention_cp_relayout.py"
|
|
WORKFLOW_RUN_ID = 987654321
|
|
WORKFLOW_RUN_API_URL = f"https://api.github.com/repos/radixark/miles/actions/runs/{WORKFLOW_RUN_ID}"
|
|
WORKFLOW_RUN_URL = f"https://github.com/radixark/miles/actions/runs/{WORKFLOW_RUN_ID}"
|
|
RUN_MARKER = f"<!-- rerun-test-run:{WORKFLOW_RUN_ID} -->"
|
|
RUN_STARTED_AT = "2026-08-23T09:25:25Z"
|
|
# 3m11s after RUN_STARTED_AT, the shape of a real 4-GPU file run.
|
|
REPORT_NOW = datetime.datetime(2026, 8, 23, 9, 28, 36, tzinfo=datetime.timezone.utc)
|
|
NEW_COMMENT_ID = 4242
|
|
EXISTING_COMMENT_ID = 777
|
|
ANNOUNCE_BODY = (
|
|
f"⏳ `{RUN_FILE_PATH}` is **running** — [workflow run]({WORKFLOW_RUN_URL})\n\n"
|
|
"Started at 2026-08-23 09:28:36 UTC; elapsed time and the result will be recorded "
|
|
f"here when it finishes.\n\n{RUN_MARKER}"
|
|
)
|
|
|
|
|
|
def file_run_env(mode, **overrides):
|
|
environ = {
|
|
"CI_COMMAND_FILE_RUN_STATUS": mode,
|
|
"FILE_RUN_PULL_NUMBER": "123",
|
|
"FILE_RUN_TEST_FILE": RUN_FILE_PATH,
|
|
"FILE_RUN_RUN_ID": str(WORKFLOW_RUN_ID),
|
|
}
|
|
if mode == "report":
|
|
environ.update(
|
|
{
|
|
"FILE_RUN_COMMENT_ID": str(EXISTING_COMMENT_ID),
|
|
"FILE_RUN_SUITE": "stage-c-4-gpu-h200",
|
|
"FILE_RUN_RESOLVE_RESULT": "success",
|
|
"FILE_RUN_CUDA_RESULT": "success",
|
|
"FILE_RUN_CPU_RESULT": "skipped",
|
|
}
|
|
)
|
|
environ.update(overrides)
|
|
return environ
|
|
|
|
|
|
def file_run_status(mode="report", **overrides):
|
|
return HANDLER._file_run_status_inputs(file_run_env(mode, **overrides))[1]
|
|
|
|
|
|
class FakeAPI:
|
|
def __init__(self, pull, *, permission="write", permission_actor_id=ACTOR_ID):
|
|
self.pull = pull
|
|
self.permission = {
|
|
"permission": permission,
|
|
"user": {"id": permission_actor_id},
|
|
}
|
|
self.calls = []
|
|
self.get_calls = []
|
|
self.permission_calls = []
|
|
self.add_calls = []
|
|
self.remove_calls = []
|
|
self.workflow_runs = {workflow_file: [] for workflow_file, _ in HANDLER.RERUN_WORKFLOWS}
|
|
self.list_run_calls = []
|
|
self.rerun_calls = []
|
|
self.list_pull_calls = []
|
|
self.dispatch_calls = []
|
|
self.reaction_calls = []
|
|
self.comment_calls = []
|
|
self.update_calls = []
|
|
self.run_calls = []
|
|
self.workflow_run = {"run_started_at": RUN_STARTED_AT}
|
|
self.head_pulls = [pull]
|
|
|
|
def get_pull(self, pull_number):
|
|
self.calls.append(("get_pull", pull_number))
|
|
self.get_calls.append(pull_number)
|
|
return self.pull
|
|
|
|
def get_permission(self, actor_login):
|
|
self.calls.append(("get_permission", actor_login))
|
|
self.permission_calls.append(actor_login)
|
|
return self.permission
|
|
|
|
def add_label(self, pull_number, label):
|
|
self.calls.append(("add_label", pull_number, label))
|
|
self.add_calls.append((pull_number, label))
|
|
return [*self.pull["labels"], {"name": label}]
|
|
|
|
def remove_label(self, pull_number, label):
|
|
self.calls.append(("remove_label", pull_number, label))
|
|
self.remove_calls.append((pull_number, label))
|
|
self.pull["labels"] = [item for item in self.pull["labels"] if item["name"] != label]
|
|
return self.pull["labels"]
|
|
|
|
def list_workflow_runs(self, workflow_file, head_sha):
|
|
self.calls.append(("list_workflow_runs", workflow_file, head_sha))
|
|
self.list_run_calls.append((workflow_file, head_sha))
|
|
return self.workflow_runs[workflow_file]
|
|
|
|
def rerun_failed_jobs(self, run_id):
|
|
self.calls.append(("rerun_failed_jobs", run_id))
|
|
self.rerun_calls.append(run_id)
|
|
|
|
def list_pulls_for_head(self, owner_login, head_ref):
|
|
self.calls.append(("list_pulls_for_head", owner_login, head_ref))
|
|
self.list_pull_calls.append((owner_login, head_ref))
|
|
return self.head_pulls
|
|
|
|
def create_workflow_dispatch(self, workflow_file, ref, inputs):
|
|
self.calls.append(("create_workflow_dispatch", workflow_file, ref, inputs))
|
|
self.dispatch_calls.append((workflow_file, ref, inputs))
|
|
return WORKFLOW_RUN_URL
|
|
|
|
def add_comment_reaction(self, comment_id, content):
|
|
self.calls.append(("add_comment_reaction", comment_id, content))
|
|
self.reaction_calls.append((comment_id, content))
|
|
|
|
def create_issue_comment(self, pull_number, body):
|
|
self.calls.append(("create_issue_comment", pull_number, body))
|
|
self.comment_calls.append((pull_number, body))
|
|
return NEW_COMMENT_ID
|
|
|
|
def update_issue_comment(self, comment_id, body):
|
|
self.calls.append(("update_issue_comment", comment_id, body))
|
|
self.update_calls.append((comment_id, body))
|
|
|
|
def get_workflow_run(self, run_id):
|
|
self.calls.append(("get_workflow_run", run_id))
|
|
self.run_calls.append(run_id)
|
|
return self.workflow_run
|
|
|
|
|
|
def event(*, body="/run-ci-short", actor_id=ACTOR_ID, author_association="NONE"):
|
|
return {
|
|
"action": "created",
|
|
"repository": {"id": HANDLER.REPOSITORY_ID, "full_name": HANDLER.REPOSITORY},
|
|
"issue": {"number": 123, "pull_request": {"url": "https://example.invalid/pulls/123"}},
|
|
"comment": {
|
|
"id": 5678,
|
|
"body": body,
|
|
"author_association": author_association,
|
|
"user": {"id": actor_id, "login": "actor", "type": "User"},
|
|
},
|
|
"sender": {"id": actor_id, "login": "actor", "type": "User"},
|
|
}
|
|
|
|
|
|
def pull(
|
|
*,
|
|
head_repository_id=HANDLER.REPOSITORY_ID,
|
|
head_sha=HEAD_SHA,
|
|
head_ref=HEAD_REF,
|
|
state="open",
|
|
labels=(),
|
|
):
|
|
head_owner = "radixark" if head_repository_id == HANDLER.REPOSITORY_ID else "fork-owner"
|
|
return {
|
|
"number": 123,
|
|
"state": state,
|
|
"base": {"repo": {"id": HANDLER.REPOSITORY_ID, "full_name": HANDLER.REPOSITORY}},
|
|
"head": {
|
|
"ref": head_ref,
|
|
"sha": head_sha,
|
|
"repo": {"id": head_repository_id, "owner": {"login": head_owner}},
|
|
},
|
|
"labels": [{"name": label} for label in labels],
|
|
}
|
|
|
|
|
|
def policy(
|
|
*,
|
|
permissions=("write", "admin"),
|
|
user_ids=(),
|
|
prior_user_ids=(),
|
|
labels=("run-ci-short", "bypass-fastfail"),
|
|
repo_permissions=("write", "admin"),
|
|
author_associations=("OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR"),
|
|
):
|
|
return {
|
|
"groups": {
|
|
"add_label_access": {
|
|
"repository_permissions": frozenset(permissions),
|
|
"user_ids": frozenset(user_ids),
|
|
"author_associations": frozenset(),
|
|
},
|
|
"repo_write_access": {
|
|
"repository_permissions": frozenset(repo_permissions),
|
|
"user_ids": frozenset(),
|
|
"author_associations": frozenset(),
|
|
},
|
|
"prior_contributor_access": {
|
|
"repository_permissions": frozenset(repo_permissions),
|
|
"user_ids": frozenset(prior_user_ids),
|
|
"author_associations": frozenset(author_associations),
|
|
},
|
|
},
|
|
"commands": {
|
|
"add_label": {
|
|
"group": "add_label_access",
|
|
"allowed_labels": frozenset(labels),
|
|
},
|
|
"clear_labels": {"group": "repo_write_access"},
|
|
"rerun_failed_ci": {"group": "prior_contributor_access"},
|
|
"run_test_file": {"group": "prior_contributor_access"},
|
|
},
|
|
}
|
|
|
|
|
|
def workflow_run(
|
|
workflow_path,
|
|
*,
|
|
run_id=10,
|
|
run_number=10,
|
|
status="completed",
|
|
conclusion="failure",
|
|
head_sha=HEAD_SHA,
|
|
head_repository_id=HANDLER.REPOSITORY_ID,
|
|
head_ref=HEAD_REF,
|
|
pull_number=123,
|
|
pull_requests=None,
|
|
):
|
|
return {
|
|
"conclusion": conclusion,
|
|
"event": "pull_request",
|
|
"head_repository": {"id": head_repository_id},
|
|
"head_branch": head_ref,
|
|
"head_sha": head_sha,
|
|
"id": run_id,
|
|
"path": workflow_path,
|
|
"pull_requests": ([{"number": pull_number}] if pull_requests is None else pull_requests),
|
|
"run_number": run_number,
|
|
"status": status,
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"body",
|
|
[
|
|
"/run-ci-short extra",
|
|
"/run-ci-short\n/run-ci-long",
|
|
"please /run-ci-short",
|
|
"/run-ci-",
|
|
"/run-ci-/unsafe",
|
|
"/run-ci",
|
|
"/run-ci ",
|
|
"/run-ci tests/e2e/test_a.py",
|
|
"/rerun-test",
|
|
"/rerun-test ",
|
|
"/rerun-test tests/e2e/test_a.py extra",
|
|
"/rerun-test tests/e2e/test_a.py\n/run-ci-short",
|
|
"please /rerun-test tests/e2e/test_a.py",
|
|
"/rerun-test tests/unit/test_a.py",
|
|
"/rerun-test tests/e2e/helper.py",
|
|
"/rerun-test /tests/e2e/test_a.py",
|
|
"/rerun-test ../tests/e2e/test_a.py",
|
|
"/rerun-test tests/e2e/../fast/test_a.py",
|
|
"/rerun-test tests/e2e/test_a.py;rm",
|
|
"/rerun-test tests/e2e/test_a.py tests/e2e/test_b.py",
|
|
"/rerun-failed-ci extra",
|
|
"/rerun-failed-ci\n/run-ci-short",
|
|
"/clear-labels extra",
|
|
"/clear-labels\n/run-ci-short",
|
|
"please /clear-labels",
|
|
],
|
|
)
|
|
def test_command_parser_rejects_non_exact_commands(body):
|
|
with pytest.raises(HANDLER.CommentCommandError, match="one exact /<label>"):
|
|
HANDLER.parse_command(body)
|
|
|
|
|
|
def test_command_parser_accepts_one_exact_command_with_outer_whitespace():
|
|
assert HANDLER.parse_command(" \n/run-ci-a_B.c-d\t") == HANDLER.AddLabel("run-ci-a_B.c-d")
|
|
|
|
|
|
def test_command_parser_accepts_exact_bypass_fastfail_command():
|
|
assert HANDLER.parse_command("/bypass-fastfail") == HANDLER.AddLabel("bypass-fastfail")
|
|
|
|
|
|
def test_command_parser_accepts_exact_clear_command_with_outer_whitespace():
|
|
assert HANDLER.parse_command(" \n/clear-labels\t") == HANDLER.ClearLabels()
|
|
|
|
|
|
def test_command_parser_accepts_exact_rerun_command_with_outer_whitespace():
|
|
assert HANDLER.parse_command(" \n/rerun-failed-ci\t") == HANDLER.RerunFailedCI()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("body", "test_file"),
|
|
[
|
|
(
|
|
"/rerun-test tests/e2e/precision/test_hf_attention_cp_relayout.py",
|
|
"tests/e2e/precision/test_hf_attention_cp_relayout.py",
|
|
),
|
|
(" \n/rerun-test tests/e2e/test_a.py\t", "tests/e2e/test_a.py"),
|
|
("/rerun-test tests/fast/rollout/test_b.py", "tests/fast/rollout/test_b.py"),
|
|
("/rerun-test tests/fast-gpu/test_c.py", "tests/fast-gpu/test_c.py"),
|
|
("/rerun-test tests/ci/test/test_d.py", "tests/ci/test/test_d.py"),
|
|
],
|
|
)
|
|
def test_command_parser_accepts_run_file_command(body, test_file):
|
|
assert HANDLER.parse_command(body) == HANDLER.RunTestFile(test_file)
|
|
|
|
|
|
@pytest.mark.parametrize("body", ["", "ordinary review comment", "/Clear-labels", "/unknown-command"])
|
|
def test_command_parser_ignores_unrelated_comments(body):
|
|
assert HANDLER.parse_command(body) is None
|
|
|
|
|
|
def test_static_registry_owns_policy_capability_and_handler_routing():
|
|
expected = {
|
|
HANDLER.AddLabel: ("add_label", "issues", HANDLER._handle_add_label, "none"),
|
|
HANDLER.ClearLabels: ("clear_labels", "issues", HANDLER._handle_clear_labels, "none"),
|
|
HANDLER.RerunFailedCI: ("rerun_failed_ci", "actions", HANDLER._handle_rerun_failed_ci, "none"),
|
|
HANDLER.RunTestFile: ("run_test_file", "actions", HANDLER._handle_run_test_file, "+1"),
|
|
}
|
|
assert set(HANDLER.COMMAND_REGISTRY) == set(expected)
|
|
for request_type, (policy_key, capability, handler, success_reaction) in expected.items():
|
|
spec = HANDLER.COMMAND_REGISTRY[request_type]
|
|
assert (spec.policy_key, spec.capability, spec.handler, spec.success_reaction) == (
|
|
policy_key,
|
|
capability,
|
|
handler,
|
|
success_reaction,
|
|
)
|
|
|
|
|
|
def test_unknown_request_type_fails_closed():
|
|
class UnknownRequest:
|
|
pass
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="not registered"):
|
|
HANDLER._command_spec(UnknownRequest())
|
|
|
|
|
|
def raw_policy():
|
|
return {
|
|
"version": 4,
|
|
"groups": {
|
|
"add_label_access": {
|
|
"repository_permissions": ["write", "admin"],
|
|
"users": [],
|
|
},
|
|
"repo_write_access": {"repository_permissions": ["write", "admin"]},
|
|
"prior_contributor_access": {
|
|
"repository_permissions": ["write", "admin"],
|
|
"users": [],
|
|
"author_associations": ["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR"],
|
|
},
|
|
},
|
|
"commands": {
|
|
"add_label": {
|
|
"group": "add_label_access",
|
|
"allowed_labels": ["run-ci-short", "bypass-fastfail"],
|
|
},
|
|
"clear_labels": {"group": "repo_write_access"},
|
|
"rerun_failed_ci": {"group": "prior_contributor_access"},
|
|
"run_test_file": {"group": "prior_contributor_access"},
|
|
},
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("text", "message"),
|
|
[
|
|
('{"version":1,"version":1,"labels":[]}', "duplicate JSON key"),
|
|
('{"version":NaN,"labels":[]}', "non-standard JSON number"),
|
|
(
|
|
'{"version":1,"add_label_access":{"repository_permissions":[true],"user_ids":[]},'
|
|
'"clear_permissions":["write"],"rerun_permissions":["write"],"labels":["run-ci-short"]}',
|
|
"repository_permissions must contain only write or admin",
|
|
),
|
|
(
|
|
'{"version":1,"add_label_access":{"repository_permissions":["read"],"user_ids":[]},'
|
|
'"clear_permissions":["write"],"rerun_permissions":["write"],"labels":["run-ci-short"]}',
|
|
"repository_permissions must contain only write or admin",
|
|
),
|
|
(
|
|
'{"version":1,"add_label_access":{"repository_permissions":["write","write"],"user_ids":[]},'
|
|
'"clear_permissions":["write"],"rerun_permissions":["write"],"labels":["run-ci-short"]}',
|
|
"repository_permissions contains duplicate permissions",
|
|
),
|
|
(
|
|
'{"version":1,"add_label_access":{"repository_permissions":["write"],"user_ids":[true]},'
|
|
'"clear_permissions":["write"],"rerun_permissions":["write"],"labels":["run-ci-short"]}',
|
|
"user_ids must contain only positive integers",
|
|
),
|
|
(
|
|
'{"version":1,"add_label_access":{"repository_permissions":["write"],"user_ids":[123,123]},'
|
|
'"clear_permissions":["write"],"rerun_permissions":["write"],"labels":["run-ci-short"]}',
|
|
"user_ids contains duplicate user IDs",
|
|
),
|
|
(
|
|
'{"version":1,"add_label_access":{"repository_permissions":["write"],"user_ids":[]},'
|
|
'"clear_permissions":["write"],"rerun_permissions":["write"],"labels":["unsafe label"]}',
|
|
"invalid exact CI label",
|
|
),
|
|
(
|
|
'{"version":1,"add_label_access":{"repository_permissions":["write"],"user_ids":[]},'
|
|
'"clear_permissions":["write"],"rerun_permissions":["write"],'
|
|
'"labels":["run-ci-short","run-ci-short"]}',
|
|
"labels contains duplicate labels",
|
|
),
|
|
(
|
|
'{"version":1,"add_label_access":{"repository_permissions":["write"],"user_ids":[]},'
|
|
'"clear_permissions":[true],"rerun_permissions":["write"],"labels":["run-ci-short"]}',
|
|
"clear_permissions must contain only write or admin",
|
|
),
|
|
(
|
|
'{"version":1,"add_label_access":{"repository_permissions":["write"],"user_ids":[]},'
|
|
'"clear_permissions":["read"],"rerun_permissions":["write"],"labels":["run-ci-short"]}',
|
|
"clear_permissions must contain only write or admin",
|
|
),
|
|
(
|
|
'{"version":1,"add_label_access":{"repository_permissions":["write"],"user_ids":[]},'
|
|
'"clear_permissions":["write","write"],"rerun_permissions":["write"],'
|
|
'"labels":["run-ci-short"]}',
|
|
"clear_permissions contains duplicate permissions",
|
|
),
|
|
(
|
|
'{"version":1,"add_label_access":{"repository_permissions":["write"],"user_ids":[]},'
|
|
'"clear_permissions":["write"],"rerun_permissions":["read"],"labels":["run-ci-short"]}',
|
|
"rerun_permissions must contain only write or admin",
|
|
),
|
|
(
|
|
'{"version":1,"add_label_access":{"repository_permissions":["write"],"user_ids":[]},'
|
|
'"clear_permissions":["write"],"rerun_permissions":["write","write"],'
|
|
'"labels":["run-ci-short"]}',
|
|
"rerun_permissions contains duplicate permissions",
|
|
),
|
|
('{"version":1,"labels":["run-ci-short"]}', "only version"),
|
|
(
|
|
'{"version":1,"add_label_access":{"repository_permissions":["write"],"user_ids":[]},'
|
|
'"clear_permissions":["write"],"rerun_permissions":["write"],'
|
|
'"labels":["run-ci-short"],'
|
|
'"roles":{}}',
|
|
"only version",
|
|
),
|
|
],
|
|
)
|
|
def test_policy_parser_rejects_legacy_or_nonstandard_schema(tmp_path, text, message):
|
|
path = tmp_path / "policy.json"
|
|
path.write_text(text)
|
|
expected = message if "duplicate JSON" in message or "non-standard JSON" in message else "only version, groups"
|
|
with pytest.raises(HANDLER.CommentCommandError, match=expected):
|
|
HANDLER.load_policy(path)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("path_parts", "value", "message"),
|
|
[
|
|
(("version",), 3, "version must be 4"),
|
|
(("groups", "add_label_access", "repository_permissions"), [True], "only write or admin"),
|
|
(("groups", "add_label_access", "repository_permissions"), ["read"], "only write or admin"),
|
|
(("groups", "add_label_access", "repository_permissions"), ["write", "write"], "duplicate permissions"),
|
|
(("groups", "prior_contributor_access", "author_associations"), [], "non-empty array"),
|
|
(("groups", "prior_contributor_access", "author_associations"), [True], "only GitHub author associations"),
|
|
(
|
|
("groups", "prior_contributor_access", "author_associations"),
|
|
["read"],
|
|
"only GitHub author associations",
|
|
),
|
|
(
|
|
("groups", "prior_contributor_access", "author_associations"),
|
|
["MEMBER", "MEMBER"],
|
|
"duplicate author associations",
|
|
),
|
|
(("commands", "add_label", "allowed_labels"), ["unsafe label"], "invalid exact CI label"),
|
|
(("commands", "add_label", "allowed_labels"), ["run-ci-short", "run-ci-short"], "duplicate labels"),
|
|
(("commands", "add_label", "group"), "missing", "unknown group"),
|
|
(("commands", "clear_labels", "unexpected"), True, "invalid fields"),
|
|
(("groups", "repo_write_access", "users"), [{"id": 123, "login": "actor"}], "invalid fields"),
|
|
(("groups", "repo_write_access", "author_associations"), ["MEMBER"], "invalid fields"),
|
|
(("groups", "add_label_access", "author_associations"), ["MEMBER"], "invalid fields"),
|
|
],
|
|
)
|
|
def test_policy_parser_rejects_invalid_group_command_or_resource(tmp_path, path_parts, value, message):
|
|
raw = raw_policy()
|
|
target = raw
|
|
for part in path_parts[:-1]:
|
|
target = target[part]
|
|
target[path_parts[-1]] = value
|
|
path = tmp_path / "policy.json"
|
|
path.write_text(json.dumps(raw))
|
|
with pytest.raises(HANDLER.CommentCommandError, match=message):
|
|
HANDLER.load_policy(path)
|
|
|
|
|
|
def test_policy_parser_loads_independent_users_for_both_customizable_tiers(tmp_path):
|
|
raw = raw_policy()
|
|
raw["groups"]["add_label_access"]["users"] = [{"id": 111, "login": "alice"}]
|
|
raw["groups"]["prior_contributor_access"]["users"] = [{"id": 222, "login": "bob"}]
|
|
path = tmp_path / "policy.json"
|
|
path.write_text(json.dumps(raw))
|
|
|
|
loaded = HANDLER.load_policy(path)
|
|
|
|
assert loaded["groups"]["add_label_access"]["user_ids"] == {111}
|
|
assert loaded["groups"]["prior_contributor_access"]["user_ids"] == {222}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("group", "body"),
|
|
[
|
|
("add_label_access", "/run-ci-short"),
|
|
("prior_contributor_access", RUN_FILE_BODY),
|
|
],
|
|
)
|
|
def test_user_login_is_display_only_for_authorization(tmp_path, group, body):
|
|
raw = raw_policy()
|
|
raw["groups"][group]["users"] = [{"id": ACTOR_ID, "login": "stale-login"}]
|
|
path = tmp_path / "policy.json"
|
|
path.write_text(json.dumps(raw))
|
|
api = FakeAPI(pull(), permission="none")
|
|
|
|
result = HANDLER.authorize_policy(
|
|
event(body=body, author_association="FIRST_TIMER"),
|
|
HANDLER.load_policy(path),
|
|
api,
|
|
)
|
|
|
|
assert result[0:2] == (123, ACTOR_ID)
|
|
assert api.calls == []
|
|
|
|
|
|
@pytest.mark.parametrize("section", ["groups", "commands"])
|
|
def test_policy_parser_rejects_unknown_group_or_command(tmp_path, section):
|
|
raw = raw_policy()
|
|
raw[section]["unexpected"] = {}
|
|
path = tmp_path / "policy.json"
|
|
path.write_text(json.dumps(raw))
|
|
expected = "groups must contain only" if section == "groups" else "commands do not match"
|
|
with pytest.raises(HANDLER.CommentCommandError, match=expected):
|
|
HANDLER.load_policy(path)
|
|
|
|
|
|
def test_repo_write_command_cannot_use_a_group_with_explicit_user_ids(tmp_path):
|
|
raw = raw_policy()
|
|
raw["groups"]["add_label_access"]["users"] = [{"id": ACTOR_ID, "login": "actor"}]
|
|
raw["commands"]["clear_labels"]["group"] = "add_label_access"
|
|
path = tmp_path / "policy.json"
|
|
path.write_text(json.dumps(raw))
|
|
with pytest.raises(HANDLER.CommentCommandError, match="cannot grant access by user ID"):
|
|
HANDLER.load_policy(path)
|
|
|
|
|
|
def test_checked_in_policy_exposes_exact_labels_and_access_groups():
|
|
loaded = HANDLER.load_policy(POLICY_PATH)
|
|
labels = loaded["commands"]["add_label"]["allowed_labels"]
|
|
assert labels == {f"run-ci-{key}" for key in KNOWN_LABELS} | {
|
|
"bypass-fastfail",
|
|
"run-ci-image",
|
|
}
|
|
assert loaded["groups"]["add_label_access"] == {
|
|
"repository_permissions": WRITE_PERMISSIONS,
|
|
"user_ids": frozenset({82826991, 59716405, 101526713, 106564213}),
|
|
"author_associations": frozenset(),
|
|
}
|
|
assert loaded["groups"]["repo_write_access"] == {
|
|
"repository_permissions": WRITE_PERMISSIONS,
|
|
"user_ids": frozenset(),
|
|
"author_associations": frozenset(),
|
|
}
|
|
assert loaded["groups"]["prior_contributor_access"] == {
|
|
"repository_permissions": WRITE_PERMISSIONS,
|
|
"user_ids": frozenset(),
|
|
"author_associations": frozenset({"OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR"}),
|
|
}
|
|
assert loaded["commands"]["clear_labels"] == {"group": "repo_write_access"}
|
|
assert loaded["commands"]["rerun_failed_ci"] == {"group": "prior_contributor_access"}
|
|
assert loaded["commands"]["run_test_file"] == {"group": "prior_contributor_access"}
|
|
assert all(HANDLER.LABEL_PATTERN.fullmatch(label) for label in labels)
|
|
|
|
|
|
@pytest.mark.parametrize("permission", ["write", "admin"])
|
|
def test_repository_writer_adds_one_exact_label(permission):
|
|
api = FakeAPI(pull(), permission=permission)
|
|
|
|
result = HANDLER.process_event(event(), policy(), api)
|
|
|
|
assert result == {
|
|
"actor_id": ACTOR_ID,
|
|
"decision": "ALLOW_ADDED",
|
|
"label": "run-ci-short",
|
|
"pull_number": 123,
|
|
}
|
|
assert api.get_calls == [123]
|
|
assert api.permission_calls == ["actor"]
|
|
assert api.add_calls == [(123, "run-ci-short")]
|
|
assert api.calls == [
|
|
("get_pull", 123),
|
|
("get_permission", "actor"),
|
|
("add_label", 123, "run-ci-short"),
|
|
]
|
|
|
|
|
|
def test_maintain_role_is_allowed_by_legacy_write_permission():
|
|
api = FakeAPI(pull(), permission="write")
|
|
api.permission["role_name"] = "maintain"
|
|
|
|
result = HANDLER.process_event(event(), policy(), api)
|
|
|
|
assert result["decision"] == "ALLOW_ADDED"
|
|
assert api.add_calls == [(123, "run-ci-short")]
|
|
|
|
|
|
def test_add_label_access_user_id_can_add_label_without_repository_write():
|
|
api = FakeAPI(pull(), permission="read")
|
|
|
|
result = HANDLER.process_event(event(), policy(user_ids=(ACTOR_ID,)), api)
|
|
|
|
assert result["decision"] == "ALLOW_ADDED"
|
|
assert api.permission_calls == []
|
|
assert api.add_calls == [(123, "run-ci-short")]
|
|
|
|
|
|
def test_add_label_access_user_id_can_add_bypass_fastfail():
|
|
api = FakeAPI(pull(), permission="none")
|
|
|
|
result = HANDLER.process_event(
|
|
event(body="/bypass-fastfail"),
|
|
policy(user_ids=(ACTOR_ID,)),
|
|
api,
|
|
)
|
|
|
|
assert result["label"] == "bypass-fastfail"
|
|
assert api.permission_calls == []
|
|
assert api.add_calls == [(123, "bypass-fastfail")]
|
|
|
|
|
|
def test_add_label_access_user_id_preflight_does_not_require_repository_permission():
|
|
api = FakeAPI(pull(), permission="none")
|
|
|
|
assert HANDLER.authorize_policy(event(), policy(user_ids=(ACTOR_ID,)), api) == (
|
|
123,
|
|
ACTOR_ID,
|
|
HANDLER.AddLabel("run-ci-short"),
|
|
HANDLER.COMMAND_REGISTRY[HANDLER.AddLabel],
|
|
)
|
|
assert api.calls == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("body", "request_type"),
|
|
[
|
|
(RUN_FILE_BODY, HANDLER.RunTestFile),
|
|
("/rerun-failed-ci", HANDLER.RerunFailedCI),
|
|
],
|
|
)
|
|
def test_prior_contributor_access_user_id_preflight_does_not_require_repository_permission(body, request_type):
|
|
api = FakeAPI(pull(), permission="none")
|
|
|
|
result = HANDLER.authorize_policy(
|
|
event(body=body, author_association="FIRST_TIMER"),
|
|
policy(prior_user_ids=(ACTOR_ID,)),
|
|
api,
|
|
)
|
|
|
|
assert result[0:2] == (123, ACTOR_ID)
|
|
assert type(result[2]) is request_type
|
|
assert api.calls == []
|
|
|
|
|
|
@pytest.mark.parametrize("body", ["/clear-labels", "/rerun-failed-ci"])
|
|
def test_add_label_access_user_id_cannot_clear_or_rerun(body):
|
|
api = FakeAPI(pull(labels=("run-ci-short",)), permission="read")
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="not authorized"):
|
|
HANDLER.process_event(event(body=body), policy(user_ids=(ACTOR_ID,)), api)
|
|
|
|
assert api.add_calls == []
|
|
assert api.remove_calls == []
|
|
assert api.rerun_calls == []
|
|
|
|
|
|
@pytest.mark.parametrize(("permission", "allowed"), [("write", False), ("admin", True)])
|
|
def test_add_label_access_group_can_require_admin(permission, allowed):
|
|
api = FakeAPI(pull(), permission=permission)
|
|
|
|
if allowed:
|
|
assert HANDLER.process_event(event(), policy(permissions=("admin",)), api)["decision"] == "ALLOW_ADDED"
|
|
else:
|
|
with pytest.raises(HANDLER.CommentCommandError, match="not authorized"):
|
|
HANDLER.process_event(event(), policy(permissions=("admin",)), api)
|
|
|
|
|
|
def test_existing_label_is_an_authorized_no_op():
|
|
api = FakeAPI(pull(labels=("run-ci-short", "documentation")))
|
|
|
|
result = HANDLER.process_event(event(), policy(), api)
|
|
|
|
assert result["decision"] == "ALLOW_ALREADY_PRESENT"
|
|
assert api.add_calls == []
|
|
|
|
|
|
@pytest.mark.parametrize("permission", ["write", "admin"])
|
|
def test_repository_writer_clears_only_ci_control_labels(permission):
|
|
api = FakeAPI(
|
|
pull(
|
|
labels=(
|
|
"run-ci-short",
|
|
"run-ci",
|
|
"run-ci-all",
|
|
"run-ci-historical",
|
|
"nightly",
|
|
"bypass-fastfail",
|
|
"documentation",
|
|
"bug",
|
|
)
|
|
),
|
|
permission=permission,
|
|
)
|
|
|
|
result = HANDLER.process_event(event(body="/clear-labels"), policy(), api)
|
|
|
|
removed = [
|
|
"bypass-fastfail",
|
|
"nightly",
|
|
"run-ci",
|
|
"run-ci-all",
|
|
"run-ci-historical",
|
|
"run-ci-short",
|
|
]
|
|
assert result == {
|
|
"actor_id": ACTOR_ID,
|
|
"decision": "ALLOW_CLEARED",
|
|
"labels": removed,
|
|
"pull_number": 123,
|
|
}
|
|
assert api.remove_calls == [(123, label) for label in removed]
|
|
assert api.pull["labels"] == [{"name": "documentation"}, {"name": "bug"}]
|
|
|
|
|
|
def test_clear_is_an_authorized_no_op_when_no_ci_control_label_exists():
|
|
api = FakeAPI(pull(labels=("documentation", "bug")))
|
|
|
|
result = HANDLER.process_event(event(body="/clear-labels"), policy(), api)
|
|
|
|
assert result["decision"] == "ALLOW_ALREADY_CLEAR"
|
|
assert result["labels"] == []
|
|
assert api.remove_calls == []
|
|
|
|
|
|
def test_unknown_request_does_not_call_github_api():
|
|
api = FakeAPI(pull())
|
|
with pytest.raises(HANDLER.CommentCommandError, match="not exposed"):
|
|
HANDLER.process_event(event(body="/run-ci-unknown"), policy(), api)
|
|
assert api.calls == []
|
|
|
|
|
|
@pytest.mark.parametrize("permission", ["read", "none", "unknown"])
|
|
def test_caller_without_write_permission_cannot_mutate(permission):
|
|
api = FakeAPI(pull(), permission=permission)
|
|
with pytest.raises(HANDLER.CommentCommandError, match="not authorized"):
|
|
HANDLER.process_event(event(), policy(), api)
|
|
assert api.calls == [("get_pull", 123), ("get_permission", "actor")]
|
|
assert api.add_calls == []
|
|
|
|
|
|
def test_preflight_checks_permission_without_reading_pull_request():
|
|
api = FakeAPI(pull())
|
|
|
|
assert HANDLER.authorize_policy(event(), policy(), api) == (
|
|
123,
|
|
ACTOR_ID,
|
|
HANDLER.AddLabel("run-ci-short"),
|
|
HANDLER.COMMAND_REGISTRY[HANDLER.AddLabel],
|
|
)
|
|
assert api.calls == [("get_permission", "actor")]
|
|
|
|
|
|
def test_clear_preflight_uses_its_own_permission_policy_without_reading_pull_request():
|
|
api = FakeAPI(pull(), permission="admin")
|
|
|
|
assert HANDLER.authorize_policy(
|
|
event(body="/clear-labels"),
|
|
policy(repo_permissions=("admin",)),
|
|
api,
|
|
) == (
|
|
123,
|
|
ACTOR_ID,
|
|
HANDLER.ClearLabels(),
|
|
HANDLER.COMMAND_REGISTRY[HANDLER.ClearLabels],
|
|
)
|
|
assert api.calls == [("get_permission", "actor")]
|
|
|
|
|
|
def test_clear_requires_its_exact_live_permission_policy():
|
|
api = FakeAPI(pull(labels=("run-ci-short",)), permission="write")
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="not authorized"):
|
|
HANDLER.process_event(
|
|
event(body="/clear-labels"),
|
|
policy(repo_permissions=("admin",)),
|
|
api,
|
|
)
|
|
|
|
assert api.remove_calls == []
|
|
|
|
|
|
@pytest.mark.parametrize("permission", ["write", "admin"])
|
|
def test_repository_writer_can_add_a_label_to_a_fork_pr(permission):
|
|
api = FakeAPI(pull(head_repository_id=999), permission=permission, permission_actor_id=2)
|
|
|
|
result = HANDLER.process_event(event(actor_id=2), policy(), api)
|
|
|
|
assert result["decision"] == "ALLOW_ADDED"
|
|
assert api.add_calls == [(123, "run-ci-short")]
|
|
|
|
|
|
def test_add_label_access_user_id_can_add_a_label_to_a_fork_pr():
|
|
api = FakeAPI(
|
|
pull(head_repository_id=999),
|
|
permission="none",
|
|
permission_actor_id=2,
|
|
)
|
|
|
|
result = HANDLER.process_event(
|
|
event(actor_id=2),
|
|
policy(user_ids=(2,)),
|
|
api,
|
|
)
|
|
|
|
assert result["decision"] == "ALLOW_ADDED"
|
|
assert api.permission_calls == []
|
|
assert api.add_calls == [(123, "run-ci-short")]
|
|
|
|
|
|
def test_repository_writer_can_clear_ci_labels_from_a_fork_pr():
|
|
api = FakeAPI(
|
|
pull(head_repository_id=999, labels=("run-ci-short", "documentation")),
|
|
permission_actor_id=2,
|
|
)
|
|
|
|
result = HANDLER.process_event(event(body="/clear-labels", actor_id=2), policy(), api)
|
|
|
|
assert result["decision"] == "ALLOW_CLEARED"
|
|
assert api.remove_calls == [(123, "run-ci-short")]
|
|
assert api.pull["labels"] == [{"name": "documentation"}]
|
|
|
|
|
|
@pytest.mark.parametrize("permission", ["write", "admin"])
|
|
def test_repository_writer_reruns_latest_failed_pr_ci(permission):
|
|
api = FakeAPI(pull(), permission=permission)
|
|
expected_ids = []
|
|
for index, (workflow_file, workflow_path) in enumerate(HANDLER.RERUN_WORKFLOWS):
|
|
old_id = 100 + index
|
|
latest_id = 200 + index
|
|
api.workflow_runs[workflow_file] = [
|
|
workflow_run(workflow_path, run_id=old_id, run_number=1),
|
|
workflow_run(workflow_path, run_id=latest_id, run_number=2),
|
|
]
|
|
expected_ids.append(latest_id)
|
|
|
|
result = HANDLER.process_event(event(body="/rerun-failed-ci"), policy(), api)
|
|
|
|
assert result == {
|
|
"actor_id": ACTOR_ID,
|
|
"decision": "ALLOW_RERUN_REQUESTED",
|
|
"head_sha": HEAD_SHA,
|
|
"pull_number": 123,
|
|
"workflow_run_ids": expected_ids,
|
|
}
|
|
expected_list_calls = [(workflow_file, HEAD_SHA) for workflow_file, _ in HANDLER.RERUN_WORKFLOWS]
|
|
assert api.list_run_calls == expected_list_calls + expected_list_calls
|
|
assert api.rerun_calls == expected_ids
|
|
assert api.permission_calls == ["actor"] * len(expected_ids)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "conclusion"),
|
|
[
|
|
("queued", None),
|
|
("in_progress", None),
|
|
("completed", "success"),
|
|
("completed", "skipped"),
|
|
("completed", "cancelled"),
|
|
("completed", "timed_out"),
|
|
],
|
|
)
|
|
def test_newer_non_failure_run_does_not_revive_an_older_failure(status, conclusion):
|
|
api = FakeAPI(pull())
|
|
workflow_file, workflow_path = HANDLER.RERUN_WORKFLOWS[0]
|
|
api.workflow_runs[workflow_file] = [
|
|
workflow_run(workflow_path, run_id=10, run_number=1),
|
|
workflow_run(
|
|
workflow_path,
|
|
run_id=20,
|
|
run_number=2,
|
|
status=status,
|
|
conclusion=conclusion,
|
|
),
|
|
]
|
|
|
|
result = HANDLER.process_event(event(body="/rerun-failed-ci"), policy(), api)
|
|
|
|
assert result["decision"] == "ALLOW_NO_FAILED_RUNS"
|
|
assert result["workflow_run_ids"] == []
|
|
assert api.rerun_calls == []
|
|
|
|
|
|
def test_rerun_ignores_same_sha_run_not_associated_with_this_pr():
|
|
api = FakeAPI(pull())
|
|
workflow_file, workflow_path = HANDLER.RERUN_WORKFLOWS[0]
|
|
api.workflow_runs[workflow_file] = [workflow_run(workflow_path, pull_number=456)]
|
|
|
|
result = HANDLER.process_event(event(body="/rerun-failed-ci"), policy(), api)
|
|
|
|
assert result["decision"] == "ALLOW_NO_FAILED_RUNS"
|
|
assert api.rerun_calls == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("change", "message"),
|
|
[
|
|
({"path": ".github/workflows/docker-pr-tag-cleanup.yml"}, "unexpected path"),
|
|
({"event": "pull_request_target"}, "unexpected event or SHA"),
|
|
({"head_sha": "b" * 40}, "unexpected event or SHA"),
|
|
({"head_branch": "other/ref"}, "unexpected head ref"),
|
|
({"head_repository": {"id": 999}}, "unexpected repository"),
|
|
({"pull_requests": None}, "invalid workflow-run pull requests"),
|
|
],
|
|
)
|
|
def test_rerun_fails_closed_on_mismatched_run_identity(change, message):
|
|
api = FakeAPI(pull())
|
|
workflow_file, workflow_path = HANDLER.RERUN_WORKFLOWS[0]
|
|
api.workflow_runs[workflow_file] = [{**workflow_run(workflow_path), **change}]
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match=message):
|
|
HANDLER.process_event(event(body="/rerun-failed-ci"), policy(), api)
|
|
|
|
assert api.rerun_calls == []
|
|
|
|
|
|
def test_rerun_requires_its_exact_live_permission_policy():
|
|
api = FakeAPI(pull(), permission="write")
|
|
workflow_file, workflow_path = HANDLER.RERUN_WORKFLOWS[0]
|
|
api.workflow_runs[workflow_file] = [workflow_run(workflow_path)]
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="not authorized"):
|
|
HANDLER.process_event(
|
|
event(body="/rerun-failed-ci"),
|
|
policy(repo_permissions=("admin",)),
|
|
api,
|
|
)
|
|
|
|
assert api.rerun_calls == []
|
|
|
|
|
|
def test_rerun_preflight_uses_its_own_policy_without_reading_pull_request():
|
|
api = FakeAPI(pull(), permission="admin")
|
|
|
|
assert HANDLER.authorize_policy(
|
|
event(body="/rerun-failed-ci"),
|
|
policy(repo_permissions=("admin",)),
|
|
api,
|
|
) == (
|
|
123,
|
|
ACTOR_ID,
|
|
HANDLER.RerunFailedCI(),
|
|
HANDLER.COMMAND_REGISTRY[HANDLER.RerunFailedCI],
|
|
)
|
|
assert api.calls == [("get_permission", "actor")]
|
|
|
|
|
|
def test_repository_writer_can_rerun_failed_ci_for_a_fork_pr():
|
|
api = FakeAPI(pull(head_repository_id=999), permission_actor_id=2)
|
|
workflow_file, workflow_path = HANDLER.RERUN_WORKFLOWS[0]
|
|
api.workflow_runs[workflow_file] = [workflow_run(workflow_path, head_repository_id=999, pull_requests=[])]
|
|
|
|
result = HANDLER.process_event(event(body="/rerun-failed-ci", actor_id=2), policy(), api)
|
|
|
|
assert result["decision"] == "ALLOW_RERUN_REQUESTED"
|
|
assert api.rerun_calls == [10]
|
|
assert api.list_pull_calls == [("fork-owner", HEAD_REF), ("fork-owner", HEAD_REF)]
|
|
|
|
|
|
@pytest.mark.parametrize("head_pulls", [[], [pull(head_repository_id=999)] * 2])
|
|
def test_fork_rerun_requires_one_unique_head_pull(head_pulls):
|
|
api = FakeAPI(pull(head_repository_id=999))
|
|
api.head_pulls = head_pulls
|
|
workflow_file, workflow_path = HANDLER.RERUN_WORKFLOWS[0]
|
|
api.workflow_runs[workflow_file] = [workflow_run(workflow_path, head_repository_id=999, pull_requests=[])]
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="exactly one pull request"):
|
|
HANDLER.process_event(event(body="/rerun-failed-ci"), policy(), api)
|
|
|
|
assert api.rerun_calls == []
|
|
|
|
|
|
def test_fork_rerun_rejects_mismatched_unique_head_pull():
|
|
api = FakeAPI(pull(head_repository_id=999))
|
|
api.head_pulls = [pull(head_repository_id=999, head_sha="b" * 40)]
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="identity does not match"):
|
|
HANDLER.process_event(event(body="/rerun-failed-ci"), policy(), api)
|
|
|
|
assert api.rerun_calls == []
|
|
|
|
|
|
def test_rerun_stops_if_pr_head_changes_before_post():
|
|
api = FakeAPI(pull())
|
|
workflow_file, workflow_path = HANDLER.RERUN_WORKFLOWS[0]
|
|
api.workflow_runs[workflow_file] = [workflow_run(workflow_path)]
|
|
pulls = iter([pull(), pull(head_sha="b" * 40)])
|
|
|
|
def get_pull(pull_number):
|
|
api.get_calls.append(pull_number)
|
|
return next(pulls)
|
|
|
|
api.get_pull = get_pull
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="head changed"):
|
|
HANDLER.process_event(event(body="/rerun-failed-ci"), policy(), api)
|
|
|
|
assert api.rerun_calls == []
|
|
|
|
|
|
def test_rerun_stops_if_latest_run_state_changes_before_post():
|
|
api = FakeAPI(pull())
|
|
workflow_file, workflow_path = HANDLER.RERUN_WORKFLOWS[0]
|
|
calls = 0
|
|
|
|
def list_workflow_runs(requested_workflow, head_sha):
|
|
nonlocal calls
|
|
assert head_sha == HEAD_SHA
|
|
if requested_workflow != workflow_file:
|
|
return []
|
|
calls += 1
|
|
if calls == 1:
|
|
return [workflow_run(workflow_path)]
|
|
return [workflow_run(workflow_path, status="queued", conclusion=None)]
|
|
|
|
api.list_workflow_runs = list_workflow_runs
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="state changed"):
|
|
HANDLER.process_event(event(body="/rerun-failed-ci"), policy(), api)
|
|
|
|
assert api.rerun_calls == []
|
|
|
|
|
|
def test_rerun_stops_after_partial_failure_without_retry_or_rollback():
|
|
api = FakeAPI(pull())
|
|
for index, (workflow_file, workflow_path) in enumerate(HANDLER.RERUN_WORKFLOWS[:2]):
|
|
api.workflow_runs[workflow_file] = [workflow_run(workflow_path, run_id=(index + 1) * 10)]
|
|
|
|
def rerun_failed_jobs(run_id):
|
|
api.rerun_calls.append(run_id)
|
|
if run_id == 20:
|
|
raise HANDLER.CommentCommandError("GitHub API request timed out")
|
|
|
|
api.rerun_failed_jobs = rerun_failed_jobs
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="workflow run 20"):
|
|
HANDLER.process_event(event(body="/rerun-failed-ci"), policy(), api)
|
|
|
|
assert api.rerun_calls == [10, 20]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("bad_event", "message"),
|
|
[
|
|
({**event(), "sender": {"id": 2}}, "sender does not match"),
|
|
(
|
|
{
|
|
**event(),
|
|
"comment": {**event()["comment"], "user": {"id": ACTOR_ID, "type": "Bot"}},
|
|
},
|
|
"human GitHub user",
|
|
),
|
|
(
|
|
{
|
|
**event(),
|
|
"comment": {**event()["comment"], "user": {"id": ACTOR_ID, "type": "User"}},
|
|
},
|
|
"login is missing",
|
|
),
|
|
({**event(), "repository": {"id": 1, "full_name": "attacker/repo"}}, "event repository"),
|
|
],
|
|
)
|
|
def test_untrusted_event_identity_fails_before_api_access(bad_event, message):
|
|
api = FakeAPI(pull())
|
|
with pytest.raises(HANDLER.CommentCommandError, match=message):
|
|
HANDLER.process_event(bad_event, policy(), api)
|
|
assert api.calls == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("permission_result", "message"),
|
|
[
|
|
([], "invalid repository permission"),
|
|
({"permission": "write"}, "invalid repository permission identity"),
|
|
({"permission": "write", "user": {"id": True}}, "invalid repository permission identity"),
|
|
({"permission": "write", "user": {"id": 2}}, "identity does not match"),
|
|
({"permission": None, "user": {"id": ACTOR_ID}}, "invalid repository permission"),
|
|
],
|
|
)
|
|
def test_invalid_permission_response_fails_before_mutation(permission_result, message):
|
|
api = FakeAPI(pull())
|
|
api.permission = permission_result
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match=message):
|
|
HANDLER.process_event(event(), policy(), api)
|
|
|
|
assert api.calls == [("get_pull", 123), ("get_permission", "actor")]
|
|
assert api.add_calls == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("bad_pull", "message"),
|
|
[
|
|
(pull(state="closed"), "not open"),
|
|
({**pull(), "head": {"repo": None}}, "head repository is missing"),
|
|
({**pull(), "base": {"repo": {"id": 1, "full_name": "attacker/repo"}}}, "base repository"),
|
|
({**pull(), "labels": None}, "labels are invalid"),
|
|
],
|
|
)
|
|
def test_unverifiable_live_pull_fails_before_mutation(bad_pull, message):
|
|
api = FakeAPI(bad_pull)
|
|
with pytest.raises(HANDLER.CommentCommandError, match=message):
|
|
HANDLER.process_event(event(), policy(), api)
|
|
assert api.calls == [("get_pull", 123)]
|
|
|
|
|
|
def test_github_api_uses_only_fixed_repository_and_additive_endpoint(monkeypatch):
|
|
requests = []
|
|
|
|
class Response:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def read(self):
|
|
return b'[{"name":"run-ci-short"}]'
|
|
|
|
def urlopen(request, *, timeout):
|
|
requests.append((request, timeout))
|
|
return Response()
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
api = HANDLER.GitHubAPI("secret-token")
|
|
api.add_label(123, "run-ci-short")
|
|
|
|
request, timeout = requests[0]
|
|
assert request.full_url == "https://api.github.com/repos/radixark/miles/issues/123/labels"
|
|
assert request.method == "POST"
|
|
assert json.loads(request.data) == {"labels": ["run-ci-short"]}
|
|
assert request.headers["Authorization"] == "Bearer secret-token"
|
|
assert timeout == 15
|
|
|
|
|
|
def test_remove_label_uses_encoded_name_in_a_fixed_repository_path(monkeypatch):
|
|
requests = []
|
|
|
|
class Response:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def read(self):
|
|
return b"[]"
|
|
|
|
def urlopen(request, *, timeout):
|
|
requests.append((request, timeout))
|
|
return Response()
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
HANDLER.GitHubAPI("secret-token").remove_label(123, "run-ci-a/b ?#%")
|
|
|
|
request, timeout = requests[0]
|
|
assert request.full_url == (
|
|
"https://api.github.com/repos/radixark/miles/issues/123/labels/" "run-ci-a%2Fb%20%3F%23%25"
|
|
)
|
|
assert request.method == "DELETE"
|
|
assert request.data is None
|
|
assert timeout == 15
|
|
|
|
|
|
def test_list_workflow_runs_uses_fixed_filters_and_complete_pagination(monkeypatch):
|
|
requests = []
|
|
|
|
class Response:
|
|
def __init__(self, payload):
|
|
self.payload = payload
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def read(self):
|
|
return json.dumps(self.payload).encode()
|
|
|
|
def urlopen(request, *, timeout):
|
|
requests.append((request, timeout))
|
|
page = urllib.parse.parse_qs(urllib.parse.urlparse(request.full_url).query)["page"][0]
|
|
if page == "1":
|
|
return Response({"total_count": 101, "workflow_runs": list(range(100))})
|
|
return Response({"total_count": 101, "workflow_runs": [100]})
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
runs = HANDLER.GitHubAPI("secret-token").list_workflow_runs("pr-test.yml", HEAD_SHA)
|
|
|
|
assert runs == list(range(101))
|
|
assert len(requests) == 2
|
|
for index, (request, timeout) in enumerate(requests, start=1):
|
|
parsed = urllib.parse.urlparse(request.full_url)
|
|
assert parsed.path == "/repos/radixark/miles/actions/workflows/pr-test.yml/runs"
|
|
assert urllib.parse.parse_qs(parsed.query) == {
|
|
"event": ["pull_request"],
|
|
"head_sha": [HEAD_SHA],
|
|
"page": [str(index)],
|
|
"per_page": ["100"],
|
|
}
|
|
assert request.method == "GET"
|
|
assert timeout == 15
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("pages", "message"),
|
|
[
|
|
([{"total_count": True, "workflow_runs": []}], "invalid workflow-run count"),
|
|
([{"total_count": 1001, "workflow_runs": []}], "invalid workflow-run count"),
|
|
([{"total_count": 1, "workflow_runs": []}], "incomplete workflow-run listing"),
|
|
(
|
|
[
|
|
{"total_count": 101, "workflow_runs": list(range(100))},
|
|
{"total_count": 102, "workflow_runs": [100]},
|
|
],
|
|
"count changed",
|
|
),
|
|
],
|
|
)
|
|
def test_list_workflow_runs_fails_closed_on_invalid_pagination(monkeypatch, pages, message):
|
|
class Response:
|
|
def __init__(self, payload):
|
|
self.payload = payload
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def read(self):
|
|
return json.dumps(self.payload).encode()
|
|
|
|
page_iterator = iter(pages)
|
|
monkeypatch.setattr(
|
|
HANDLER.urllib.request,
|
|
"urlopen",
|
|
lambda _request, timeout: Response(next(page_iterator)),
|
|
)
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match=message):
|
|
HANDLER.GitHubAPI("secret-token").list_workflow_runs("pr-test.yml", HEAD_SHA)
|
|
|
|
|
|
def test_list_pulls_for_head_uses_encoded_all_state_query(monkeypatch):
|
|
requests = []
|
|
|
|
class Response:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def read(self):
|
|
return b"[]"
|
|
|
|
def urlopen(request, *, timeout):
|
|
requests.append((request, timeout))
|
|
return Response()
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
assert HANDLER.GitHubAPI("secret-token").list_pulls_for_head("fork-owner", "feature/a b") == []
|
|
|
|
request, timeout = requests[0]
|
|
parsed = urllib.parse.urlparse(request.full_url)
|
|
assert parsed.path == "/repos/radixark/miles/pulls"
|
|
assert urllib.parse.parse_qs(parsed.query) == {
|
|
"head": ["fork-owner:feature/a b"],
|
|
"page": ["1"],
|
|
"per_page": ["100"],
|
|
"state": ["all"],
|
|
}
|
|
assert request.method == "GET"
|
|
assert timeout == 15
|
|
|
|
|
|
def test_rerun_failed_jobs_uses_exact_endpoint_and_accepts_empty_201(monkeypatch):
|
|
requests = []
|
|
|
|
class Response:
|
|
status = 201
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def read(self):
|
|
return b""
|
|
|
|
def urlopen(request, *, timeout):
|
|
requests.append((request, timeout))
|
|
return Response()
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
HANDLER.GitHubAPI("secret-token").rerun_failed_jobs(987)
|
|
|
|
request, timeout = requests[0]
|
|
assert request.full_url == ("https://api.github.com/repos/radixark/miles/actions/runs/987/rerun-failed-jobs")
|
|
assert request.method == "POST"
|
|
assert request.data is None
|
|
assert timeout == 15
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "body", "message"),
|
|
[(202, b"", "expected 201"), (201, b"{}", "unexpected response body")],
|
|
)
|
|
def test_rerun_failed_jobs_rejects_unconfirmed_response_without_retry(monkeypatch, status, body, message):
|
|
attempts = 0
|
|
|
|
class Response:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def read(self):
|
|
return body
|
|
|
|
response = Response()
|
|
response.status = status
|
|
|
|
def urlopen(_request, *, timeout):
|
|
nonlocal attempts
|
|
attempts += 1
|
|
assert timeout == 15
|
|
return response
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
with pytest.raises(HANDLER.CommentCommandError, match=message):
|
|
HANDLER.GitHubAPI("secret-token").rerun_failed_jobs(987)
|
|
assert attempts == 1
|
|
|
|
|
|
def test_permission_lookup_encodes_the_login_in_a_fixed_repository_path(monkeypatch):
|
|
requests = []
|
|
|
|
class Response:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def read(self):
|
|
return b'{"permission":"write","user":{"id":1234}}'
|
|
|
|
def urlopen(request, *, timeout):
|
|
requests.append((request, timeout))
|
|
return Response()
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
HANDLER.GitHubAPI("secret-token").get_permission("actor/../../attacker")
|
|
|
|
request, timeout = requests[0]
|
|
assert request.full_url == (
|
|
"https://api.github.com/repos/radixark/miles/collaborators/actor%2F..%2F..%2Fattacker/permission"
|
|
)
|
|
assert request.method == "GET"
|
|
assert timeout == 15
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("exception", "message"),
|
|
[
|
|
(urllib.error.HTTPError("url", 403, "forbidden", {}, None), "HTTP 403"),
|
|
(urllib.error.HTTPError("url", 404, "missing", {}, None), "HTTP 404"),
|
|
(TimeoutError(), "timed out"),
|
|
],
|
|
)
|
|
def test_permission_api_failure_is_not_retried(monkeypatch, exception, message):
|
|
attempts = []
|
|
|
|
def urlopen(_request, *, timeout):
|
|
attempts.append(timeout)
|
|
raise exception
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
with pytest.raises(HANDLER.CommentCommandError, match=message):
|
|
HANDLER.GitHubAPI("secret-token").get_permission("actor")
|
|
assert attempts == [15]
|
|
|
|
|
|
def test_permission_api_invalid_json_is_not_retried(monkeypatch):
|
|
attempts = 0
|
|
|
|
class Response:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def read(self):
|
|
return b"not-json"
|
|
|
|
def urlopen(_request, *, timeout):
|
|
nonlocal attempts
|
|
attempts += 1
|
|
assert timeout == 15
|
|
return Response()
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
with pytest.raises(HANDLER.CommentCommandError, match="invalid JSON"):
|
|
HANDLER.GitHubAPI("secret-token").get_permission("actor")
|
|
assert attempts == 1
|
|
|
|
|
|
@pytest.mark.parametrize("operation", ["add", "remove"])
|
|
@pytest.mark.parametrize(
|
|
("exception", "message"),
|
|
[
|
|
(urllib.error.HTTPError("url", 403, "forbidden", {}, None), "HTTP 403"),
|
|
(TimeoutError(), "timed out"),
|
|
],
|
|
)
|
|
def test_label_mutation_api_failure_is_not_retried(monkeypatch, operation, exception, message):
|
|
attempts = []
|
|
|
|
def urlopen(_request, *, timeout):
|
|
attempts.append(timeout)
|
|
raise exception
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
with pytest.raises(HANDLER.CommentCommandError, match=message):
|
|
api = HANDLER.GitHubAPI("secret-token")
|
|
if operation == "add":
|
|
api.add_label(123, "run-ci-short")
|
|
else:
|
|
api.remove_label(123, "run-ci-short")
|
|
assert attempts == [15]
|
|
|
|
|
|
def test_http_error_names_the_request_and_accepted_permissions(monkeypatch):
|
|
def urlopen(_request, *, timeout):
|
|
raise urllib.error.HTTPError(
|
|
"url", 403, "forbidden", {"X-Accepted-GitHub-Permissions": "pull_requests=write"}, None
|
|
)
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
with pytest.raises(HANDLER.CommentCommandError) as excinfo:
|
|
HANDLER.GitHubAPI("secret-token").add_label(123, "run-ci-short")
|
|
message = str(excinfo.value)
|
|
assert message == (
|
|
"GitHub API returned HTTP 403 for POST /repos/radixark/miles/issues/123/labels; "
|
|
"accepted permissions: pull_requests=write"
|
|
)
|
|
assert "secret-token" not in message
|
|
|
|
|
|
def test_http_error_without_accepted_permissions_still_names_the_request(monkeypatch):
|
|
def urlopen(_request, *, timeout):
|
|
raise urllib.error.HTTPError("url", 404, "missing", {}, None)
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
with pytest.raises(HANDLER.CommentCommandError) as excinfo:
|
|
HANDLER.GitHubAPI("secret-token").get_permission("actor")
|
|
assert str(excinfo.value) == (
|
|
"GitHub API returned HTTP 404 for GET /repos/radixark/miles/collaborators/actor/permission"
|
|
)
|
|
|
|
|
|
def test_unconfirmed_mutation_response_fails_without_rollback():
|
|
api = FakeAPI(pull())
|
|
api.add_label = lambda _pull_number, _label: []
|
|
with pytest.raises(HANDLER.CommentCommandError, match="did not confirm"):
|
|
HANDLER.process_event(event(), policy(), api)
|
|
|
|
|
|
def test_unconfirmed_label_removal_fails_without_rollback():
|
|
api = FakeAPI(pull(labels=("run-ci-short", "documentation")))
|
|
api.remove_label = lambda _pull_number, _label: [
|
|
{"name": "run-ci-short"},
|
|
{"name": "documentation"},
|
|
]
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="did not confirm removal"):
|
|
HANDLER.process_event(event(body="/clear-labels"), policy(), api)
|
|
|
|
|
|
def test_clear_stops_after_partial_failure_without_retry_or_rollback():
|
|
api = FakeAPI(pull(labels=("run-ci-a", "run-ci-b", "documentation")))
|
|
|
|
def remove_label(pull_number, label):
|
|
api.calls.append(("remove_label", pull_number, label))
|
|
api.remove_calls.append((pull_number, label))
|
|
if label == "run-ci-b":
|
|
raise HANDLER.CommentCommandError("GitHub API request timed out")
|
|
api.pull["labels"] = [item for item in api.pull["labels"] if item["name"] != label]
|
|
return api.pull["labels"]
|
|
|
|
api.remove_label = remove_label
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="could not remove CI label run-ci-b"):
|
|
HANDLER.process_event(event(body="/clear-labels"), policy(), api)
|
|
|
|
assert api.remove_calls == [(123, "run-ci-a"), (123, "run-ci-b")]
|
|
assert api.pull["labels"] == [{"name": "run-ci-b"}, {"name": "documentation"}]
|
|
|
|
|
|
def test_clear_rejects_a_final_response_with_a_new_ci_control_label():
|
|
api = FakeAPI(pull(labels=("run-ci-short",)))
|
|
api.remove_label = lambda _pull_number, _label: [{"name": "nightly"}]
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="all CI labels were removed"):
|
|
HANDLER.process_event(event(body="/clear-labels"), policy(), api)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("body", "capability", "success_reaction"),
|
|
[
|
|
("/run-ci-short", "issues", "none"),
|
|
("/bypass-fastfail", "issues", "none"),
|
|
("/clear-labels", "issues", "none"),
|
|
("/rerun-failed-ci", "actions", "none"),
|
|
(RUN_FILE_BODY, "actions", "+1"),
|
|
],
|
|
)
|
|
def test_preflight_writes_only_fixed_routing(monkeypatch, tmp_path, body, capability, success_reaction):
|
|
api = FakeAPI(pull())
|
|
output_path = tmp_path / "github-output"
|
|
monkeypatch.setattr(HANDLER, "load_json", lambda _path: event(body=body))
|
|
monkeypatch.setattr(HANDLER, "load_policy", lambda _path: policy())
|
|
monkeypatch.setattr(HANDLER, "GitHubAPI", lambda _token: api)
|
|
monkeypatch.setenv("GITHUB_EVENT_PATH", "event.json")
|
|
monkeypatch.setenv("CI_COMMAND_POLICY_PATH", "policy.json")
|
|
monkeypatch.setenv("CI_COMMAND_API_TOKEN", "token")
|
|
monkeypatch.setenv("CI_COMMAND_PREFLIGHT", "true")
|
|
monkeypatch.setenv("GITHUB_OUTPUT", str(output_path))
|
|
|
|
assert HANDLER.main() == 0
|
|
assert output_path.read_text() == (f"capability={capability}\nsuccess_reaction={success_reaction}\n")
|
|
assert api.calls == [("get_permission", "actor")]
|
|
|
|
|
|
def test_preflight_rejects_an_unknown_registry_capability(monkeypatch, tmp_path):
|
|
api = FakeAPI(pull())
|
|
output_path = tmp_path / "github-output"
|
|
spec = HANDLER.COMMAND_REGISTRY[HANDLER.AddLabel]
|
|
monkeypatch.setitem(HANDLER.COMMAND_REGISTRY, HANDLER.AddLabel, spec._replace(capability="unknown"))
|
|
monkeypatch.setattr(HANDLER, "load_json", lambda _path: event())
|
|
monkeypatch.setattr(HANDLER, "load_policy", lambda _path: policy())
|
|
monkeypatch.setattr(HANDLER, "GitHubAPI", lambda _token: api)
|
|
monkeypatch.setenv("GITHUB_EVENT_PATH", "event.json")
|
|
monkeypatch.setenv("CI_COMMAND_POLICY_PATH", "policy.json")
|
|
monkeypatch.setenv("CI_COMMAND_API_TOKEN", "token")
|
|
monkeypatch.setenv("CI_COMMAND_PREFLIGHT", "true")
|
|
monkeypatch.setenv("GITHUB_OUTPUT", str(output_path))
|
|
|
|
assert HANDLER.main() == 1
|
|
assert not output_path.exists()
|
|
|
|
|
|
def test_preflight_rejects_an_unknown_registry_success_reaction(monkeypatch, tmp_path):
|
|
api = FakeAPI(pull())
|
|
output_path = tmp_path / "github-output"
|
|
spec = HANDLER.COMMAND_REGISTRY[HANDLER.RunTestFile]
|
|
monkeypatch.setitem(
|
|
HANDLER.COMMAND_REGISTRY,
|
|
HANDLER.RunTestFile,
|
|
spec._replace(success_reaction="heart"),
|
|
)
|
|
monkeypatch.setattr(HANDLER, "load_json", lambda _path: event(body=RUN_FILE_BODY))
|
|
monkeypatch.setattr(HANDLER, "load_policy", lambda _path: policy())
|
|
monkeypatch.setattr(HANDLER, "GitHubAPI", lambda _token: api)
|
|
monkeypatch.setenv("GITHUB_EVENT_PATH", "event.json")
|
|
monkeypatch.setenv("CI_COMMAND_POLICY_PATH", "policy.json")
|
|
monkeypatch.setenv("CI_COMMAND_API_TOKEN", "token")
|
|
monkeypatch.setenv("CI_COMMAND_PREFLIGHT", "true")
|
|
monkeypatch.setenv("GITHUB_OUTPUT", str(output_path))
|
|
|
|
assert HANDLER.main() == 1
|
|
assert not output_path.exists()
|
|
|
|
|
|
def test_unrelated_comment_preflight_uses_no_policy_or_api(monkeypatch, tmp_path):
|
|
output_path = tmp_path / "github-output"
|
|
monkeypatch.setattr(HANDLER, "load_json", lambda _path: event(body="ordinary review comment"))
|
|
monkeypatch.setattr(HANDLER, "load_policy", lambda _path: pytest.fail("policy must not be loaded"))
|
|
monkeypatch.setattr(HANDLER, "GitHubAPI", lambda _token: pytest.fail("API must not be initialized"))
|
|
monkeypatch.setenv("GITHUB_EVENT_PATH", "event.json")
|
|
monkeypatch.setenv("CI_COMMAND_PREFLIGHT", "true")
|
|
monkeypatch.setenv("GITHUB_OUTPUT", str(output_path))
|
|
|
|
assert HANDLER.main() == 0
|
|
assert output_path.read_text() == "capability=none\nsuccess_reaction=none\n"
|
|
|
|
|
|
@pytest.mark.parametrize("permission", ["write", "admin"])
|
|
def test_repository_writer_dispatches_a_file_run(permission):
|
|
api = FakeAPI(pull(), permission=permission)
|
|
|
|
result = HANDLER.process_event(event(body=RUN_FILE_BODY), policy(), api)
|
|
|
|
assert api.dispatch_calls == [
|
|
(
|
|
"run-ci-file.yml",
|
|
"main",
|
|
{"pull_number": "123", "head_sha": HEAD_SHA, "test_file": RUN_FILE_PATH},
|
|
)
|
|
]
|
|
assert result == {
|
|
"actor_id": ACTOR_ID,
|
|
"decision": "ALLOW_FILE_RUN_DISPATCHED",
|
|
"head_sha": HEAD_SHA,
|
|
"pull_number": 123,
|
|
"test_file": RUN_FILE_PATH,
|
|
"workflow_run_url": WORKFLOW_RUN_URL,
|
|
}
|
|
|
|
|
|
def test_file_run_main_reports_the_dispatched_run_without_writing_an_output(monkeypatch, tmp_path, capsys):
|
|
api = FakeAPI(pull())
|
|
output_path = tmp_path / "github-output"
|
|
monkeypatch.setattr(HANDLER, "load_json", lambda _path: event(body=RUN_FILE_BODY))
|
|
monkeypatch.setattr(HANDLER, "load_policy", lambda _path: policy())
|
|
monkeypatch.setattr(HANDLER, "GitHubAPI", lambda _token: api)
|
|
monkeypatch.setenv("GITHUB_EVENT_PATH", "event.json")
|
|
monkeypatch.setenv("CI_COMMAND_POLICY_PATH", "policy.json")
|
|
monkeypatch.setenv("CI_COMMAND_API_TOKEN", "actions-token")
|
|
monkeypatch.setenv("GITHUB_OUTPUT", str(output_path))
|
|
|
|
assert HANDLER.main() == 0
|
|
# The dispatched run URL stays in the audit record; the dispatched run now
|
|
# owns the pull-request status comment, so no job output carries it.
|
|
assert json.loads(capsys.readouterr().out)["workflow_run_url"] == WORKFLOW_RUN_URL
|
|
assert not output_path.exists()
|
|
|
|
|
|
def test_file_run_forwards_validated_pr_body_pins():
|
|
target = pull()
|
|
target["body"] = "Summary line\nci-image-tag: pr-42\nci-megatron-pr: #77\nci-sglang-pr: feature/pin-x\n"
|
|
api = FakeAPI(target)
|
|
|
|
HANDLER.process_event(event(body=RUN_FILE_BODY), policy(), api)
|
|
|
|
(_, _, inputs) = api.dispatch_calls[0]
|
|
assert inputs == {
|
|
"pull_number": "123",
|
|
"head_sha": HEAD_SHA,
|
|
"test_file": RUN_FILE_PATH,
|
|
"ci_image_tag": "pr-42",
|
|
"ci_megatron_pr": "#77",
|
|
"ci_sglang_pr": "feature/pin-x",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"line",
|
|
["ci-image-tag: -bad", "ci-megatron-pr: $(reboot)", "ci-sglang-pr: bad;ref"],
|
|
)
|
|
def test_file_run_rejects_an_invalid_pr_body_pin(line):
|
|
target = pull()
|
|
target["body"] = f"{line}\n"
|
|
api = FakeAPI(target)
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="unsupported value"):
|
|
HANDLER.process_event(event(body=RUN_FILE_BODY), policy(), api)
|
|
assert api.dispatch_calls == []
|
|
|
|
|
|
@pytest.mark.parametrize("permission", ["read", "triage", "none"])
|
|
def test_fork_file_run_without_contributor_tier_or_write_is_denied(permission):
|
|
api = FakeAPI(pull(head_repository_id=HANDLER.REPOSITORY_ID + 1), permission=permission)
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="not authorized"):
|
|
HANDLER.process_event(event(body=RUN_FILE_BODY), policy(), api)
|
|
assert api.dispatch_calls == []
|
|
|
|
|
|
@pytest.mark.parametrize("permission", ["write", "admin"])
|
|
def test_repository_writer_dispatches_a_fork_file_run(permission):
|
|
api = FakeAPI(pull(head_repository_id=HANDLER.REPOSITORY_ID + 1), permission=permission)
|
|
|
|
result = HANDLER.process_event(event(body=RUN_FILE_BODY), policy(), api)
|
|
|
|
assert api.list_pull_calls == [("fork-owner", HEAD_REF)]
|
|
assert api.dispatch_calls == [
|
|
(
|
|
"run-ci-file.yml",
|
|
"main",
|
|
{"pull_number": "123", "head_sha": HEAD_SHA, "test_file": RUN_FILE_PATH},
|
|
)
|
|
]
|
|
assert result["decision"] == "ALLOW_FILE_RUN_DISPATCHED"
|
|
|
|
|
|
@pytest.mark.parametrize("author_association", ["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR"])
|
|
def test_prior_contributor_dispatches_a_fork_file_run_without_a_permission_lookup(author_association):
|
|
api = FakeAPI(pull(head_repository_id=HANDLER.REPOSITORY_ID + 1), permission="none")
|
|
|
|
result = HANDLER.process_event(event(body=RUN_FILE_BODY, author_association=author_association), policy(), api)
|
|
|
|
assert api.permission_calls == []
|
|
assert api.list_pull_calls == [("fork-owner", HEAD_REF)]
|
|
assert result["decision"] == "ALLOW_FILE_RUN_DISPATCHED"
|
|
|
|
|
|
@pytest.mark.parametrize("author_association", ["FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER", "NONE", "MANNEQUIN"])
|
|
def test_first_time_contributor_cannot_dispatch_a_fork_file_run(author_association):
|
|
api = FakeAPI(pull(head_repository_id=HANDLER.REPOSITORY_ID + 1), permission="read")
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="not authorized"):
|
|
HANDLER.process_event(event(body=RUN_FILE_BODY, author_association=author_association), policy(), api)
|
|
assert api.dispatch_calls == []
|
|
|
|
|
|
def test_fork_file_run_requires_one_unique_head_pull():
|
|
api = FakeAPI(pull(head_repository_id=HANDLER.REPOSITORY_ID + 1))
|
|
api.head_pulls = []
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="exactly one pull request"):
|
|
HANDLER.process_event(event(body=RUN_FILE_BODY), policy(), api)
|
|
assert api.dispatch_calls == []
|
|
|
|
|
|
@pytest.mark.parametrize("permission", ["read", "triage", "none"])
|
|
def test_file_run_requires_live_write_permission(permission):
|
|
api = FakeAPI(pull(), permission=permission)
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="not authorized"):
|
|
HANDLER.process_event(event(body=RUN_FILE_BODY), policy(), api)
|
|
assert api.dispatch_calls == []
|
|
|
|
|
|
def test_add_label_access_user_id_cannot_dispatch_a_file_run():
|
|
api = FakeAPI(pull(), permission="read")
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="not authorized"):
|
|
HANDLER.process_event(event(body=RUN_FILE_BODY), policy(user_ids=(ACTOR_ID,)), api)
|
|
assert api.dispatch_calls == []
|
|
|
|
|
|
@pytest.mark.parametrize("author_association", ["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR"])
|
|
def test_prior_contributor_dispatches_a_file_run_without_a_permission_lookup(author_association):
|
|
api = FakeAPI(pull(), permission="none")
|
|
|
|
result = HANDLER.process_event(
|
|
event(body=RUN_FILE_BODY, author_association=author_association),
|
|
policy(),
|
|
api,
|
|
)
|
|
|
|
assert api.permission_calls == []
|
|
assert api.dispatch_calls == [
|
|
(
|
|
"run-ci-file.yml",
|
|
"main",
|
|
{"pull_number": "123", "head_sha": HEAD_SHA, "test_file": RUN_FILE_PATH},
|
|
)
|
|
]
|
|
assert result["decision"] == "ALLOW_FILE_RUN_DISPATCHED"
|
|
|
|
|
|
@pytest.mark.parametrize("author_association", ["FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER", "NONE", "MANNEQUIN"])
|
|
def test_first_time_contributor_cannot_dispatch_a_file_run(author_association):
|
|
api = FakeAPI(pull(), permission="read")
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="not authorized"):
|
|
HANDLER.process_event(
|
|
event(body=RUN_FILE_BODY, author_association=author_association),
|
|
policy(),
|
|
api,
|
|
)
|
|
assert api.dispatch_calls == []
|
|
|
|
|
|
def test_prior_contributor_access_user_id_dispatches_a_file_run():
|
|
api = FakeAPI(pull(), permission="none")
|
|
|
|
result = HANDLER.process_event(
|
|
event(body=RUN_FILE_BODY, author_association="FIRST_TIMER"),
|
|
policy(prior_user_ids=(ACTOR_ID,)),
|
|
api,
|
|
)
|
|
|
|
assert api.permission_calls == []
|
|
assert api.dispatch_calls == [
|
|
(
|
|
"run-ci-file.yml",
|
|
"main",
|
|
{"pull_number": "123", "head_sha": HEAD_SHA, "test_file": RUN_FILE_PATH},
|
|
)
|
|
]
|
|
assert result["decision"] == "ALLOW_FILE_RUN_DISPATCHED"
|
|
|
|
|
|
@pytest.mark.parametrize("body", ["/run-ci-short", "/clear-labels"])
|
|
def test_contributor_association_grants_no_label_command(body):
|
|
api = FakeAPI(pull(labels=("run-ci-short",)), permission="read")
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="not authorized"):
|
|
HANDLER.process_event(event(body=body, author_association="CONTRIBUTOR"), policy(), api)
|
|
|
|
assert api.add_calls == []
|
|
assert api.remove_calls == []
|
|
assert api.rerun_calls == []
|
|
assert api.dispatch_calls == []
|
|
|
|
|
|
@pytest.mark.parametrize("body", ["/run-ci-short", "/clear-labels"])
|
|
def test_prior_contributor_access_user_id_grants_no_label_command(body):
|
|
api = FakeAPI(pull(labels=("run-ci-short",)), permission="none")
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="not authorized"):
|
|
HANDLER.process_event(
|
|
event(body=body, author_association="FIRST_TIMER"),
|
|
policy(prior_user_ids=(ACTOR_ID,)),
|
|
api,
|
|
)
|
|
|
|
assert api.add_calls == []
|
|
assert api.remove_calls == []
|
|
assert api.rerun_calls == []
|
|
assert api.dispatch_calls == []
|
|
|
|
|
|
def test_contributor_reruns_failed_ci_without_a_permission_lookup():
|
|
api = FakeAPI(pull(), permission="none")
|
|
workflow_file, workflow_path = HANDLER.RERUN_WORKFLOWS[0]
|
|
api.workflow_runs[workflow_file] = [workflow_run(workflow_path)]
|
|
|
|
result = HANDLER.process_event(event(body="/rerun-failed-ci", author_association="CONTRIBUTOR"), policy(), api)
|
|
|
|
assert api.permission_calls == []
|
|
assert api.rerun_calls == [10]
|
|
assert result["decision"] == "ALLOW_RERUN_REQUESTED"
|
|
|
|
|
|
def test_prior_contributor_access_user_id_reruns_failed_ci():
|
|
api = FakeAPI(pull(), permission="none")
|
|
workflow_file, workflow_path = HANDLER.RERUN_WORKFLOWS[0]
|
|
api.workflow_runs[workflow_file] = [workflow_run(workflow_path)]
|
|
|
|
result = HANDLER.process_event(
|
|
event(body="/rerun-failed-ci", author_association="FIRST_TIMER"),
|
|
policy(prior_user_ids=(ACTOR_ID,)),
|
|
api,
|
|
)
|
|
|
|
assert api.permission_calls == []
|
|
assert api.rerun_calls == [10]
|
|
assert result["decision"] == "ALLOW_RERUN_REQUESTED"
|
|
|
|
|
|
@pytest.mark.parametrize("author_association", [None, True, "", "collaborator", "EVERYONE"])
|
|
def test_event_parser_rejects_an_invalid_author_association(author_association):
|
|
invalid = event(body=RUN_FILE_BODY)
|
|
if author_association is None:
|
|
del invalid["comment"]["author_association"]
|
|
else:
|
|
invalid["comment"]["author_association"] = author_association
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="author association is invalid"):
|
|
HANDLER.parse_event(invalid)
|
|
|
|
|
|
def test_create_workflow_dispatch_requests_and_returns_exact_run_details(monkeypatch):
|
|
requests = []
|
|
|
|
class Response:
|
|
status = 200
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def read(self):
|
|
return json.dumps(
|
|
{
|
|
"workflow_run_id": WORKFLOW_RUN_ID,
|
|
"run_url": WORKFLOW_RUN_API_URL,
|
|
"html_url": WORKFLOW_RUN_URL,
|
|
}
|
|
).encode("utf-8")
|
|
|
|
def urlopen(request, *, timeout):
|
|
requests.append((request, timeout))
|
|
return Response()
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
result = HANDLER.GitHubAPI("secret-token").create_workflow_dispatch(
|
|
"run-ci-file.yml", "feature/test", {"test_file": "tests/e2e/test_a.py"}
|
|
)
|
|
|
|
request, timeout = requests[0]
|
|
assert request.full_url == (
|
|
"https://api.github.com/repos/radixark/miles/actions/workflows/run-ci-file.yml/dispatches"
|
|
)
|
|
assert request.method == "POST"
|
|
assert json.loads(request.data.decode("utf-8")) == {
|
|
"ref": "feature/test",
|
|
"inputs": {"test_file": "tests/e2e/test_a.py"},
|
|
"return_run_details": True,
|
|
}
|
|
assert timeout == 15
|
|
assert result == WORKFLOW_RUN_URL
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "body", "message"),
|
|
[(204, b"", "expected 200"), (200, b"", "invalid JSON")],
|
|
)
|
|
def test_create_workflow_dispatch_rejects_unconfirmed_response(monkeypatch, status, body, message):
|
|
attempts = 0
|
|
|
|
class Response:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def read(self):
|
|
return body
|
|
|
|
response = Response()
|
|
response.status = status
|
|
|
|
def urlopen(_request, *, timeout):
|
|
nonlocal attempts
|
|
attempts += 1
|
|
assert timeout == 15
|
|
return response
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
with pytest.raises(HANDLER.CommentCommandError, match=message):
|
|
HANDLER.GitHubAPI("secret-token").create_workflow_dispatch("run-ci-file.yml", "feature/test", {})
|
|
assert attempts == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("details", "message"),
|
|
[
|
|
({}, "workflow run ID must be a positive integer"),
|
|
(
|
|
{
|
|
"workflow_run_id": WORKFLOW_RUN_ID,
|
|
"run_url": "https://api.github.com/repos/radixark/miles/actions/runs/1",
|
|
"html_url": WORKFLOW_RUN_URL,
|
|
},
|
|
"mismatched workflow dispatch details",
|
|
),
|
|
(
|
|
{
|
|
"workflow_run_id": WORKFLOW_RUN_ID,
|
|
"run_url": WORKFLOW_RUN_API_URL,
|
|
"html_url": "https://example.invalid/actions/runs/987654321",
|
|
},
|
|
"workflow run URL is invalid",
|
|
),
|
|
],
|
|
)
|
|
def test_create_workflow_dispatch_rejects_invalid_run_details(monkeypatch, details, message):
|
|
class Response:
|
|
status = 200
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def read(self):
|
|
return json.dumps(details).encode("utf-8")
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", lambda _request, timeout: Response())
|
|
with pytest.raises(HANDLER.CommentCommandError, match=message):
|
|
HANDLER.GitHubAPI("secret-token").create_workflow_dispatch("run-ci-file.yml", "main", {})
|
|
|
|
|
|
def test_create_issue_comment_uses_exact_endpoint_and_confirms_body(monkeypatch):
|
|
requests = []
|
|
|
|
class Response:
|
|
status = 201
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def read(self):
|
|
return json.dumps({"body": ANNOUNCE_BODY, "id": NEW_COMMENT_ID}).encode("utf-8")
|
|
|
|
def urlopen(request, *, timeout):
|
|
requests.append((request, timeout))
|
|
return Response()
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
assert HANDLER.GitHubAPI("secret-token").create_issue_comment(123, ANNOUNCE_BODY) == NEW_COMMENT_ID
|
|
|
|
request, timeout = requests[0]
|
|
assert request.full_url == "https://api.github.com/repos/radixark/miles/issues/123/comments"
|
|
assert request.method == "POST"
|
|
assert json.loads(request.data.decode("utf-8")) == {"body": ANNOUNCE_BODY}
|
|
assert timeout == 15
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "body", "message"),
|
|
[
|
|
(200, {"body": ANNOUNCE_BODY, "id": NEW_COMMENT_ID}, "expected 201"),
|
|
(201, {"body": "wrong", "id": NEW_COMMENT_ID}, "did not confirm"),
|
|
(201, {"body": ANNOUNCE_BODY}, "comment ID"),
|
|
],
|
|
)
|
|
def test_create_issue_comment_rejects_unconfirmed_response_without_retry(
|
|
monkeypatch,
|
|
status,
|
|
body,
|
|
message,
|
|
):
|
|
attempts = 0
|
|
|
|
class Response:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def read(self):
|
|
return json.dumps(body).encode("utf-8")
|
|
|
|
response = Response()
|
|
response.status = status
|
|
|
|
def urlopen(_request, *, timeout):
|
|
nonlocal attempts
|
|
attempts += 1
|
|
assert timeout == 15
|
|
return response
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
with pytest.raises(HANDLER.CommentCommandError, match=message):
|
|
HANDLER.GitHubAPI("secret-token").create_issue_comment(123, ANNOUNCE_BODY)
|
|
assert attempts == 1
|
|
|
|
|
|
@pytest.mark.parametrize("status", [200, 201])
|
|
def test_add_comment_reaction_accepts_created_or_existing_reaction(monkeypatch, status):
|
|
requests = []
|
|
|
|
class Response:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def read(self):
|
|
return b'{"content":"+1"}'
|
|
|
|
response = Response()
|
|
response.status = status
|
|
|
|
def urlopen(request, *, timeout):
|
|
requests.append((request, timeout))
|
|
return response
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
HANDLER.GitHubAPI("secret-token").add_comment_reaction(5678, "+1")
|
|
|
|
request, timeout = requests[0]
|
|
assert request.full_url == ("https://api.github.com/repos/radixark/miles/issues/comments/5678/reactions")
|
|
assert request.method == "POST"
|
|
assert json.loads(request.data.decode("utf-8")) == {"content": "+1"}
|
|
assert timeout == 15
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "body", "message"),
|
|
[
|
|
(202, b'{"content":"+1"}', "expected 200 or 201"),
|
|
(204, b"", "expected 200 or 201"),
|
|
(201, b'{"content":"heart"}', "did not confirm"),
|
|
(201, b"not-json", "invalid JSON"),
|
|
],
|
|
)
|
|
def test_add_comment_reaction_rejects_unconfirmed_response_without_retry(monkeypatch, status, body, message):
|
|
attempts = 0
|
|
|
|
class Response:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def read(self):
|
|
return body
|
|
|
|
response = Response()
|
|
response.status = status
|
|
|
|
def urlopen(_request, *, timeout):
|
|
nonlocal attempts
|
|
attempts += 1
|
|
assert timeout == 15
|
|
return response
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
with pytest.raises(HANDLER.CommentCommandError, match=message):
|
|
HANDLER.GitHubAPI("secret-token").add_comment_reaction(5678, "+1")
|
|
assert attempts == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("exception", "message"),
|
|
[
|
|
(urllib.error.HTTPError("url", 403, "forbidden", {}, None), "HTTP 403"),
|
|
(TimeoutError(), "timed out"),
|
|
],
|
|
)
|
|
def test_comment_reaction_api_failure_is_not_retried(monkeypatch, exception, message):
|
|
attempts = []
|
|
|
|
def urlopen(_request, *, timeout):
|
|
attempts.append(timeout)
|
|
raise exception
|
|
|
|
monkeypatch.setattr(HANDLER.urllib.request, "urlopen", urlopen)
|
|
with pytest.raises(HANDLER.CommentCommandError, match=message):
|
|
HANDLER.GitHubAPI("secret-token").add_comment_reaction(5678, "+1")
|
|
assert attempts == [15]
|
|
|
|
|
|
def test_acknowledge_event_reacts_only_for_run_test_file():
|
|
api = FakeAPI(pull())
|
|
|
|
result = HANDLER.acknowledge_event(event(body=RUN_FILE_BODY), api)
|
|
|
|
assert api.reaction_calls == [(5678, "+1")]
|
|
assert result == {
|
|
"actor_id": ACTOR_ID,
|
|
"decision": "ALLOW_SUCCESS_REACTION_CONFIRMED",
|
|
"pull_number": 123,
|
|
"reaction": "+1",
|
|
}
|
|
|
|
|
|
def test_acknowledge_event_rejects_commands_without_a_success_reaction():
|
|
api = FakeAPI(pull())
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="does not define"):
|
|
HANDLER.acknowledge_event(event(body="/rerun-failed-ci"), api)
|
|
assert api.reaction_calls == []
|
|
|
|
|
|
@pytest.mark.parametrize("comment_id", [None, 0, "5678"])
|
|
def test_acknowledge_event_rejects_an_invalid_comment_id(comment_id):
|
|
api = FakeAPI(pull())
|
|
command_event = event(body=RUN_FILE_BODY)
|
|
command_event["comment"]["id"] = comment_id
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError, match="comment ID must be a positive integer"):
|
|
HANDLER.acknowledge_event(command_event, api)
|
|
assert api.reaction_calls == []
|
|
|
|
|
|
def test_acknowledge_mode_reacts_without_loading_policy(monkeypatch, capsys):
|
|
api = FakeAPI(pull())
|
|
monkeypatch.setattr(HANDLER, "load_json", lambda _path: event(body=RUN_FILE_BODY))
|
|
monkeypatch.setattr(HANDLER, "load_policy", lambda _path: pytest.fail("policy must not be loaded"))
|
|
monkeypatch.setattr(HANDLER, "GitHubAPI", lambda _token: api)
|
|
monkeypatch.setenv("GITHUB_EVENT_PATH", "event.json")
|
|
monkeypatch.setenv("CI_COMMAND_API_TOKEN", "reaction-token")
|
|
monkeypatch.setenv("CI_COMMAND_ACKNOWLEDGE", "true")
|
|
|
|
assert HANDLER.main() == 0
|
|
assert api.calls == [("add_comment_reaction", 5678, "+1")]
|
|
assert json.loads(capsys.readouterr().out) == {
|
|
"actor_id": ACTOR_ID,
|
|
"decision": "ALLOW_SUCCESS_REACTION_CONFIRMED",
|
|
"pull_number": 123,
|
|
"reaction": "+1",
|
|
}
|
|
|
|
|
|
def test_acknowledge_mode_rejects_a_command_without_a_success_reaction(monkeypatch):
|
|
api = FakeAPI(pull())
|
|
monkeypatch.setattr(HANDLER, "load_json", lambda _path: event(body="/rerun-failed-ci"))
|
|
monkeypatch.setattr(HANDLER, "load_policy", lambda _path: pytest.fail("policy must not be loaded"))
|
|
monkeypatch.setattr(HANDLER, "GitHubAPI", lambda _token: api)
|
|
monkeypatch.setenv("GITHUB_EVENT_PATH", "event.json")
|
|
monkeypatch.setenv("CI_COMMAND_API_TOKEN", "reaction-token")
|
|
monkeypatch.setenv("CI_COMMAND_ACKNOWLEDGE", "true")
|
|
|
|
assert HANDLER.main() == 1
|
|
assert api.calls == []
|
|
|
|
|
|
def test_announce_posts_a_running_status_comment_keyed_to_the_run(monkeypatch):
|
|
api = FakeAPI(pull())
|
|
monkeypatch.setattr(HANDLER, "_utc_now", lambda: REPORT_NOW)
|
|
|
|
result = HANDLER.announce_file_run(api, file_run_status("announce"))
|
|
|
|
pull_number, body = api.comment_calls[0]
|
|
assert pull_number == 123
|
|
assert body == ANNOUNCE_BODY
|
|
assert result == {
|
|
"comment_id": NEW_COMMENT_ID,
|
|
"decision": "FILE_RUN_ANNOUNCED",
|
|
"pull_number": 123,
|
|
"run_id": WORKFLOW_RUN_ID,
|
|
"test_file": RUN_FILE_PATH,
|
|
}
|
|
|
|
|
|
def test_report_updates_the_announced_comment_with_the_result_and_duration(monkeypatch):
|
|
api = FakeAPI(pull())
|
|
monkeypatch.setattr(HANDLER, "_utc_now", lambda: REPORT_NOW)
|
|
|
|
result = HANDLER.report_file_run(api, file_run_status(), EXISTING_COMMENT_ID)
|
|
|
|
assert api.comment_calls == []
|
|
comment_id, body = api.update_calls[0]
|
|
assert comment_id == EXISTING_COMMENT_ID
|
|
assert body == (
|
|
f"\u2705 `{RUN_FILE_PATH}` **passed** on `stage-c-4-gpu-h200` in 3m11s "
|
|
f"\u2014 [workflow run]({WORKFLOW_RUN_URL})\n\n{RUN_MARKER}"
|
|
)
|
|
assert result == {
|
|
"comment_id": EXISTING_COMMENT_ID,
|
|
"decision": "FILE_RUN_PASSED",
|
|
"duration": "3m11s",
|
|
"pull_number": 123,
|
|
"run_id": WORKFLOW_RUN_ID,
|
|
"test_file": RUN_FILE_PATH,
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("overrides", "decision", "icon", "explanation"),
|
|
[
|
|
(
|
|
{"FILE_RUN_CUDA_RESULT": "failure"},
|
|
"FILE_RUN_FAILED",
|
|
"\u274c",
|
|
"The execution job failed; inspect the workflow run for the failing step.",
|
|
),
|
|
(
|
|
{"FILE_RUN_RESOLVE_RESULT": "failure", "FILE_RUN_CUDA_RESULT": "skipped"},
|
|
"FILE_RUN_FAILED",
|
|
"\u274c",
|
|
"The run never started: resolving the test file's execution plan failed.",
|
|
),
|
|
(
|
|
{"FILE_RUN_CUDA_RESULT": "cancelled"},
|
|
"FILE_RUN_CANCELLED",
|
|
"\u26aa",
|
|
"The run was cancelled.",
|
|
),
|
|
(
|
|
{"FILE_RUN_RESOLVE_RESULT": "cancelled", "FILE_RUN_CUDA_RESULT": "skipped"},
|
|
"FILE_RUN_CANCELLED",
|
|
"\u26aa",
|
|
"The run was cancelled.",
|
|
),
|
|
(
|
|
{"FILE_RUN_CUDA_RESULT": "skipped", "FILE_RUN_CPU_RESULT": "skipped"},
|
|
"FILE_RUN_FAILED",
|
|
"\u274c",
|
|
"No execution job ran: the resolved plan selected neither the CUDA nor the CPU job.",
|
|
),
|
|
],
|
|
)
|
|
def test_report_never_calls_a_non_passing_run_a_pass(monkeypatch, overrides, decision, icon, explanation):
|
|
api = FakeAPI(pull())
|
|
monkeypatch.setattr(HANDLER, "_utc_now", lambda: REPORT_NOW)
|
|
|
|
result = HANDLER.report_file_run(api, file_run_status(**overrides), EXISTING_COMMENT_ID)
|
|
|
|
body = api.update_calls[0][1]
|
|
assert body.startswith(icon)
|
|
assert "**passed**" not in body
|
|
assert explanation in body
|
|
assert result["decision"] == decision
|
|
|
|
|
|
def test_report_reads_the_run_start_from_the_api_not_from_an_input(monkeypatch):
|
|
api = FakeAPI(pull())
|
|
api.workflow_run = {"run_started_at": "2026-08-23T08:25:25Z"}
|
|
monkeypatch.setattr(HANDLER, "_utc_now", lambda: REPORT_NOW)
|
|
|
|
result = HANDLER.report_file_run(api, file_run_status(), EXISTING_COMMENT_ID)
|
|
|
|
assert api.run_calls == [WORKFLOW_RUN_ID]
|
|
assert result["duration"] == "1h03m11s"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"workflow_run",
|
|
[
|
|
{},
|
|
{"created_at": RUN_STARTED_AT},
|
|
{"run_started_at": ""},
|
|
{"run_started_at": "not-a-timestamp"},
|
|
{"run_started_at": "2026-08-23T09:25:25"},
|
|
{"run_started_at": 17},
|
|
"nope",
|
|
],
|
|
)
|
|
def test_report_fails_closed_on_an_unusable_run_start(monkeypatch, workflow_run):
|
|
api = FakeAPI(pull())
|
|
api.workflow_run = workflow_run
|
|
monkeypatch.setattr(HANDLER, "_utc_now", lambda: REPORT_NOW)
|
|
|
|
with pytest.raises(HANDLER.CommentCommandError):
|
|
HANDLER.report_file_run(api, file_run_status(), EXISTING_COMMENT_ID)
|
|
assert api.update_calls == []
|
|
assert api.comment_calls == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("seconds", "formatted"),
|
|
[(0, "0s"), (9, "9s"), (59, "59s"), (60, "1m00s"), (191, "3m11s"), (3600, "1h00m00s"), (3791, "1h03m11s")],
|
|
)
|
|
def test_duration_formatting_is_stable(seconds, formatted):
|
|
assert HANDLER._format_duration(seconds) == formatted
|
|
|
|
|
|
def test_negative_duration_is_a_hard_error():
|
|
with pytest.raises(HANDLER.CommentCommandError, match="negative"):
|
|
HANDLER._format_duration(-1)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mode", "overrides", "message"),
|
|
[
|
|
("announce", {"CI_COMMAND_FILE_RUN_STATUS": "publish"}, "announce or report"),
|
|
("announce", {"FILE_RUN_PULL_NUMBER": "0"}, "pull request number"),
|
|
("announce", {"FILE_RUN_PULL_NUMBER": "12a"}, "pull request number"),
|
|
("announce", {"FILE_RUN_PULL_NUMBER": ""}, "pull request number"),
|
|
("announce", {"FILE_RUN_RUN_ID": "-1"}, "workflow run ID"),
|
|
("announce", {"FILE_RUN_TEST_FILE": "tests/../etc/passwd"}, "test file is invalid"),
|
|
("announce", {"FILE_RUN_TEST_FILE": ""}, "test file is invalid"),
|
|
("report", {"FILE_RUN_SUITE": "stage a cpu; rm -rf /"}, "suite is invalid"),
|
|
("report", {"FILE_RUN_RESOLVE_RESULT": "exploded"}, "resolve result is invalid"),
|
|
("report", {"FILE_RUN_CPU_RESULT": "success"}, "two executing jobs"),
|
|
("report", {"FILE_RUN_COMMENT_ID": ""}, "comment ID"),
|
|
("report", {"FILE_RUN_COMMENT_ID": "0"}, "comment ID"),
|
|
],
|
|
)
|
|
def test_file_run_status_inputs_fail_closed(mode, overrides, message):
|
|
with pytest.raises(HANDLER.CommentCommandError, match=message):
|
|
HANDLER._file_run_status_inputs(file_run_env(mode, **overrides))
|
|
|
|
|
|
def test_status_mode_needs_no_event_and_loads_no_policy(monkeypatch, capsys):
|
|
api = FakeAPI(pull())
|
|
monkeypatch.setattr(HANDLER, "load_json", lambda _path: pytest.fail("no event payload may be read"))
|
|
monkeypatch.setattr(HANDLER, "load_policy", lambda _path: pytest.fail("policy must not be loaded"))
|
|
monkeypatch.setattr(HANDLER, "GitHubAPI", lambda _token: api)
|
|
monkeypatch.setattr(HANDLER, "_utc_now", lambda: REPORT_NOW)
|
|
monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False)
|
|
monkeypatch.setenv("CI_COMMAND_API_TOKEN", "status-token")
|
|
for name, value in file_run_env("report").items():
|
|
monkeypatch.setenv(name, value)
|
|
|
|
assert HANDLER.main() == 0
|
|
assert json.loads(capsys.readouterr().out)["decision"] == "FILE_RUN_PASSED"
|
|
|
|
|
|
def test_announce_mode_writes_the_created_comment_id(monkeypatch, tmp_path, capsys):
|
|
api = FakeAPI(pull())
|
|
output_path = tmp_path / "github-output"
|
|
monkeypatch.setattr(HANDLER, "GitHubAPI", lambda _token: api)
|
|
monkeypatch.setattr(HANDLER, "_utc_now", lambda: REPORT_NOW)
|
|
monkeypatch.setenv("CI_COMMAND_API_TOKEN", "status-token")
|
|
monkeypatch.setenv("GITHUB_OUTPUT", str(output_path))
|
|
for name, value in file_run_env("announce").items():
|
|
monkeypatch.setenv(name, value)
|
|
|
|
assert HANDLER.main() == 0
|
|
assert output_path.read_text() == f"comment_id={NEW_COMMENT_ID}\n"
|
|
assert json.loads(capsys.readouterr().out)["decision"] == "FILE_RUN_ANNOUNCED"
|
|
|
|
|
|
def test_status_mode_reports_a_handler_error_as_a_failed_step(monkeypatch, capsys):
|
|
monkeypatch.setattr(HANDLER, "GitHubAPI", lambda _token: pytest.fail("token is validated first"))
|
|
monkeypatch.setenv("CI_COMMAND_API_TOKEN", "status-token")
|
|
for name, value in file_run_env("report", FILE_RUN_TEST_FILE="not-a-test").items():
|
|
monkeypatch.setenv(name, value)
|
|
|
|
assert HANDLER.main() == 1
|
|
assert "::error::file run test file is invalid" in capsys.readouterr().out
|
|
|
|
|
|
def test_the_gateway_no_longer_owns_a_reply_path():
|
|
# The run itself reports progress and the result; a second static comment
|
|
# from the gateway would duplicate it.
|
|
assert not hasattr(HANDLER, "reply_event")
|
|
assert not hasattr(HANDLER, "_write_workflow_run_url")
|
|
|
|
|
|
def test_workflow_runs_only_trusted_code_with_minimal_permissions():
|
|
workflow = WORKFLOW_PATH.read_text()
|
|
assert "issue_comment:\n types: [created]" in workflow
|
|
assert "github.event.comment.body" not in workflow
|
|
assert "permissions:\n contents: read" in workflow
|
|
assert "ref: ${{ github.sha }}" in workflow
|
|
assert "persist-credentials: false" in workflow
|
|
assert "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" in workflow
|
|
# The command App exists only for the issues capability: label mutations
|
|
# made with GITHUB_TOKEN would never trigger the labeled CI workflows.
|
|
assert workflow.count("actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1") == 1
|
|
assert workflow.count("client-id: ${{ vars.CI_COMMAND_APP_CLIENT_ID }}") == 1
|
|
assert workflow.count("private-key: ${{ secrets.CI_COMMAND_APP_PRIVATE_KEY }}") == 1
|
|
assert "permission-issues: write" in workflow
|
|
assert "permission-actions" not in workflow
|
|
assert "CI_COMMAND_API_TOKEN: ${{ github.token }}" in workflow
|
|
assert "CI_COMMAND_API_TOKEN: ${{ steps.issues-token.outputs.token }}" in workflow
|
|
assert "CI_COMMAND_APP_TOKEN" not in workflow
|
|
assert workflow.index("CI_COMMAND_PREFLIGHT") < workflow.index("actions/create-github-app-token@")
|
|
# The actions capability never waits on the App gate; label commands fail
|
|
# loudly when the App is not enabled instead of skipping silently.
|
|
assert "vars.CI_COMMAND_APP_ENABLED != 'true'" in workflow
|
|
assert "vars.CI_COMMAND_APP_ENABLED == 'true'" not in workflow
|
|
assert "steps.authorize.outputs.capability != 'none'" in workflow
|
|
assert "steps.authorize.outputs.capability != 'issues'" in workflow
|
|
assert "steps.authorize.outputs.capability != 'actions'" in workflow
|
|
assert "capability: ${{ steps.authorize.outputs.capability }}" in workflow
|
|
assert "success_reaction: ${{ steps.authorize.outputs.success_reaction }}" in workflow
|
|
assert "needs: handle-command" in workflow
|
|
assert "if: needs.handle-command.outputs.capability == 'actions'" in workflow
|
|
assert "group: comment-ci-actions-${{ github.event.issue.number }}" in workflow
|
|
assert "cancel-in-progress: false" in workflow
|
|
assert "queue: max" in workflow
|
|
handle_job = workflow.split(" handle-command:", 1)[1].split(" actions-command:", 1)[0]
|
|
actions_job = workflow.split(" actions-command:", 1)[1].split(" acknowledge-command:", 1)[0]
|
|
acknowledge_job = workflow.split(" acknowledge-command:", 1)[1]
|
|
issues_token = workflow.split("- name: Mint the issues-scoped App token", 1)[1].split(
|
|
"- name: Authorize and run the issues command", 1
|
|
)[0]
|
|
# Exact indented lines, so a commented-out key cannot satisfy the check.
|
|
assert "\n permission-issues: write\n" in issues_token
|
|
assert "\n permission-pull-requests: write\n" in issues_token
|
|
assert "permission-pull-requests: read" not in issues_token
|
|
assert "Require the command App for label commands" in handle_job
|
|
assert handle_job.index("Require the command App for label commands") < handle_job.index(
|
|
"Mint the issues-scoped App token"
|
|
)
|
|
# Each GITHUB_TOKEN job scopes its own permissions; only the actions job
|
|
# may dispatch or rerun, and only the acknowledgement job may write issues.
|
|
assert ("permissions:\n contents: read\n actions: write\n pull-requests: read") in actions_job
|
|
assert "issues: write" not in actions_job
|
|
assert "create-github-app-token" not in actions_job
|
|
assert "CI_COMMAND_API_TOKEN: ${{ github.token }}" in actions_job
|
|
# The acknowledgement job targets a comment on a pull request, and issues-API calls
|
|
# whose target issue is a PR are gated on pull-requests scope.
|
|
assert "permissions:\n contents: read\n issues: write\n pull-requests: write" in acknowledge_job
|
|
assert "actions: write" not in acknowledge_job
|
|
assert "create-github-app-token" not in acknowledge_job
|
|
assert workflow.index("Authorize and run the actions command") < workflow.index(
|
|
"Acknowledge the successful command"
|
|
)
|
|
assert "CI_COMMAND_ACKNOWLEDGE" not in actions_job
|
|
assert "needs: [handle-command, actions-command]" in acknowledge_job
|
|
assert (
|
|
"if: >-\n"
|
|
" needs.actions-command.result == 'success' &&\n"
|
|
" needs.handle-command.outputs.success_reaction == '+1'"
|
|
) in acknowledge_job
|
|
assert "always()" not in acknowledge_job
|
|
assert 'CI_COMMAND_ACKNOWLEDGE: "true"' in acknowledge_job
|
|
assert "CI_COMMAND_REPLY" not in actions_job
|
|
assert "workflow_run_url:" not in actions_job
|
|
assert "reply-command:" not in workflow
|
|
assert "CI_COMMAND_REPLY" not in workflow
|
|
assert "CI_COMMAND_WORKFLOW_RUN_URL" not in workflow
|
|
assert "pull_request_target" not in workflow
|
|
assert "github.event.pull_request.head" not in workflow
|
|
assert "pip install" not in workflow
|
|
assert "contents: write" not in workflow
|
|
assert "\n actions: write" not in workflow
|