-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcheck_tests_present.py
More file actions
189 lines (155 loc) · 6.51 KB
/
check_tests_present.py
File metadata and controls
189 lines (155 loc) · 6.51 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
#!/usr/bin/env python3
"""Verify behaviour-changing PRs ship tests alongside the src/ change.
`docs/DEVELOPMENT.md` *Testing Policy*: every PR that adds or changes
behaviour must include tests. Today only the 75% coverage gate
approximates this — a PR can drop coverage from 95% to 76% on
uncovered new code and still pass. This gate adds direct enforcement.
Behaviour:
- Reads `git diff --name-only origin/<base>...HEAD` for `src/**/*.py`
and `tests/**/*.py`.
- Determines the PR's commit-type prefix from the event payload's
`pull_request.title` (same source as `check_version_bump.py`).
- If `src/**/*.py` files changed AND no `tests/**/*.py` file was
touched in the same diff:
- **Block (exit 1)** when the prefix is `feat:` or `fix:` — these
types declare a behaviour change that the policy says must come
with tests.
- **Warn (exit 0)** for `chore:`, `docs:`, `refactor:`, `test:`,
`release:` — these may legitimately touch `src/` without new
tests (config rename, internal-only refactor, doc-string fix).
Exit codes:
0 — pass (or warn-only)
1 — block: behaviour-change PR shipped without tests
2 — script-level error (missing env, parse failure)
Usage (CI, on pull_request events):
python .github/scripts/check_tests_present.py
Required env (set automatically by GitHub Actions):
- ``GITHUB_BASE_REF`` — base branch name (e.g. "develop")
- ``GITHUB_EVENT_PATH`` — path to event JSON (for PR title)
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
EVENT_READ_ERRORS = (OSError, json.JSONDecodeError)
# Prefixes that declare a behaviour change → tests required.
BLOCKING_PREFIXES: frozenset[str] = frozenset({"feat", "fix"})
# Prefixes that may legitimately touch src/ without new tests → warn-only.
WARN_ONLY_PREFIXES: frozenset[str] = frozenset(
{"chore", "docs", "refactor", "test", "release"}
)
def pr_title_from_event() -> str | None:
"""Read the PR title from the GitHub Actions event payload, if present."""
event_path = os.environ.get("GITHUB_EVENT_PATH")
if not event_path:
return None
try:
data = json.loads(Path(event_path).read_text(encoding="utf-8"))
except EVENT_READ_ERRORS:
return None
pr = data.get("pull_request")
if not isinstance(pr, dict):
return None
title = pr.get("title")
return title if isinstance(title, str) else None
def commit_type_prefix(title: str | None) -> str | None:
"""Extract the conventional-commit type prefix from a PR title.
Examples: "feat: add tool" → "feat", "chore(api): rename" → "chore",
"release: v1.2.3" → "release". Returns None if the title doesn't
contain a colon (i.e. doesn't match the `type: subject` shape).
"""
if not title or ":" not in title:
return None
head = title.lstrip()
# Type ends at the first of "(", "!", ":" — whichever comes first.
# The colon is mandatory (already checked above), so we always find one.
earliest = len(head)
for terminator in ("(", "!", ":"):
idx = head.find(terminator)
if 0 < idx < earliest:
earliest = idx
return head[:earliest].lower() if earliest > 0 else None
def changed_files(base_ref: str) -> list[str]:
"""Return paths changed between `origin/<base_ref>` and HEAD."""
try:
out = subprocess.check_output( # noqa: S603 - args are CI-trusted
[ # noqa: S607 - git on PATH
"git",
"diff",
"--name-only",
f"origin/{base_ref}...HEAD",
],
text=True,
stderr=subprocess.PIPE,
)
except subprocess.CalledProcessError as e:
msg = (
f"git diff origin/{base_ref}...HEAD failed: "
f"{e.stderr.strip() or e.returncode}"
)
raise RuntimeError(msg) from e
return [line for line in out.splitlines() if line.strip()]
def _is_under(path: str, dirname: str) -> bool:
"""True when `path` is inside `<dirname>/`."""
return path.startswith(dirname + "/") or path == dirname
def main() -> int:
base_ref = os.environ.get("GITHUB_BASE_REF")
if not base_ref:
print("::warning::GITHUB_BASE_REF not set; skipping (not a PR run).")
return 0
title = pr_title_from_event()
prefix = commit_type_prefix(title)
try:
files = changed_files(base_ref)
except RuntimeError as e:
print(f"::error::{e}")
return 2
src_py = [f for f in files if _is_under(f, "src") and f.endswith(".py")]
tests_py = [f for f in files if _is_under(f, "tests") and f.endswith(".py")]
if not src_py:
print("No `src/**/*.py` changes — gate not applicable.")
return 0
if tests_py:
print(
f"src/ changed ({len(src_py)} file(s)); tests/ also touched "
f"({len(tests_py)} file(s)). OK."
)
return 0
# src/ changed, tests/ untouched — decide based on prefix.
src_summary = ", ".join(src_py[:5]) + (
f" (+{len(src_py) - 5} more)" if len(src_py) > 5 else ""
)
if prefix in BLOCKING_PREFIXES:
print(
f"::error::PR title prefix `{prefix}:` declares a behaviour "
"change but the diff touches `src/` without any "
"`tests/**/*.py` change. Per `docs/DEVELOPMENT.md` Testing "
"Policy, every behaviour-change PR ships tests.\n"
f" src/ files changed: {src_summary}\n"
" Fix: add or update a test in `tests/`, or restructure the "
"PR if the change is genuinely test-exempt (rename, comment-only, "
"internal refactor) — and use a different commit-type prefix "
f"({sorted(WARN_ONLY_PREFIXES)})."
)
return 1
if prefix in WARN_ONLY_PREFIXES:
print(
f"::warning::PR title prefix `{prefix}:` touches `src/` "
"without `tests/` changes. Allowed for this prefix; "
"double-check that no behaviour change slipped in.\n"
f" src/ files changed: {src_summary}"
)
return 0
# Unknown / missing prefix — fall through to warn-only. The
# `Lint PR title` and `Commit-type sync` gates already enforce
# the 7-prefix list elsewhere; this script doesn't duplicate that.
print(
f"::warning::PR title prefix not recognised ({prefix!r}); "
"tests-required gate skipped. The `Lint PR title` job is the "
"source of truth for prefix validation."
)
return 0
if __name__ == "__main__":
sys.exit(main())