| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Check that auto-labeling rules still match real files. |
| 4 | |
| 5 | Mergify silently ignores a condition that can never match, so a file-path |
| 6 | condition keeps passing review long after the file it names has moved or been |
| 7 | deleted. This walks every `files=` / `files~=` condition in `.github/mergify.yml` |
| 8 | and fails if one matches nothing in the tree. |
| 9 | |
| 10 | Usage: |
| 11 | python tools/pre_commit/check_label_rules.py |
| 12 | """ |
| 13 | |
| 14 | import subprocess |
| 15 | import sys |
| 16 | |
| 17 | import regex as re |
| 18 | import yaml |
| 19 | |
| 20 | MERGIFY = ".github/mergify.yml" |
| 21 | |
| 22 | |
| 23 | def tracked_files() -> list[str]: |
| 24 | out = subprocess.run( |
| 25 | ["git", "ls-files"], capture_output=True, text=True, check=True |
| 26 | ).stdout |
| 27 | return [line for line in out.split("\n") if line] |
| 28 | |
| 29 | |
| 30 | def file_conditions(node, found: list[str]) -> list[str]: |
| 31 | """Collect every string condition nested under a rule's `conditions`.""" |
| 32 | if isinstance(node, dict): |
| 33 | for value in node.values(): |
| 34 | file_conditions(value, found) |
| 35 | elif isinstance(node, list): |
| 36 | for value in node: |
| 37 | file_conditions(value, found) |
| 38 | elif isinstance(node, str): |
| 39 | found.append(node) |
| 40 | return found |
| 41 | |
| 42 | |
| 43 | def check(files: list[str]) -> list[tuple[str, str, str]]: |
| 44 | with open(MERGIFY) as f: |
| 45 | rules = yaml.safe_load(f)["pull_request_rules"] |
| 46 | |
| 47 | # removed-files is excluded: it names paths the tree no longer has. |
| 48 | attrs = ("files", "added-files", "modified-files") |
| 49 | |
| 50 | dead = [] |
| 51 | for rule in rules: |
| 52 | for cond in file_conditions(rule.get("conditions", []), []): |
| 53 | cond = cond.strip() |
| 54 | # Negated conditions (label-tpu-remove) are expected to match nothing. |
| 55 | if cond.startswith("-"): |
| 56 | continue |
| 57 | attr = next( |
| 58 | ( |
| 59 | a |
| 60 | for a in attrs |
| 61 | if cond.startswith(f"{a}~=") or cond.startswith(f"{a}=") |
| 62 | ), |
| 63 | None, |
| 64 | ) |
| 65 | if attr is None: |
| 66 | continue |
| 67 | if cond.startswith(f"{attr}~="): |
| 68 | pattern = cond[len(attr) + 2 :] |
| 69 | try: |
| 70 | regex = re.compile(pattern) |
| 71 | except re.error as exc: |
| 72 | dead.append((rule["name"], cond, f"invalid regex: {exc}")) |
| 73 | continue |
| 74 | matched = any(regex.search(f) for f in files) |
| 75 | else: |
| 76 | matched = cond[len(attr) + 1 :] in files |
| 77 | if not matched: |
| 78 | dead.append((rule["name"], cond, "matches no tracked file")) |
| 79 | return dead |
| 80 | |
| 81 | |
| 82 | def main() -> int: |
| 83 | dead = check(tracked_files()) |
| 84 | if not dead: |
| 85 | return 0 |
| 86 | print(f"{len(dead)} label condition(s) match nothing:\n", file=sys.stderr) |
| 87 | for name, cond, why in dead: |
| 88 | print(f" [{name}] {cond}\n {why}", file=sys.stderr) |
| 89 | print( |
| 90 | "\nRepoint the condition at the file's new location, or remove it.", |
| 91 | file=sys.stderr, |
| 92 | ) |
| 93 | return 1 |
| 94 | |
| 95 | |
| 96 | if __name__ == "__main__": |
| 97 | raise SystemExit(main()) |