mirror of
https://github.com/radixark/miles.git
synced 2026-11-01 07:53:37 +08:00
1363 lines
52 KiB
Python
1363 lines
52 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import datetime
|
|
import json
|
|
import os
|
|
import re
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import NamedTuple
|
|
|
|
API_ROOT = "https://api.github.com"
|
|
REPOSITORY = "radixark/miles"
|
|
REPOSITORY_ID = 1072725553
|
|
CLEAR_COMMAND = "clear-labels"
|
|
RERUN_COMMAND = "rerun-failed-ci"
|
|
RUN_FILE_COMMAND = "rerun-test"
|
|
RUN_FILE_WORKFLOW = "run-ci-file.yml"
|
|
RUN_FILE_WORKFLOW_REF = "main"
|
|
CLEAR_EXACT_LABELS = frozenset({"nightly", "bypass-fastfail"})
|
|
RERUN_WORKFLOWS = (
|
|
("pre-commit.yml", ".github/workflows/pre-commit.yml"),
|
|
("pr-test.yml", ".github/workflows/pr-test.yml"),
|
|
("pr-test-rocm.yml", ".github/workflows/pr-test-rocm.yml"),
|
|
)
|
|
COMMAND_PATTERN = re.compile(r"/(run-ci-[A-Za-z0-9][A-Za-z0-9_.-]*|bypass-fastfail)")
|
|
# "/run-ci" remains the label-family marker, while "/rerun-test" owns targeted
|
|
# file runs; either family must parse as one exact command or fail loudly.
|
|
COMMAND_MARKERS = ("/run-ci", "/rerun-test", "/bypass-fastfail", "/clear-labels", "/rerun-failed-ci")
|
|
# Registered test files only: fixed roots, path segments that cannot form
|
|
# ".." or an absolute path, and a test_*.py basename. The dispatched workflow
|
|
# re-validates the same shape before the path reaches any shell command.
|
|
TEST_FILE_PATTERN = re.compile(
|
|
r"tests/(?:e2e|fast|fast-gpu|ci)(?:/[A-Za-z0-9_][A-Za-z0-9_.-]*)*/test_[A-Za-z0-9_][A-Za-z0-9_.-]*\.py"
|
|
)
|
|
RUN_FILE_COMMAND_PATTERN = re.compile(rf"/{RUN_FILE_COMMAND} +({TEST_FILE_PATTERN.pattern})")
|
|
# PR-body dependency pins, mirroring the exact patterns the pr-test.yml
|
|
# resolve steps grep for; each match is forwarded as one dispatch input.
|
|
PR_BODY_PINS = (
|
|
(
|
|
"ci_image_tag",
|
|
re.compile(r"^ci-image-tag:\s+(\S+)", re.MULTILINE),
|
|
re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}"),
|
|
),
|
|
(
|
|
"ci_megatron_pr",
|
|
re.compile(r"^ci-megatron-pr:\s+(\S+)", re.MULTILINE),
|
|
re.compile(r"#[0-9]+|[A-Za-z0-9_][A-Za-z0-9_./-]*"),
|
|
),
|
|
(
|
|
"ci_sglang_pr",
|
|
re.compile(r"^ci-sglang-pr:\s+(\S+)", re.MULTILINE),
|
|
re.compile(r"#[0-9]+|[A-Za-z0-9_][A-Za-z0-9_./-]*"),
|
|
),
|
|
)
|
|
LABEL_PATTERN = re.compile(r"(?:run-ci-[A-Za-z0-9][A-Za-z0-9_.-]*|bypass-fastfail)")
|
|
SHA_PATTERN = re.compile(r"[0-9a-f]{40}")
|
|
WORKFLOW_RUN_URL_PATTERN = re.compile(rf"https://github\.com/{re.escape(REPOSITORY)}/actions/runs/[1-9][0-9]*")
|
|
# Suite names come from the resolver, and reach a comment body verbatim.
|
|
SUITE_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*")
|
|
POLICY_PERMISSIONS = frozenset({"write", "admin"})
|
|
POLICY_GROUPS = frozenset({"add_label_access", "repo_write_access", "prior_contributor_access"})
|
|
# The comment payload's author_association enum, as GitHub defines it. Groups
|
|
# may allow a subset; associations outside the enum fail loudly.
|
|
AUTHOR_ASSOCIATIONS = frozenset(
|
|
{
|
|
"COLLABORATOR",
|
|
"CONTRIBUTOR",
|
|
"FIRST_TIMER",
|
|
"FIRST_TIME_CONTRIBUTOR",
|
|
"MANNEQUIN",
|
|
"MEMBER",
|
|
"NONE",
|
|
"OWNER",
|
|
}
|
|
)
|
|
|
|
|
|
class CommentCommandError(Exception):
|
|
pass
|
|
|
|
|
|
class AddLabel(NamedTuple):
|
|
label: str
|
|
|
|
|
|
class ClearLabels(NamedTuple):
|
|
pass
|
|
|
|
|
|
class RerunFailedCI(NamedTuple):
|
|
pass
|
|
|
|
|
|
class RunTestFile(NamedTuple):
|
|
test_file: str
|
|
|
|
|
|
class CommandSpec(NamedTuple):
|
|
policy_key: str
|
|
capability: str
|
|
handler: object
|
|
resource_key: object
|
|
resource_loader: object
|
|
resource_getter: object
|
|
allows_user_ids: bool
|
|
audit_key: str
|
|
audit_value: object
|
|
success_reaction: str
|
|
|
|
|
|
class CommandContext(NamedTuple):
|
|
api: object
|
|
pull_number: int
|
|
actor_id: int
|
|
actor_login: str
|
|
author_association: str
|
|
allowed_permissions: frozenset
|
|
allowed_user_ids: frozenset
|
|
allowed_author_associations: frozenset
|
|
current_labels: frozenset
|
|
head_sha: str
|
|
head_repository_id: int
|
|
head_owner_login: str
|
|
head_ref: str
|
|
body: str
|
|
|
|
|
|
def _strict_object(pairs):
|
|
result = {}
|
|
for key, value in pairs:
|
|
if key in result:
|
|
raise CommentCommandError(f"duplicate JSON key: {key}")
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def _reject_json_constant(value):
|
|
raise CommentCommandError(f"non-standard JSON number: {value}")
|
|
|
|
|
|
def load_json(path):
|
|
try:
|
|
text = Path(path).read_text(encoding="utf-8")
|
|
return json.loads(
|
|
text,
|
|
object_pairs_hook=_strict_object,
|
|
parse_constant=_reject_json_constant,
|
|
)
|
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
raise CommentCommandError(f"cannot read JSON from {path}: {error}") from error
|
|
|
|
|
|
def _validate_permissions(name, values):
|
|
if not isinstance(values, list) or not values:
|
|
raise CommentCommandError(f"{name} must be a non-empty array")
|
|
if any(type(value) is not str or value not in POLICY_PERMISSIONS for value in values):
|
|
raise CommentCommandError(f"{name} must contain only write or admin")
|
|
if len(set(values)) != len(values):
|
|
raise CommentCommandError(f"{name} contains duplicate permissions")
|
|
return frozenset(values)
|
|
|
|
|
|
def _validate_author_associations(name, values):
|
|
if not isinstance(values, list) or not values:
|
|
raise CommentCommandError(f"{name} must be a non-empty array")
|
|
if any(type(value) is not str or value not in AUTHOR_ASSOCIATIONS for value in values):
|
|
raise CommentCommandError(f"{name} must contain only GitHub author associations")
|
|
if len(set(values)) != len(values):
|
|
raise CommentCommandError(f"{name} contains duplicate author associations")
|
|
return frozenset(values)
|
|
|
|
|
|
def _validate_labels(name, values):
|
|
if not isinstance(values, list) or not values:
|
|
raise CommentCommandError(f"{name} must be a non-empty array")
|
|
for label in values:
|
|
if not isinstance(label, str) or LABEL_PATTERN.fullmatch(label) is None:
|
|
raise CommentCommandError(f"invalid exact CI label: {label!r}")
|
|
if len(set(values)) != len(values):
|
|
raise CommentCommandError(f"{name} contains duplicate labels")
|
|
return frozenset(values)
|
|
|
|
|
|
def load_policy(path):
|
|
raw = load_json(path)
|
|
if not isinstance(raw, dict) or set(raw) != {"version", "groups", "commands"}:
|
|
raise CommentCommandError("policy must contain only version, groups, and commands")
|
|
if type(raw["version"]) is not int or raw["version"] != 4:
|
|
raise CommentCommandError("policy version must be 4")
|
|
|
|
raw_groups = raw["groups"]
|
|
if not isinstance(raw_groups, dict) or set(raw_groups) != POLICY_GROUPS:
|
|
raise CommentCommandError(
|
|
"policy groups must contain only add_label_access, repo_write_access, and prior_contributor_access"
|
|
)
|
|
groups = {}
|
|
for name, raw_group in raw_groups.items():
|
|
expected_keys = {"repository_permissions"}
|
|
if name in {"add_label_access", "prior_contributor_access"}:
|
|
expected_keys.add("users")
|
|
if name == "prior_contributor_access":
|
|
expected_keys.add("author_associations")
|
|
if not isinstance(raw_group, dict) or set(raw_group) != expected_keys:
|
|
raise CommentCommandError(f"invalid fields for policy group {name}")
|
|
groups[name] = {
|
|
"repository_permissions": _validate_permissions(
|
|
f"groups.{name}.repository_permissions",
|
|
raw_group["repository_permissions"],
|
|
),
|
|
"user_ids": (
|
|
frozenset(user["id"] for user in raw_group["users"]) if "users" in raw_group else frozenset()
|
|
),
|
|
"author_associations": (
|
|
_validate_author_associations(
|
|
f"groups.{name}.author_associations",
|
|
raw_group["author_associations"],
|
|
)
|
|
if "author_associations" in raw_group
|
|
else frozenset()
|
|
),
|
|
}
|
|
|
|
specs_by_key = {spec.policy_key: spec for spec in COMMAND_REGISTRY.values()}
|
|
if len(specs_by_key) != len(COMMAND_REGISTRY):
|
|
raise CommentCommandError("command registry contains duplicate policy keys")
|
|
raw_commands = raw["commands"]
|
|
if not isinstance(raw_commands, dict) or set(raw_commands) != set(specs_by_key):
|
|
raise CommentCommandError("policy commands do not match the static command registry")
|
|
commands = {}
|
|
for name, spec in specs_by_key.items():
|
|
raw_command = raw_commands[name]
|
|
expected_keys = {"group"}
|
|
if spec.resource_key is not None:
|
|
expected_keys.add(spec.resource_key)
|
|
if not isinstance(raw_command, dict) or set(raw_command) != expected_keys:
|
|
raise CommentCommandError(f"invalid fields for policy command {name}")
|
|
group_name = raw_command["group"]
|
|
if not isinstance(group_name, str) or group_name not in groups:
|
|
raise CommentCommandError(f"policy command {name} references an unknown group")
|
|
if not spec.allows_user_ids and groups[group_name]["user_ids"]:
|
|
raise CommentCommandError(f"policy command {name} cannot grant access by user ID")
|
|
command = {"group": group_name}
|
|
if spec.resource_key is not None:
|
|
command[spec.resource_key] = spec.resource_loader(
|
|
f"commands.{name}.{spec.resource_key}",
|
|
raw_command[spec.resource_key],
|
|
)
|
|
commands[name] = command
|
|
|
|
return {"groups": groups, "commands": commands}
|
|
|
|
|
|
def parse_command(body):
|
|
if not isinstance(body, str):
|
|
raise CommentCommandError("comment body must be a string")
|
|
command = body.strip()
|
|
if command == f"/{CLEAR_COMMAND}":
|
|
return ClearLabels()
|
|
if command == f"/{RERUN_COMMAND}":
|
|
return RerunFailedCI()
|
|
match = COMMAND_PATTERN.fullmatch(command)
|
|
if match is not None:
|
|
return AddLabel(match.group(1))
|
|
match = RUN_FILE_COMMAND_PATTERN.fullmatch(command)
|
|
if match is not None:
|
|
return RunTestFile(match.group(1))
|
|
if any(marker in command for marker in COMMAND_MARKERS):
|
|
raise CommentCommandError(
|
|
"comment must contain one exact /<label>, /rerun-test <test-file>, /clear-labels, "
|
|
"or /rerun-failed-ci command"
|
|
)
|
|
return None
|
|
|
|
|
|
def _positive_int(value, name):
|
|
if type(value) is not int or value <= 0:
|
|
raise CommentCommandError(f"{name} must be a positive integer")
|
|
return value
|
|
|
|
|
|
def _validate_workflow_run_url(value):
|
|
if not isinstance(value, str) or WORKFLOW_RUN_URL_PATTERN.fullmatch(value) is None:
|
|
raise CommentCommandError("workflow run URL is invalid")
|
|
return value
|
|
|
|
|
|
def parse_event(event):
|
|
if not isinstance(event, dict) or event.get("action") != "created":
|
|
raise CommentCommandError("event must be issue_comment.created")
|
|
|
|
repository = event.get("repository")
|
|
if not isinstance(repository, dict):
|
|
raise CommentCommandError("event repository is missing")
|
|
if repository.get("id") != REPOSITORY_ID or repository.get("full_name") != REPOSITORY:
|
|
raise CommentCommandError("event repository does not match radixark/miles")
|
|
|
|
issue = event.get("issue")
|
|
if not isinstance(issue, dict) or not isinstance(issue.get("pull_request"), dict):
|
|
raise CommentCommandError("comment is not attached to a pull request")
|
|
pull_number = _positive_int(issue.get("number"), "pull request number")
|
|
|
|
comment = event.get("comment")
|
|
if not isinstance(comment, dict) or not isinstance(comment.get("user"), dict):
|
|
raise CommentCommandError("comment author is missing")
|
|
if comment["user"].get("type") != "User":
|
|
raise CommentCommandError("comment author must be a human GitHub user")
|
|
actor_id = _positive_int(comment["user"].get("id"), "comment author ID")
|
|
actor_login = comment["user"].get("login")
|
|
if not isinstance(actor_login, str) or not actor_login:
|
|
raise CommentCommandError("comment author login is missing")
|
|
# GitHub stamps the association at comment time; an unknown value fails
|
|
# loudly rather than defaulting into or out of any policy tier.
|
|
author_association = comment.get("author_association")
|
|
if not isinstance(author_association, str) or author_association not in AUTHOR_ASSOCIATIONS:
|
|
raise CommentCommandError("comment author association is invalid")
|
|
|
|
sender = event.get("sender")
|
|
if not isinstance(sender, dict) or sender.get("id") != actor_id:
|
|
raise CommentCommandError("event sender does not match the comment author")
|
|
|
|
return pull_number, actor_id, actor_login, author_association, parse_command(comment.get("body"))
|
|
|
|
|
|
class GitHubAPI:
|
|
def __init__(self, token):
|
|
if not token:
|
|
raise CommentCommandError("GitHub API token is missing")
|
|
self.token = token
|
|
|
|
def _request(self, path, *, method="GET", payload=None, expected_statuses=None, expect_json=True):
|
|
data = None
|
|
if payload is not None:
|
|
data = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
request = urllib.request.Request(
|
|
f"{API_ROOT}{path}",
|
|
data=data,
|
|
method=method,
|
|
headers={
|
|
"Accept": "application/vnd.github+json",
|
|
"Authorization": f"Bearer {self.token}",
|
|
"Content-Type": "application/json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=15) as response:
|
|
if expected_statuses is not None and response.status not in expected_statuses:
|
|
expected = " or ".join(str(status) for status in expected_statuses)
|
|
raise CommentCommandError(f"GitHub API returned HTTP {response.status}; expected {expected}")
|
|
body = response.read()
|
|
if not expect_json:
|
|
if body.strip():
|
|
raise CommentCommandError("GitHub API returned an unexpected response body")
|
|
return None
|
|
return json.loads(body.decode("utf-8"))
|
|
except urllib.error.HTTPError as error:
|
|
# Name the request so a permission failure points at one call; the
|
|
# token never appears here, and GitHub's accepted-permissions header
|
|
# states which installation permission that call needed.
|
|
detail = f"GitHub API returned HTTP {error.code} for {method} {path}"
|
|
accepted = error.headers.get("X-Accepted-GitHub-Permissions")
|
|
if accepted:
|
|
detail = f"{detail}; accepted permissions: {accepted}"
|
|
raise CommentCommandError(detail) from error
|
|
except urllib.error.URLError as error:
|
|
raise CommentCommandError(f"GitHub API request failed: {error.reason}") from error
|
|
except TimeoutError as error:
|
|
raise CommentCommandError("GitHub API request timed out") from error
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
raise CommentCommandError("GitHub API returned invalid JSON") from error
|
|
|
|
def get_pull(self, pull_number):
|
|
return self._request(f"/repos/{REPOSITORY}/pulls/{pull_number}")
|
|
|
|
def get_permission(self, actor_login):
|
|
encoded_login = urllib.parse.quote(actor_login, safe="")
|
|
return self._request(f"/repos/{REPOSITORY}/collaborators/{encoded_login}/permission")
|
|
|
|
def add_label(self, pull_number, label):
|
|
return self._request(
|
|
f"/repos/{REPOSITORY}/issues/{pull_number}/labels",
|
|
method="POST",
|
|
payload={"labels": [label]},
|
|
)
|
|
|
|
def remove_label(self, pull_number, label):
|
|
encoded_label = urllib.parse.quote(label, safe="")
|
|
return self._request(
|
|
f"/repos/{REPOSITORY}/issues/{pull_number}/labels/{encoded_label}",
|
|
method="DELETE",
|
|
)
|
|
|
|
def list_workflow_runs(self, workflow_file, head_sha):
|
|
encoded_workflow = urllib.parse.quote(workflow_file, safe="")
|
|
runs = []
|
|
total_count = None
|
|
for page in range(1, 11):
|
|
query = urllib.parse.urlencode(
|
|
{
|
|
"event": "pull_request",
|
|
"head_sha": head_sha,
|
|
"page": page,
|
|
"per_page": 100,
|
|
}
|
|
)
|
|
result = self._request(f"/repos/{REPOSITORY}/actions/workflows/{encoded_workflow}/runs?{query}")
|
|
if not isinstance(result, dict):
|
|
raise CommentCommandError("GitHub API returned an invalid workflow-run page")
|
|
page_total = result.get("total_count")
|
|
page_runs = result.get("workflow_runs")
|
|
if type(page_total) is not int or page_total < 0 or page_total > 1000:
|
|
raise CommentCommandError("GitHub API returned an invalid workflow-run count")
|
|
if not isinstance(page_runs, list):
|
|
raise CommentCommandError("GitHub API returned invalid workflow runs")
|
|
if total_count is None:
|
|
total_count = page_total
|
|
elif page_total != total_count:
|
|
raise CommentCommandError("workflow-run count changed during pagination")
|
|
runs.extend(page_runs)
|
|
if len(runs) >= total_count:
|
|
if len(runs) != total_count:
|
|
raise CommentCommandError("GitHub API returned too many workflow runs")
|
|
return runs
|
|
if not page_runs:
|
|
raise CommentCommandError("GitHub API returned an incomplete workflow-run listing")
|
|
raise CommentCommandError("workflow-run listing exceeded the 1000-run API limit")
|
|
|
|
def list_pulls_for_head(self, owner_login, head_ref):
|
|
pulls = []
|
|
for page in range(1, 11):
|
|
query = urllib.parse.urlencode(
|
|
{
|
|
"head": f"{owner_login}:{head_ref}",
|
|
"page": page,
|
|
"per_page": 100,
|
|
"state": "all",
|
|
}
|
|
)
|
|
result = self._request(f"/repos/{REPOSITORY}/pulls?{query}")
|
|
if not isinstance(result, list):
|
|
raise CommentCommandError("GitHub API returned an invalid head pull-request page")
|
|
pulls.extend(result)
|
|
if len(result) < 100:
|
|
return pulls
|
|
raise CommentCommandError("head pull-request listing exceeded the 1000-result limit")
|
|
|
|
def rerun_failed_jobs(self, run_id):
|
|
self._request(
|
|
f"/repos/{REPOSITORY}/actions/runs/{run_id}/rerun-failed-jobs",
|
|
method="POST",
|
|
expected_statuses=(201,),
|
|
expect_json=False,
|
|
)
|
|
|
|
def create_workflow_dispatch(self, workflow_file, ref, inputs):
|
|
encoded_workflow = urllib.parse.quote(workflow_file, safe="")
|
|
result = self._request(
|
|
f"/repos/{REPOSITORY}/actions/workflows/{encoded_workflow}/dispatches",
|
|
method="POST",
|
|
payload={"ref": ref, "inputs": inputs, "return_run_details": True},
|
|
expected_statuses=(200,),
|
|
)
|
|
if not isinstance(result, dict):
|
|
raise CommentCommandError("GitHub API returned invalid workflow dispatch details")
|
|
run_id = _positive_int(result.get("workflow_run_id"), "workflow run ID")
|
|
html_url = _validate_workflow_run_url(result.get("html_url"))
|
|
if (
|
|
result.get("run_url") != f"{API_ROOT}/repos/{REPOSITORY}/actions/runs/{run_id}"
|
|
or html_url != f"https://github.com/{REPOSITORY}/actions/runs/{run_id}"
|
|
):
|
|
raise CommentCommandError("GitHub API returned mismatched workflow dispatch details")
|
|
return html_url
|
|
|
|
def add_comment_reaction(self, comment_id, content):
|
|
result = self._request(
|
|
f"/repos/{REPOSITORY}/issues/comments/{comment_id}/reactions",
|
|
method="POST",
|
|
payload={"content": content},
|
|
expected_statuses=(200, 201),
|
|
)
|
|
if not isinstance(result, dict) or result.get("content") != content:
|
|
raise CommentCommandError("GitHub API did not confirm the comment reaction")
|
|
|
|
def create_issue_comment(self, pull_number, body):
|
|
result = self._request(
|
|
f"/repos/{REPOSITORY}/issues/{pull_number}/comments",
|
|
method="POST",
|
|
payload={"body": body},
|
|
expected_statuses=(201,),
|
|
)
|
|
if not isinstance(result, dict) or result.get("body") != body:
|
|
raise CommentCommandError("GitHub API did not confirm the workflow reply")
|
|
return _positive_int(result.get("id"), "comment ID")
|
|
|
|
def update_issue_comment(self, comment_id, body):
|
|
result = self._request(
|
|
f"/repos/{REPOSITORY}/issues/comments/{comment_id}",
|
|
method="PATCH",
|
|
payload={"body": body},
|
|
expected_statuses=(200,),
|
|
)
|
|
if not isinstance(result, dict) or result.get("body") != body:
|
|
raise CommentCommandError("GitHub API did not confirm the status update")
|
|
|
|
def get_workflow_run(self, run_id):
|
|
return self._request(f"/repos/{REPOSITORY}/actions/runs/{run_id}")
|
|
|
|
|
|
def _validate_live_pull(pull, pull_number):
|
|
if not isinstance(pull, dict) or pull.get("number") != pull_number:
|
|
raise CommentCommandError("GitHub API returned the wrong pull request")
|
|
if pull.get("state") != "open":
|
|
raise CommentCommandError("pull request is not open")
|
|
|
|
base = pull.get("base")
|
|
base_repository = base.get("repo") if isinstance(base, dict) else None
|
|
if not isinstance(base_repository, dict):
|
|
raise CommentCommandError("pull request base repository is missing")
|
|
if base_repository.get("id") != REPOSITORY_ID or base_repository.get("full_name") != REPOSITORY:
|
|
raise CommentCommandError("pull request base repository does not match radixark/miles")
|
|
|
|
head = pull.get("head")
|
|
head_repository = head.get("repo") if isinstance(head, dict) else None
|
|
if not isinstance(head_repository, dict):
|
|
raise CommentCommandError("pull request head repository is missing")
|
|
head_repository_id = _positive_int(head_repository.get("id"), "pull request head repository ID")
|
|
head_repository_owner = head_repository.get("owner")
|
|
head_owner_login = head_repository_owner.get("login") if isinstance(head_repository_owner, dict) else None
|
|
if not isinstance(head_owner_login, str) or not head_owner_login:
|
|
raise CommentCommandError("pull request head repository owner is missing")
|
|
head_ref = head.get("ref") if isinstance(head, dict) else None
|
|
if not isinstance(head_ref, str) or not head_ref:
|
|
raise CommentCommandError("pull request head ref is missing")
|
|
|
|
labels = pull.get("labels")
|
|
if not isinstance(labels, list) or any(not isinstance(item, dict) for item in labels):
|
|
raise CommentCommandError("pull request labels are invalid")
|
|
names = []
|
|
for item in labels:
|
|
name = item.get("name")
|
|
if not isinstance(name, str):
|
|
raise CommentCommandError("pull request label name is invalid")
|
|
names.append(name)
|
|
|
|
head_sha = head.get("sha") if isinstance(head, dict) else None
|
|
if not isinstance(head_sha, str) or SHA_PATTERN.fullmatch(head_sha) is None:
|
|
raise CommentCommandError("pull request head SHA is invalid")
|
|
|
|
return frozenset(names), head_sha, head_repository_id, head_owner_login, head_ref
|
|
|
|
|
|
def _pull_body(pull):
|
|
body = pull.get("body")
|
|
if body is None:
|
|
return ""
|
|
if not isinstance(body, str):
|
|
raise CommentCommandError("pull request body is invalid")
|
|
return body
|
|
|
|
|
|
def _command_spec(request):
|
|
if request is None:
|
|
raise CommentCommandError("comment does not contain a recognized command")
|
|
spec = COMMAND_REGISTRY.get(type(request))
|
|
if spec is None:
|
|
raise CommentCommandError("request type is not registered")
|
|
return spec
|
|
|
|
|
|
def resolve_policy(event, policy):
|
|
pull_number, actor_id, actor_login, author_association, request = parse_event(event)
|
|
spec = _command_spec(request)
|
|
try:
|
|
command_policy = policy["commands"][spec.policy_key]
|
|
group = policy["groups"][command_policy["group"]]
|
|
allowed_permissions = group["repository_permissions"]
|
|
allowed_user_ids = group["user_ids"] if spec.allows_user_ids else frozenset()
|
|
allowed_author_associations = group["author_associations"]
|
|
except (KeyError, TypeError) as error:
|
|
raise CommentCommandError("resolved command policy is invalid") from error
|
|
if spec.resource_key is not None:
|
|
resource = spec.resource_getter(request)
|
|
if resource not in command_policy[spec.resource_key]:
|
|
raise CommentCommandError("requested label is not exposed by policy")
|
|
return (
|
|
pull_number,
|
|
actor_id,
|
|
actor_login,
|
|
author_association,
|
|
request,
|
|
spec,
|
|
allowed_permissions,
|
|
allowed_user_ids,
|
|
allowed_author_associations,
|
|
)
|
|
|
|
|
|
def require_permission(api, actor_id, actor_login, allowed_permissions):
|
|
permission_result = api.get_permission(actor_login)
|
|
if not isinstance(permission_result, dict):
|
|
raise CommentCommandError("GitHub API returned an invalid repository permission")
|
|
permission_user = permission_result.get("user")
|
|
permission_user_id = permission_user.get("id") if isinstance(permission_user, dict) else None
|
|
if type(permission_user_id) is not int or permission_user_id <= 0:
|
|
raise CommentCommandError("GitHub API returned an invalid repository permission identity")
|
|
if permission_user_id != actor_id:
|
|
raise CommentCommandError("repository permission identity does not match the comment author")
|
|
permission = permission_result.get("permission")
|
|
if not isinstance(permission, str):
|
|
raise CommentCommandError("GitHub API returned an invalid repository permission")
|
|
if permission not in allowed_permissions:
|
|
raise CommentCommandError("comment author is not authorized for the requested operation")
|
|
|
|
|
|
def require_access(
|
|
api,
|
|
actor_id,
|
|
actor_login,
|
|
author_association,
|
|
allowed_permissions,
|
|
allowed_user_ids,
|
|
allowed_author_associations,
|
|
):
|
|
if actor_id in allowed_user_ids:
|
|
return
|
|
# The association GitHub stamped on the comment admits prior contributors
|
|
# without a live-permission lookup; anyone else needs write or admin.
|
|
if author_association in allowed_author_associations:
|
|
return
|
|
require_permission(api, actor_id, actor_login, allowed_permissions)
|
|
|
|
|
|
def _is_ci_control_label(label):
|
|
return label.startswith("run-ci") or label in CLEAR_EXACT_LABELS
|
|
|
|
|
|
def _latest_failed_run(
|
|
runs,
|
|
workflow_path,
|
|
pull_number,
|
|
head_sha,
|
|
head_repository_id,
|
|
head_ref,
|
|
allow_empty_pull_requests,
|
|
):
|
|
associated = []
|
|
seen_ids = set()
|
|
for run in runs:
|
|
if not isinstance(run, dict):
|
|
raise CommentCommandError("GitHub API returned an invalid workflow run")
|
|
run_id = _positive_int(run.get("id"), "workflow run ID")
|
|
if run_id in seen_ids:
|
|
raise CommentCommandError("GitHub API returned a duplicate workflow run")
|
|
seen_ids.add(run_id)
|
|
run_number = _positive_int(run.get("run_number"), "workflow run number")
|
|
if run.get("path") != workflow_path:
|
|
raise CommentCommandError("GitHub API returned a workflow run from an unexpected path")
|
|
if run.get("event") != "pull_request" or run.get("head_sha") != head_sha:
|
|
raise CommentCommandError("GitHub API returned a workflow run from an unexpected event or SHA")
|
|
if run.get("head_branch") != head_ref:
|
|
raise CommentCommandError("GitHub API returned a workflow run from an unexpected head ref")
|
|
run_head_repository = run.get("head_repository")
|
|
if not isinstance(run_head_repository, dict) or run_head_repository.get("id") != head_repository_id:
|
|
raise CommentCommandError("GitHub API returned a workflow run from an unexpected repository")
|
|
pull_requests = run.get("pull_requests")
|
|
if not isinstance(pull_requests, list):
|
|
raise CommentCommandError("GitHub API returned invalid workflow-run pull requests")
|
|
pull_numbers = set()
|
|
for pull_request in pull_requests:
|
|
if not isinstance(pull_request, dict):
|
|
raise CommentCommandError("GitHub API returned an invalid workflow-run pull request")
|
|
pull_numbers.add(_positive_int(pull_request.get("number"), "workflow-run pull number"))
|
|
if pull_number in pull_numbers or (not pull_numbers and allow_empty_pull_requests):
|
|
associated.append((run_number, run_id, run))
|
|
|
|
if not associated:
|
|
return None
|
|
_, run_id, latest = max(associated, key=lambda item: (item[0], item[1]))
|
|
if latest.get("status") == "completed" and latest.get("conclusion") == "failure":
|
|
return run_id
|
|
return None
|
|
|
|
|
|
def _require_unique_head_pull(
|
|
api,
|
|
pull_number,
|
|
head_sha,
|
|
head_repository_id,
|
|
head_owner_login,
|
|
head_ref,
|
|
):
|
|
pulls = api.list_pulls_for_head(head_owner_login, head_ref)
|
|
if len(pulls) != 1:
|
|
raise CommentCommandError("fork head does not identify exactly one pull request")
|
|
pull = pulls[0]
|
|
if not isinstance(pull, dict) or pull.get("number") != pull_number or pull.get("state") != "open":
|
|
raise CommentCommandError("fork head identifies an unexpected pull request")
|
|
base = pull.get("base")
|
|
base_repository = base.get("repo") if isinstance(base, dict) else None
|
|
head = pull.get("head")
|
|
head_repository = head.get("repo") if isinstance(head, dict) else None
|
|
head_repository_owner = head_repository.get("owner") if isinstance(head_repository, dict) else None
|
|
if (
|
|
not isinstance(base_repository, dict)
|
|
or base_repository.get("id") != REPOSITORY_ID
|
|
or base_repository.get("full_name") != REPOSITORY
|
|
or not isinstance(head_repository, dict)
|
|
or head_repository.get("id") != head_repository_id
|
|
or not isinstance(head_repository_owner, dict)
|
|
or head_repository_owner.get("login") != head_owner_login
|
|
or head.get("ref") != head_ref
|
|
or head.get("sha") != head_sha
|
|
):
|
|
raise CommentCommandError("fork head pull-request identity does not match the live pull request")
|
|
|
|
|
|
def _handle_rerun_failed_ci(context, request):
|
|
if type(request) is not RerunFailedCI:
|
|
raise CommentCommandError("rerun handler received the wrong request type")
|
|
(
|
|
api,
|
|
pull_number,
|
|
actor_id,
|
|
actor_login,
|
|
author_association,
|
|
allowed_permissions,
|
|
allowed_user_ids,
|
|
allowed_author_associations,
|
|
_,
|
|
head_sha,
|
|
head_repository_id,
|
|
head_owner_login,
|
|
head_ref,
|
|
_,
|
|
) = context
|
|
is_fork = head_repository_id != REPOSITORY_ID
|
|
if is_fork:
|
|
_require_unique_head_pull(
|
|
api,
|
|
pull_number,
|
|
head_sha,
|
|
head_repository_id,
|
|
head_owner_login,
|
|
head_ref,
|
|
)
|
|
candidates = []
|
|
for workflow_file, workflow_path in RERUN_WORKFLOWS:
|
|
runs = api.list_workflow_runs(workflow_file, head_sha)
|
|
run_id = _latest_failed_run(
|
|
runs,
|
|
workflow_path,
|
|
pull_number,
|
|
head_sha,
|
|
head_repository_id,
|
|
head_ref,
|
|
is_fork,
|
|
)
|
|
if run_id is not None:
|
|
candidates.append((run_id, workflow_file, workflow_path))
|
|
|
|
candidates.sort()
|
|
if not candidates:
|
|
require_access(
|
|
api,
|
|
actor_id,
|
|
actor_login,
|
|
author_association,
|
|
allowed_permissions,
|
|
allowed_user_ids,
|
|
allowed_author_associations,
|
|
)
|
|
for run_id, workflow_file, workflow_path in candidates:
|
|
current_pull = api.get_pull(pull_number)
|
|
(
|
|
_,
|
|
current_head_sha,
|
|
current_head_repository_id,
|
|
current_head_owner_login,
|
|
current_head_ref,
|
|
) = _validate_live_pull(current_pull, pull_number)
|
|
if (
|
|
current_head_sha != head_sha
|
|
or current_head_repository_id != head_repository_id
|
|
or current_head_owner_login != head_owner_login
|
|
or current_head_ref != head_ref
|
|
):
|
|
raise CommentCommandError("pull request head changed before rerun")
|
|
if is_fork:
|
|
_require_unique_head_pull(
|
|
api,
|
|
pull_number,
|
|
head_sha,
|
|
head_repository_id,
|
|
head_owner_login,
|
|
head_ref,
|
|
)
|
|
current_run_id = _latest_failed_run(
|
|
api.list_workflow_runs(workflow_file, head_sha),
|
|
workflow_path,
|
|
pull_number,
|
|
head_sha,
|
|
head_repository_id,
|
|
head_ref,
|
|
is_fork,
|
|
)
|
|
if current_run_id != run_id:
|
|
raise CommentCommandError("latest workflow-run state changed before rerun")
|
|
require_access(
|
|
api,
|
|
actor_id,
|
|
actor_login,
|
|
author_association,
|
|
allowed_permissions,
|
|
allowed_user_ids,
|
|
allowed_author_associations,
|
|
)
|
|
try:
|
|
api.rerun_failed_jobs(run_id)
|
|
except CommentCommandError as error:
|
|
raise CommentCommandError(f"could not rerun failed jobs for workflow run {run_id}: {error}") from error
|
|
|
|
return {
|
|
"actor_id": actor_id,
|
|
"decision": "ALLOW_RERUN_REQUESTED" if candidates else "ALLOW_NO_FAILED_RUNS",
|
|
"head_sha": head_sha,
|
|
"pull_number": pull_number,
|
|
"workflow_run_ids": [candidate[0] for candidate in candidates],
|
|
}
|
|
|
|
|
|
def _handle_add_label(context, request):
|
|
if type(request) is not AddLabel:
|
|
raise CommentCommandError("add-label handler received the wrong request type")
|
|
require_access(
|
|
context.api,
|
|
context.actor_id,
|
|
context.actor_login,
|
|
context.author_association,
|
|
context.allowed_permissions,
|
|
context.allowed_user_ids,
|
|
context.allowed_author_associations,
|
|
)
|
|
label = request.label
|
|
if label in context.current_labels:
|
|
decision = "ALLOW_ALREADY_PRESENT"
|
|
else:
|
|
result = context.api.add_label(context.pull_number, label)
|
|
if not isinstance(result, list) or label not in {
|
|
item.get("name") for item in result if isinstance(item, dict)
|
|
}:
|
|
raise CommentCommandError("GitHub API did not confirm the requested label")
|
|
decision = "ALLOW_ADDED"
|
|
return {
|
|
"actor_id": context.actor_id,
|
|
"decision": decision,
|
|
"label": label,
|
|
"pull_number": context.pull_number,
|
|
}
|
|
|
|
|
|
def _handle_clear_labels(context, request):
|
|
if type(request) is not ClearLabels:
|
|
raise CommentCommandError("clear-labels handler received the wrong request type")
|
|
require_access(
|
|
context.api,
|
|
context.actor_id,
|
|
context.actor_login,
|
|
context.author_association,
|
|
context.allowed_permissions,
|
|
context.allowed_user_ids,
|
|
context.allowed_author_associations,
|
|
)
|
|
labels_to_remove = sorted(label for label in context.current_labels if _is_ci_control_label(label))
|
|
remaining_labels = context.current_labels
|
|
for label in labels_to_remove:
|
|
try:
|
|
result = context.api.remove_label(context.pull_number, label)
|
|
except CommentCommandError as error:
|
|
raise CommentCommandError(f"could not remove CI label {label}: {error}") from error
|
|
if (
|
|
not isinstance(result, list)
|
|
or any(not isinstance(item, dict) or not isinstance(item.get("name"), str) for item in result)
|
|
or label in {item["name"] for item in result}
|
|
):
|
|
raise CommentCommandError(f"GitHub API did not confirm removal of {label}")
|
|
remaining_labels = frozenset(item["name"] for item in result)
|
|
if any(_is_ci_control_label(label) for label in remaining_labels):
|
|
raise CommentCommandError("GitHub API did not confirm that all CI labels were removed")
|
|
return {
|
|
"actor_id": context.actor_id,
|
|
"decision": "ALLOW_CLEARED" if labels_to_remove else "ALLOW_ALREADY_CLEAR",
|
|
"labels": labels_to_remove,
|
|
"pull_number": context.pull_number,
|
|
}
|
|
|
|
|
|
def _pr_body_pins(body):
|
|
pins = {}
|
|
for input_name, body_pattern, value_pattern in PR_BODY_PINS:
|
|
match = body_pattern.search(body)
|
|
if match is None:
|
|
continue
|
|
value = match.group(1)
|
|
if value_pattern.fullmatch(value) is None:
|
|
raise CommentCommandError(
|
|
f"PR body pin {input_name.replace('_', '-')} has an unsupported value: {value!r}"
|
|
)
|
|
pins[input_name] = value
|
|
return pins
|
|
|
|
|
|
def _handle_run_test_file(context, request):
|
|
if type(request) is not RunTestFile:
|
|
raise CommentCommandError("rerun-test handler received the wrong request type")
|
|
if context.head_repository_id != REPOSITORY_ID:
|
|
# A fork head is the normal shape of a non-write contributor's PR —
|
|
# same-repository branches require write to push — so the policy tier
|
|
# is the whole gate and fork adds no extra approval. The head identity
|
|
# is pinned to its unique open PR, and the dispatched workflow
|
|
# withholds repository secrets from fork heads.
|
|
_require_unique_head_pull(
|
|
context.api,
|
|
context.pull_number,
|
|
context.head_sha,
|
|
context.head_repository_id,
|
|
context.head_owner_login,
|
|
context.head_ref,
|
|
)
|
|
require_access(
|
|
context.api,
|
|
context.actor_id,
|
|
context.actor_login,
|
|
context.author_association,
|
|
context.allowed_permissions,
|
|
context.allowed_user_ids,
|
|
context.allowed_author_associations,
|
|
)
|
|
inputs = {
|
|
"pull_number": str(context.pull_number),
|
|
"head_sha": context.head_sha,
|
|
"test_file": request.test_file,
|
|
}
|
|
inputs.update(_pr_body_pins(context.body))
|
|
workflow_run_url = context.api.create_workflow_dispatch(
|
|
RUN_FILE_WORKFLOW,
|
|
RUN_FILE_WORKFLOW_REF,
|
|
inputs,
|
|
)
|
|
return {
|
|
"actor_id": context.actor_id,
|
|
"decision": "ALLOW_FILE_RUN_DISPATCHED",
|
|
"head_sha": context.head_sha,
|
|
"pull_number": context.pull_number,
|
|
"test_file": request.test_file,
|
|
"workflow_run_url": workflow_run_url,
|
|
}
|
|
|
|
|
|
def _add_label_resource(request):
|
|
if type(request) is not AddLabel:
|
|
raise CommentCommandError("add-label resource received the wrong request type")
|
|
return request.label
|
|
|
|
|
|
def _clear_command_value(request):
|
|
if type(request) is not ClearLabels:
|
|
raise CommentCommandError("clear-labels audit received the wrong request type")
|
|
return f"/{CLEAR_COMMAND}"
|
|
|
|
|
|
def _rerun_command_value(request):
|
|
if type(request) is not RerunFailedCI:
|
|
raise CommentCommandError("rerun audit received the wrong request type")
|
|
return f"/{RERUN_COMMAND}"
|
|
|
|
|
|
def _run_file_value(request):
|
|
if type(request) is not RunTestFile:
|
|
raise CommentCommandError("rerun-test audit received the wrong request type")
|
|
return request.test_file
|
|
|
|
|
|
COMMAND_REGISTRY = {
|
|
AddLabel: CommandSpec(
|
|
"add_label",
|
|
"issues",
|
|
_handle_add_label,
|
|
"allowed_labels",
|
|
_validate_labels,
|
|
_add_label_resource,
|
|
True,
|
|
"label",
|
|
_add_label_resource,
|
|
"none",
|
|
),
|
|
ClearLabels: CommandSpec(
|
|
"clear_labels",
|
|
"issues",
|
|
_handle_clear_labels,
|
|
None,
|
|
None,
|
|
None,
|
|
False,
|
|
"command",
|
|
_clear_command_value,
|
|
"none",
|
|
),
|
|
RerunFailedCI: CommandSpec(
|
|
"rerun_failed_ci",
|
|
"actions",
|
|
_handle_rerun_failed_ci,
|
|
None,
|
|
None,
|
|
None,
|
|
True,
|
|
"command",
|
|
_rerun_command_value,
|
|
"none",
|
|
),
|
|
RunTestFile: CommandSpec(
|
|
"run_test_file",
|
|
"actions",
|
|
_handle_run_test_file,
|
|
None,
|
|
None,
|
|
None,
|
|
True,
|
|
"test_file",
|
|
_run_file_value,
|
|
"+1",
|
|
),
|
|
}
|
|
|
|
|
|
def process_event(event, policy, api):
|
|
(
|
|
pull_number,
|
|
actor_id,
|
|
actor_login,
|
|
author_association,
|
|
request,
|
|
spec,
|
|
allowed_permissions,
|
|
allowed_user_ids,
|
|
allowed_author_associations,
|
|
) = resolve_policy(event, policy)
|
|
pull = api.get_pull(pull_number)
|
|
(
|
|
current_labels,
|
|
head_sha,
|
|
head_repository_id,
|
|
head_owner_login,
|
|
head_ref,
|
|
) = _validate_live_pull(pull, pull_number)
|
|
context = CommandContext(
|
|
api,
|
|
pull_number,
|
|
actor_id,
|
|
actor_login,
|
|
author_association,
|
|
allowed_permissions,
|
|
allowed_user_ids,
|
|
allowed_author_associations,
|
|
current_labels,
|
|
head_sha,
|
|
head_repository_id,
|
|
head_owner_login,
|
|
head_ref,
|
|
_pull_body(pull),
|
|
)
|
|
return spec.handler(context, request)
|
|
|
|
|
|
def authorize_policy(event, policy, api):
|
|
(
|
|
pull_number,
|
|
actor_id,
|
|
actor_login,
|
|
author_association,
|
|
request,
|
|
spec,
|
|
allowed_permissions,
|
|
allowed_user_ids,
|
|
allowed_author_associations,
|
|
) = resolve_policy(event, policy)
|
|
require_access(
|
|
api,
|
|
actor_id,
|
|
actor_login,
|
|
author_association,
|
|
allowed_permissions,
|
|
allowed_user_ids,
|
|
allowed_author_associations,
|
|
)
|
|
return pull_number, actor_id, request, spec
|
|
|
|
|
|
def acknowledge_event(event, api):
|
|
pull_number, actor_id, _, _, request = parse_event(event)
|
|
spec = _command_spec(request)
|
|
if spec.success_reaction == "none":
|
|
raise CommentCommandError("command does not define a success reaction")
|
|
comment_id = _positive_int(event["comment"].get("id"), "comment ID")
|
|
api.add_comment_reaction(comment_id, spec.success_reaction)
|
|
return {
|
|
"actor_id": actor_id,
|
|
"decision": "ALLOW_SUCCESS_REACTION_CONFIRMED",
|
|
"pull_number": pull_number,
|
|
"reaction": spec.success_reaction,
|
|
}
|
|
|
|
|
|
def _file_run_marker(run_id):
|
|
# Keep the owning workflow run visible in the comment source; the exact
|
|
# comment ID passed between jobs prevents concurrent runs from colliding.
|
|
return f"<!-- rerun-test-run:{run_id} -->"
|
|
|
|
|
|
def _format_duration(seconds):
|
|
seconds = int(seconds)
|
|
if seconds < 0:
|
|
raise CommentCommandError("workflow run duration is negative")
|
|
minutes, seconds = divmod(seconds, 60)
|
|
hours, minutes = divmod(minutes, 60)
|
|
if hours:
|
|
return f"{hours}h{minutes:02d}m{seconds:02d}s"
|
|
if minutes:
|
|
return f"{minutes}m{seconds:02d}s"
|
|
return f"{seconds}s"
|
|
|
|
|
|
def _parse_run_timestamp(value, name):
|
|
if not isinstance(value, str) or not value:
|
|
raise CommentCommandError(f"workflow run {name} is missing")
|
|
try:
|
|
parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError as error:
|
|
raise CommentCommandError(f"workflow run {name} is invalid") from error
|
|
if parsed.tzinfo is None:
|
|
raise CommentCommandError(f"workflow run {name} has no timezone")
|
|
return parsed
|
|
|
|
|
|
def _run_elapsed(api, run_id, now):
|
|
run = api.get_workflow_run(run_id)
|
|
if not isinstance(run, dict):
|
|
raise CommentCommandError("GitHub API returned an invalid workflow run")
|
|
started = _parse_run_timestamp(run.get("run_started_at"), "start time")
|
|
return (now - started).total_seconds()
|
|
|
|
|
|
# Job results this handler knows how to describe. Anything else fails loudly
|
|
# rather than being reported to a reviewer as an unknown-but-fine state.
|
|
FILE_RUN_RESULTS = frozenset({"success", "failure", "cancelled", "skipped", ""})
|
|
FILE_RUN_STATUS_MODES = frozenset({"announce", "report"})
|
|
|
|
|
|
class FileRunStatus(NamedTuple):
|
|
pull_number: int
|
|
test_file: str
|
|
run_id: int
|
|
suite: str
|
|
resolve_result: str
|
|
execute_result: str
|
|
|
|
|
|
def _file_run_status_inputs(environ):
|
|
mode = environ.get("CI_COMMAND_FILE_RUN_STATUS", "")
|
|
if mode not in FILE_RUN_STATUS_MODES:
|
|
raise CommentCommandError("file run status mode must be announce or report")
|
|
pull_number = _parse_positive_int(environ.get("FILE_RUN_PULL_NUMBER"), "pull request number")
|
|
run_id = _parse_positive_int(environ.get("FILE_RUN_RUN_ID"), "workflow run ID")
|
|
test_file = environ.get("FILE_RUN_TEST_FILE", "")
|
|
if TEST_FILE_PATTERN.fullmatch(test_file) is None:
|
|
raise CommentCommandError("file run test file is invalid")
|
|
suite = environ.get("FILE_RUN_SUITE", "")
|
|
if suite and SUITE_PATTERN.fullmatch(suite) is None:
|
|
raise CommentCommandError("file run suite is invalid")
|
|
results = {
|
|
"resolve": environ.get("FILE_RUN_RESOLVE_RESULT", ""),
|
|
"cuda": environ.get("FILE_RUN_CUDA_RESULT", ""),
|
|
"cpu": environ.get("FILE_RUN_CPU_RESULT", ""),
|
|
}
|
|
for name, value in results.items():
|
|
if value not in FILE_RUN_RESULTS:
|
|
raise CommentCommandError(f"file run {name} result is invalid")
|
|
# Exactly one hardware job executes the file; the other is skipped by its
|
|
# own `if`, so the executed result is the one that is not "skipped".
|
|
executed = [value for value in (results["cuda"], results["cpu"]) if value not in {"skipped", ""}]
|
|
if len(executed) > 1:
|
|
raise CommentCommandError("file run reported two executing jobs")
|
|
comment_id = None
|
|
if mode == "report":
|
|
comment_id = _parse_positive_int(environ.get("FILE_RUN_COMMENT_ID"), "comment ID")
|
|
return (
|
|
mode,
|
|
FileRunStatus(
|
|
pull_number=pull_number,
|
|
test_file=test_file,
|
|
run_id=run_id,
|
|
suite=suite,
|
|
resolve_result=results["resolve"],
|
|
execute_result=executed[0] if executed else "",
|
|
),
|
|
comment_id,
|
|
)
|
|
|
|
|
|
def _parse_positive_int(value, name):
|
|
if not isinstance(value, str) or not value.isdigit():
|
|
raise CommentCommandError(f"{name} must be a positive integer")
|
|
return _positive_int(int(value), name)
|
|
|
|
|
|
def _file_run_outcome(status):
|
|
"""Describe the run for a reviewer: headline, and why when it is not a pass."""
|
|
if status.resolve_result == "cancelled" or status.execute_result == "cancelled":
|
|
return "cancelled", "The run was cancelled."
|
|
if status.resolve_result != "success":
|
|
return "failed", "The run never started: resolving the test file's execution plan failed."
|
|
if status.execute_result == "success":
|
|
return "passed", ""
|
|
if status.execute_result == "failure":
|
|
return "failed", "The execution job failed; inspect the workflow run for the failing step."
|
|
return "failed", "No execution job ran: the resolved plan selected neither the CUDA nor the CPU job."
|
|
|
|
|
|
def announce_file_run(api, status):
|
|
run_url = f"https://github.com/{REPOSITORY}/actions/runs/{status.run_id}"
|
|
body = (
|
|
f"⏳ `{status.test_file}` is **running** — [workflow run]({run_url})\n\n"
|
|
f"Started at {_utc_now().strftime('%Y-%m-%d %H:%M:%S')} UTC; "
|
|
"elapsed time and the result will be recorded here when it finishes.\n\n"
|
|
f"{_file_run_marker(status.run_id)}"
|
|
)
|
|
comment_id = api.create_issue_comment(status.pull_number, body)
|
|
return {
|
|
"comment_id": comment_id,
|
|
"decision": "FILE_RUN_ANNOUNCED",
|
|
"pull_number": status.pull_number,
|
|
"run_id": status.run_id,
|
|
"test_file": status.test_file,
|
|
}
|
|
|
|
|
|
def report_file_run(api, status, comment_id):
|
|
outcome, explanation = _file_run_outcome(status)
|
|
icon = {"passed": "✅", "failed": "❌", "cancelled": "⚪"}[outcome]
|
|
run_url = f"https://github.com/{REPOSITORY}/actions/runs/{status.run_id}"
|
|
elapsed = _format_duration(_run_elapsed(api, status.run_id, _utc_now()))
|
|
where = f" on `{status.suite}`" if status.suite else ""
|
|
lines = [f"{icon} `{status.test_file}` **{outcome}**{where} in {elapsed} — [workflow run]({run_url})"]
|
|
if explanation:
|
|
lines.append(explanation)
|
|
lines.append(_file_run_marker(status.run_id))
|
|
body = "\n\n".join(lines)
|
|
api.update_issue_comment(comment_id, body)
|
|
return {
|
|
"comment_id": comment_id,
|
|
"decision": f"FILE_RUN_{outcome.upper()}",
|
|
"duration": elapsed,
|
|
"pull_number": status.pull_number,
|
|
"run_id": status.run_id,
|
|
"test_file": status.test_file,
|
|
}
|
|
|
|
|
|
def _utc_now():
|
|
return datetime.datetime.now(datetime.timezone.utc)
|
|
|
|
|
|
def _write_routing(capability, success_reaction):
|
|
if capability not in {"none", "issues", "actions"}:
|
|
raise CommentCommandError("command registry selected an unknown capability")
|
|
if success_reaction not in {"none", "+1"}:
|
|
raise CommentCommandError("command registry selected an unknown success reaction")
|
|
output_path = os.environ.get("GITHUB_OUTPUT")
|
|
if not output_path:
|
|
return
|
|
try:
|
|
with Path(output_path).open("a", encoding="utf-8") as output:
|
|
output.write(f"capability={capability}\n")
|
|
output.write(f"success_reaction={success_reaction}\n")
|
|
except OSError as error:
|
|
raise CommentCommandError(f"cannot write GITHUB_OUTPUT: {error}") from error
|
|
|
|
|
|
def _write_file_run_comment_id(comment_id):
|
|
comment_id = _positive_int(comment_id, "comment ID")
|
|
output_path = os.environ.get("GITHUB_OUTPUT")
|
|
if not output_path:
|
|
return
|
|
try:
|
|
with Path(output_path).open("a", encoding="utf-8") as output:
|
|
output.write(f"comment_id={comment_id}\n")
|
|
except OSError as error:
|
|
raise CommentCommandError(f"cannot write GITHUB_OUTPUT: {error}") from error
|
|
|
|
|
|
def main():
|
|
try:
|
|
# The file-run status modes report on a dispatched workflow run, not on
|
|
# a comment event, so they are the one path that reads no event payload.
|
|
if os.environ.get("CI_COMMAND_FILE_RUN_STATUS"):
|
|
mode, status, comment_id = _file_run_status_inputs(os.environ)
|
|
api = GitHubAPI(os.environ["CI_COMMAND_API_TOKEN"])
|
|
if mode == "announce":
|
|
result = announce_file_run(api, status)
|
|
_write_file_run_comment_id(result["comment_id"])
|
|
else:
|
|
result = report_file_run(api, status, comment_id)
|
|
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
|
|
return 0
|
|
event = load_json(os.environ["GITHUB_EVENT_PATH"])
|
|
if os.environ.get("CI_COMMAND_ACKNOWLEDGE") == "true":
|
|
api = GitHubAPI(os.environ["CI_COMMAND_API_TOKEN"])
|
|
result = acknowledge_event(event, api)
|
|
elif os.environ.get("CI_COMMAND_PREFLIGHT") == "true":
|
|
pull_number, actor_id, _, _, request = parse_event(event)
|
|
if request is None:
|
|
_write_routing("none", "none")
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"actor_id": actor_id,
|
|
"decision": "IGNORE_UNRECOGNIZED_COMMAND",
|
|
"pull_number": pull_number,
|
|
},
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
)
|
|
)
|
|
return 0
|
|
policy = load_policy(os.environ["CI_COMMAND_POLICY_PATH"])
|
|
api = GitHubAPI(os.environ["CI_COMMAND_API_TOKEN"])
|
|
pull_number, actor_id, request, spec = authorize_policy(event, policy, api)
|
|
authorization = {"actor_id": actor_id, "pull_number": pull_number}
|
|
authorization[spec.audit_key] = spec.audit_value(request)
|
|
_write_routing(spec.capability, spec.success_reaction)
|
|
print(
|
|
json.dumps(
|
|
authorization,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
)
|
|
)
|
|
return 0
|
|
else:
|
|
policy = load_policy(os.environ["CI_COMMAND_POLICY_PATH"])
|
|
api = GitHubAPI(os.environ["CI_COMMAND_API_TOKEN"])
|
|
result = process_event(event, policy, api)
|
|
except CommentCommandError as error:
|
|
print(f"::error::{error}")
|
|
return 1
|
|
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|