[CI] support /rerun-ut command in slash handler (#19800)
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
@@ -407,6 +409,243 @@ def handle_rerun_stage(
|
||||
return False
|
||||
|
||||
|
||||
CUDA_SUITE_TO_RUNNER = {
|
||||
"stage-a-test-1": "1-gpu-runner",
|
||||
"stage-a-cpu-only": "ubuntu-latest",
|
||||
"stage-b-test-small-1-gpu": "1-gpu-5090",
|
||||
"stage-b-test-large-1-gpu": "1-gpu-runner",
|
||||
"stage-b-test-large-2-gpu": "2-gpu-runner",
|
||||
"stage-b-test-4-gpu-b200": "4-gpu-b200",
|
||||
"stage-c-test-4-gpu-h100": "4-gpu-h100",
|
||||
"stage-c-test-8-gpu-h200": "8-gpu-h200",
|
||||
"stage-c-test-8-gpu-h20": "8-gpu-h20",
|
||||
"stage-c-test-4-gpu-b200": "4-gpu-b200",
|
||||
"stage-c-test-deepep-4-gpu": "4-gpu-h100",
|
||||
"stage-c-test-deepep-8-gpu-h200": "8-gpu-h200",
|
||||
}
|
||||
|
||||
|
||||
def resolve_test_file(file_part):
|
||||
"""
|
||||
Resolve a user-provided file path to a path relative to test/.
|
||||
|
||||
Supports:
|
||||
- Full path: test/registered/core/test_srt_endpoint.py
|
||||
- Relative to test/: registered/core/test_srt_endpoint.py
|
||||
- Bare filename: test_srt_endpoint.py (glob-matched, must be unique)
|
||||
|
||||
Returns (resolved_path, error_message). On success error_message is None.
|
||||
"""
|
||||
if file_part.startswith("test/"):
|
||||
file_part = file_part[len("test/") :]
|
||||
|
||||
if "/" not in file_part:
|
||||
matches = glob.glob(f"test/registered/**/{file_part}", recursive=True)
|
||||
if len(matches) == 0:
|
||||
return (
|
||||
None,
|
||||
f"No test file found matching `{file_part}` under `test/registered/`.",
|
||||
)
|
||||
if len(matches) > 1:
|
||||
match_list = "\n".join(f"- `{m}`" for m in sorted(matches))
|
||||
return None, (
|
||||
f"Ambiguous filename `{file_part}` — matched {len(matches)} files:\n\n"
|
||||
f"{match_list}\n\n"
|
||||
f"Please provide the full path, e.g. `/rerun-ut {matches[0]}`"
|
||||
)
|
||||
return matches[0][len("test/") :], None
|
||||
|
||||
full_path = f"test/{file_part}"
|
||||
if not os.path.isfile(full_path):
|
||||
return None, f"File not found: `{full_path}`"
|
||||
return file_part, None
|
||||
|
||||
|
||||
def detect_cuda_suite(file_path_from_test):
|
||||
"""
|
||||
Read a test file and extract the suite from register_cuda_ci(suite="...").
|
||||
|
||||
Returns (suite_name, runner_label, error_message).
|
||||
"""
|
||||
full_path = f"test/{file_path_from_test}"
|
||||
with open(full_path, "r") as f:
|
||||
content = f.read()
|
||||
|
||||
match = re.search(
|
||||
r'register_cuda_ci\([^)]*suite\s*=\s*["\']([^"\']+)["\']', content
|
||||
)
|
||||
if not match:
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
(
|
||||
f"No `register_cuda_ci()` found in `{full_path}`.\n\n"
|
||||
f"This file may not be a registered CUDA CI test."
|
||||
),
|
||||
)
|
||||
|
||||
suite = match.group(1)
|
||||
runner = CUDA_SUITE_TO_RUNNER.get(suite)
|
||||
if not runner:
|
||||
known = ", ".join(f"`{s}`" for s in sorted(CUDA_SUITE_TO_RUNNER))
|
||||
return (
|
||||
suite,
|
||||
None,
|
||||
(
|
||||
f"Unknown CUDA suite `{suite}` in `{full_path}`.\n\n"
|
||||
f"Known suites: {known}"
|
||||
),
|
||||
)
|
||||
return suite, runner, None
|
||||
|
||||
|
||||
def handle_rerun_ut(gh_repo, pr, comment, user_perms, test_spec, token):
|
||||
"""
|
||||
Handles the /rerun-ut <file>::<TestClass.test_method> command.
|
||||
Dispatches a lightweight workflow to run a single test on the correct CUDA runner.
|
||||
"""
|
||||
if not (
|
||||
user_perms.get("can_rerun_ut", False)
|
||||
or user_perms.get("can_rerun_stage", False)
|
||||
):
|
||||
print("Permission denied: neither can_rerun_ut nor can_rerun_stage is true.")
|
||||
return False
|
||||
|
||||
if not test_spec:
|
||||
comment.create_reaction("confused")
|
||||
pr.create_issue_comment(
|
||||
"❌ Please specify a test: `/rerun-ut <file>::<TestClass.test_method>`\n\n"
|
||||
"Examples:\n"
|
||||
"- `/rerun-ut test/registered/core/test_srt_endpoint.py::TestSRTEndpoint.test_simple_decode`\n"
|
||||
"- `/rerun-ut registered/core/test_srt_endpoint.py::TestSRTEndpoint`\n"
|
||||
"- `/rerun-ut test_srt_endpoint.py`"
|
||||
)
|
||||
return False
|
||||
|
||||
# Parse spec: split on :: to get file path and optional test selector
|
||||
if "::" in test_spec:
|
||||
file_part, test_selector = test_spec.split("::", 1)
|
||||
else:
|
||||
file_part = test_spec
|
||||
test_selector = None
|
||||
|
||||
file_part = file_part.strip()
|
||||
if test_selector:
|
||||
test_selector = test_selector.strip()
|
||||
|
||||
# Resolve file path
|
||||
resolved_path, err = resolve_test_file(file_part)
|
||||
if err:
|
||||
comment.create_reaction("confused")
|
||||
pr.create_issue_comment(f"❌ {err}")
|
||||
return False
|
||||
|
||||
# Detect suite and runner
|
||||
suite, runner_label, err = detect_cuda_suite(resolved_path)
|
||||
if err:
|
||||
comment.create_reaction("confused")
|
||||
pr.create_issue_comment(f"❌ {err}")
|
||||
return False
|
||||
|
||||
# Build test_command: file path (+ optional test selector as unittest arg)
|
||||
test_command = resolved_path
|
||||
if test_selector:
|
||||
test_command = f"{resolved_path} {test_selector}"
|
||||
|
||||
print(
|
||||
f"Resolved: file={resolved_path}, selector={test_selector}, "
|
||||
f"suite={suite}, runner={runner_label}, command='{test_command}'"
|
||||
)
|
||||
|
||||
try:
|
||||
workflow_name = "Rerun UT"
|
||||
workflows = gh_repo.get_workflows()
|
||||
target_workflow = None
|
||||
for wf in workflows:
|
||||
if wf.name == workflow_name:
|
||||
target_workflow = wf
|
||||
break
|
||||
|
||||
if not target_workflow:
|
||||
print(f"Error: {workflow_name} workflow not found")
|
||||
return False
|
||||
|
||||
is_fork = (
|
||||
pr.head.repo is None or pr.head.repo.owner.login != gh_repo.owner.login
|
||||
)
|
||||
print(f"PR is from fork: {is_fork}")
|
||||
|
||||
pr_head_sha = None
|
||||
if is_fork:
|
||||
ref = "main"
|
||||
pr_head_sha = pr.head.sha
|
||||
inputs = {
|
||||
"test_command": test_command,
|
||||
"runner_label": runner_label,
|
||||
"pr_head_sha": pr_head_sha,
|
||||
}
|
||||
else:
|
||||
ref = pr.head.ref
|
||||
inputs = {
|
||||
"test_command": test_command,
|
||||
"runner_label": runner_label,
|
||||
}
|
||||
|
||||
dispatch_time = time.time()
|
||||
|
||||
dispatch_url = f"https://api.github.com/repos/{gh_repo.full_name}/actions/workflows/{target_workflow.id}/dispatches"
|
||||
dispatch_resp = requests.post(
|
||||
dispatch_url,
|
||||
json={"ref": ref, "inputs": inputs},
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
},
|
||||
)
|
||||
success = dispatch_resp.status_code in (200, 204)
|
||||
if not success:
|
||||
print(f"Dispatch failed: {dispatch_resp.status_code} {dispatch_resp.text}")
|
||||
|
||||
if success:
|
||||
print(f"Successfully triggered rerun-ut: {test_command}")
|
||||
comment.create_reaction("+1")
|
||||
pr.create_issue_comment(
|
||||
f"✅ Triggered `/rerun-ut` on `{runner_label}` runner:\n"
|
||||
f"```\ncd test/ && python3 {test_command}\n```"
|
||||
)
|
||||
|
||||
run_url = find_workflow_run_url(
|
||||
gh_repo,
|
||||
target_workflow.id,
|
||||
ref,
|
||||
"rerun-ut",
|
||||
token,
|
||||
dispatch_time,
|
||||
pr_head_sha=pr_head_sha,
|
||||
max_wait=30,
|
||||
)
|
||||
if run_url:
|
||||
pr.create_issue_comment(f"🔗 [View workflow run]({run_url})")
|
||||
else:
|
||||
pr.create_issue_comment(
|
||||
f"⚠️ Could not retrieve workflow run URL. "
|
||||
f"Check the [Actions tab](https://github.com/{gh_repo.full_name}/actions) for progress."
|
||||
)
|
||||
return True
|
||||
else:
|
||||
print("Failed to trigger workflow_dispatch")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error triggering rerun-ut: {e}")
|
||||
comment.create_reaction("confused")
|
||||
pr.create_issue_comment(
|
||||
f"❌ Failed to trigger rerun-ut: {str(e)}\n\n"
|
||||
f"Please check the logs or contact maintainers."
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
# 1. Load Environment Variables
|
||||
token = get_env_var("GITHUB_TOKEN")
|
||||
@@ -427,7 +666,7 @@ def main():
|
||||
pr = repo.get_pull(pr_number)
|
||||
comment = repo.get_issue(pr_number).get_comment(comment_id)
|
||||
|
||||
# PR authors can always rerun failed CI on their own PRs,
|
||||
# PR authors can always rerun failed CI and rerun individual UTs on their own PRs,
|
||||
# even if they are not listed in CI_PERMISSIONS.json.
|
||||
# Note: /tag-run-ci-label and /rerun-stage still require CI_PERMISSIONS.json.
|
||||
if pr.user.login == user_login:
|
||||
@@ -442,6 +681,7 @@ def main():
|
||||
f"User {user_login} is the PR author and has existing CI permissions."
|
||||
)
|
||||
user_perms["can_rerun_failed_ci"] = True
|
||||
user_perms["can_rerun_ut"] = True
|
||||
|
||||
if not user_perms:
|
||||
print(f"User {user_login} does not have any configured permissions. Exiting.")
|
||||
@@ -486,6 +726,11 @@ def main():
|
||||
stage_name = parts[1].strip() if len(parts) > 1 else None
|
||||
handle_rerun_stage(repo, pr, comment, user_perms, stage_name, token)
|
||||
|
||||
elif first_line.startswith("/rerun-ut"):
|
||||
parts = first_line.split(maxsplit=1)
|
||||
test_spec = parts[1].strip() if len(parts) > 1 else None
|
||||
handle_rerun_ut(repo, pr, comment, user_perms, test_spec, token)
|
||||
|
||||
else:
|
||||
print(f"Unknown or ignored command: {first_line}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user