[CI] Add /rerun-test --changed to rerun every test file a PR modifies (#37618)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Alison Shao <a.shao@wustl.edu>
This commit is contained in:
Shuwen Wang
2026-09-04 22:38:11 -07:00
committed by GitHub
co-authored by Claude Opus 5 Alison Shao
parent 756d0e0a85
commit 09f542b23a
3 changed files with 199 additions and 19 deletions
+96 -16
View File
@@ -30,6 +30,8 @@ PERMISSIONS_FILE_PATH = ".github/CI_PERMISSIONS.json"
TEST_GROUPS_FILE_PATH = "scripts/ci/rerun_test_groups.json"
PRECISION_BASELINE_TEST = "registered/debug_utils/test_nightly_precision_regression.py"
PRECISION_BASELINE_REFRESH_FLAG = "--refresh-precision-baseline"
CHANGED_TESTS_FLAG = "--changed"
CHANGED_TESTS_SHORT_FLAG = "-c"
MAINTENANCE_ISSUE_NUMBER = 21065
@@ -588,8 +590,8 @@ def expand_glob_spec(file_part):
Globs are matched against the same locations resolve_test_file() searches
— test/registered/ and the multimodal_gen test dir — so e.g.
`test_*backend*.py` reruns every backend test without hand-enumerating
each file. Two constraints keep a broad pattern from pulling in non-tests:
a match must live under a known test root and be named `test_*.py`.
each file. `_is_rerunnable_test_path` keeps a broad pattern from pulling in
non-tests.
glob's `*` matches path separators only via `**`, so a bare pattern is
searched recursively under each root; a path-ful pattern is anchored.
@@ -628,19 +630,11 @@ def expand_glob_spec(file_part):
expanded.add(p)
matches = expanded
def _under_test_root(path):
return path.startswith("test/registered/") or path.startswith(
MULTIMODAL_TEST_DIR + "/"
)
files = sorted(
{
os.path.normpath(p)
for p in matches
if os.path.isfile(p)
and os.path.basename(p).startswith("test_")
and p.endswith(".py")
and _under_test_root(os.path.normpath(p))
if os.path.isfile(p) and _is_rerunnable_test_path(os.path.normpath(p))
}
)
if not files:
@@ -652,6 +646,76 @@ def expand_glob_spec(file_part):
return files, None
def _collects_pytest_tests(path):
"""Whether a test file defines anything pytest would collect."""
if not os.path.isfile(path):
# Fork-added file, absent from the handler's main checkout; leave it for
# resolve_test_file() to report as `File not found`.
return True
with open(path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
return (
re.search(r"^\s*((async )?def test_|class Test)", content, re.MULTILINE)
is not None
)
def _is_rerunnable_test_path(path):
"""A repo-relative test file /rerun-test may select on its own (glob or --changed)."""
under_test_root = path.startswith("test/registered/") or path.startswith(
MULTIMODAL_TEST_DIR + "/"
)
if (
not under_test_root
or not os.path.basename(path).startswith("test_")
or not path.endswith(".py")
):
return False
if not path.startswith(MULTIMODAL_TEST_DIR + "/"):
# detect_suite() rejects an unregistered file, and a registered one may
# expose its cases through load_tests() rather than `def test_`.
return True
# Nothing downstream rejects a multimodal path, so a `test_*.py` helper that
# collects nothing reaches `pytest -x` and exits 5. manual/ is hand-run.
return "manual" not in path.split("/") and _collects_pytest_tests(path)
def _move_changes_dispatch(previous_filename, filename):
"""Whether a content-free move still changes how `filename` dispatches."""
previous_filename = previous_filename or ""
is_mm = filename.startswith(MULTIMODAL_TEST_DIR + "/")
if is_mm != previous_filename.startswith(MULTIMODAL_TEST_DIR + "/"):
return True
if not _is_rerunnable_test_path(previous_filename):
return True
return is_mm and (
detect_multimodal_suite(previous_filename)[0]
!= detect_multimodal_suite(filename)[0]
)
def changed_test_files(pr):
"""Rerunnable test files the PR adds or edits, as repo-relative paths.
A pure move reports `renamed` with an empty diff and is dropped, unless the
move itself changes dispatch: into a CI root, across the multimodal
boundary, or onto a different multimodal pool.
"""
return sorted(
f.filename
for f in pr.get_files()
if f.status != "removed"
and _is_rerunnable_test_path(f.filename)
and (
f.changes > 0
or (
f.status == "renamed"
and _move_changes_dispatch(f.previous_filename, f.filename)
)
)
)
def resolve_test_file(file_part):
"""
Resolve a user-provided file path to a path relative to test/ or full path for multimodal.
@@ -1196,6 +1260,7 @@ def handle_rerun_test(
skip_permission_check=False,
command_label=None,
refresh_precision_baseline=False,
include_changed_tests=False,
):
"""
Handles the /rerun-test command. Resolves all test specs, groups them by
@@ -1212,7 +1277,7 @@ def handle_rerun_test(
):
return False
if not test_specs:
if not test_specs and not include_changed_tests:
comment.create_reaction("confused")
pr.create_issue_comment(
"⛔ Please specify a test: `/rerun-test <file>::<TestClass.test_method>`\n\n"
@@ -1222,7 +1287,9 @@ def handle_rerun_test(
"- `/rerun-test test_srt_endpoint.py`\n"
"- `/rerun-test test_a.py test_b.py test_c.py` (multiple tests)\n"
"- `/rerun-test test_*backend*.py` (wildcard — reruns every matching "
"file; wrap the pattern in backticks so GitHub keeps the `*` literal)"
"file; wrap the pattern in backticks so GitHub keeps the `*` literal)\n"
f"- `/rerun-test {CHANGED_TESTS_FLAG}` (or `{CHANGED_TESTS_SHORT_FLAG}`; "
"every test file this PR adds or modifies)"
)
return False
@@ -1232,6 +1299,17 @@ def handle_rerun_test(
pr.create_issue_comment(gate_msg)
return False
if include_changed_tests:
changed = changed_test_files(pr)
if not changed and not test_specs:
comment.create_reaction("confused")
pr.create_issue_comment(
f"⛔ `{CHANGED_TESTS_FLAG}`: this PR adds or modifies no runnable test files "
f"under `test/registered/` or `{MULTIMODAL_TEST_DIR}/`."
)
return False
test_specs = list(test_specs or []) + changed
# Phase 0: Expand wildcard specs into concrete test files. A spec whose
# file part contains a glob metacharacter (* ? [) expands to every
# matching file; plain specs pass through to single-file resolution.
@@ -1542,9 +1620,10 @@ def main():
elif first_line.startswith("/rerun-test"):
rerun_args = first_line.split()[1:]
refresh_precision_baseline = PRECISION_BASELINE_REFRESH_FLAG in rerun_args
test_specs = [
arg for arg in rerun_args if arg != PRECISION_BASELINE_REFRESH_FLAG
]
changed_flags = {CHANGED_TESTS_FLAG, CHANGED_TESTS_SHORT_FLAG}
include_changed_tests = bool(changed_flags & set(rerun_args))
flags = changed_flags | {PRECISION_BASELINE_REFRESH_FLAG}
test_specs = [arg for arg in rerun_args if arg not in flags]
handle_rerun_test(
repo,
pr,
@@ -1554,6 +1633,7 @@ def main():
token,
command_label=first_line,
refresh_precision_baseline=refresh_precision_baseline,
include_changed_tests=include_changed_tests,
)
else: