Skip to content

Commit 9c70417

Browse files
committed
Require test evidence for SDK source changes
Coverage thresholds do not see omitted modules such as integration and server, so SDK source changes need an explicit per-change evidence gate. Wire the gate into coverage and a PR workflow, requiring matching unit and e2e test changes for each changed agentrun source file. Constraint: GitHub Python SDK repository uses repository-root paths, not monorepo python/ paths. Rejected: Relying on global or incremental coverage alone | coverage omits important SDK directories and is not per-file enforcement. Confidence: high Scope-risk: moderate Directive: Keep coverage thresholds and this evidence gate separate; omitted coverage files still need unit and e2e evidence. Tested: uv run --python 3.10 --all-extras isort scripts/check_test_evidence.py tests/unittests/test_quality_gate.py Tested: uv run --python 3.10 --all-extras pyink --config pyproject.toml scripts/check_test_evidence.py tests/unittests/test_quality_gate.py Tested: uv run --python 3.10 --all-extras pytest tests/unittests/test_quality_gate.py Tested: uv run python scripts/check_test_evidence.py simulated missing/unit-only/e2e-only/unit-plus-e2e cases Change-Id: I6241b508960ee114f344ca2de5302c3fbdd80b92 Not-tested: Full make coverage on the GitHub checkout; the change was validated with targeted gate tests and simulations. Signed-off-by: congxiao.wxx <congxiao.wxx@alibaba-inc.com>
1 parent a26d8b6 commit 9c70417

5 files changed

Lines changed: 516 additions & 2 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Python Quality
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- 'Makefile'
7+
- 'agentrun/**'
8+
- 'tests/**'
9+
- 'scripts/check_test_evidence.py'
10+
- '.github/workflows/python-quality.yml'
11+
workflow_dispatch:
12+
13+
permissions:
14+
contents: read
15+
16+
jobs:
17+
quality-gate:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Checkout
21+
uses: actions/checkout@v4
22+
with:
23+
fetch-depth: 0
24+
25+
- name: Setup Python
26+
uses: actions/setup-python@v5
27+
with:
28+
python-version: '3.10'
29+
30+
- name: Install uv
31+
run: python -m pip install uv
32+
33+
- name: Install dependencies
34+
run: uv sync --python 3.10 --dev --all-extras
35+
36+
- name: Run SDK test evidence gate
37+
run: uv run python scripts/check_test_evidence.py --base-ref "origin/${{ github.base_ref || 'main' }}"
38+
39+
- name: Run quality gate tests
40+
run: uv run pytest tests/unittests/test_quality_gate.py

‎Makefile‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,15 @@ test-unit: ## 运行单元测试
142142
test-e2e: ## 运行端到端测试
143143
@uv run pytest tests/e2e/
144144

145+
.PHONY: quality-gate
146+
quality-gate: ## 运行 SDK 增量质量门禁
147+
@uv run --python ${PYTHON_VERSION} --all-extras python scripts/check_test_evidence.py
148+
145149
.PHONY: mypy-check
146150
mypy-check: ## 运行 mypy 类型检查
147151
@uv run mypy --config-file mypy.ini .
148152

149153
.PHONY: coverage
150-
coverage: ## 运行测试并显示覆盖率报告(全量代码 + 增量代码)
154+
coverage: quality-gate ## 运行测试并显示覆盖率报告(全量代码 + 增量代码)
151155
@echo "📊 运行覆盖率测试..."
152156
@uv run --python ${PYTHON_VERSION} --all-extras python scripts/check_coverage.py $(COVERAGE_ARGS)
153-

‎README.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,3 +223,25 @@ SDK 会自动读取以下环境变量:
223223
| `AGENTRUN_DATA_ENDPOINT` | 数据端点 | - |
224224
| `AGENTRUN_SDK_DEBUG` | 开启 DEBUG 日志 | - |
225225

226+
## 开发质量门禁
227+
228+
PR 中修改 `agentrun/**/*.py` 时,必须在同一个变更里提供匹配的单元测试
229+
证据和 E2E 测试证据。这个门禁独立于 coverage 统计,即使文件位于
230+
coverage omit 目录(例如 `agentrun/integration/*` 或
231+
`agentrun/server/*`),也会被检查。
232+
233+
本地运行:
234+
235+
```bash
236+
make quality-gate
237+
```
238+
239+
匹配规则示例:
240+
241+
- `agentrun/integration/...` 变更需要 `tests/unittests/integration/`
242+
或匹配的单测文件变更,同时需要 `tests/e2e/test_integration.py` 等 E2E
243+
文件变更。
244+
- `agentrun/server/...` 变更需要 `tests/unittests/server/`、
245+
`tests/unittests/**/test_server.py` 等单测变更,同时需要
246+
`tests/e2e/test_server.py` 等 E2E 变更。
247+
- 非 SDK 源码路径(如 `examples/`、`docs/`、`codegen/`)不触发此门禁。

‎scripts/check_test_evidence.py‎

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
#!/usr/bin/env python3
2+
"""Require test evidence for changed SDK source files."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
from dataclasses import dataclass
8+
from pathlib import PurePosixPath
9+
import subprocess
10+
import sys
11+
from typing import Iterable, Sequence
12+
13+
SDK_ROOT = PurePosixPath("agentrun")
14+
UNITTEST_ROOT = PurePosixPath("tests/unittests")
15+
E2E_ROOT = PurePosixPath("tests/e2e")
16+
DEFAULT_BASE_REF = "origin/main"
17+
18+
19+
@dataclass(frozen=True)
20+
class ChangedFile:
21+
status: str
22+
path: str
23+
24+
25+
@dataclass(frozen=True)
26+
class MissingTestEvidence:
27+
source_path: str
28+
evidence_type: str
29+
expected_patterns: tuple[str, ...]
30+
31+
32+
def is_sdk_source(path: str) -> bool:
33+
posix_path = PurePosixPath(path)
34+
return (
35+
path.endswith(".py")
36+
and posix_path.is_relative_to(SDK_ROOT)
37+
and "__pycache__" not in posix_path.parts
38+
)
39+
40+
41+
def is_unit_test_evidence(path: str) -> bool:
42+
return is_test_evidence(path, UNITTEST_ROOT)
43+
44+
45+
def is_e2e_test_evidence(path: str) -> bool:
46+
return is_test_evidence(path, E2E_ROOT)
47+
48+
49+
def is_test_evidence(path: str, test_root: PurePosixPath) -> bool:
50+
posix_path = PurePosixPath(path)
51+
return path.endswith(".py") and posix_path.is_relative_to(test_root)
52+
53+
54+
def _module_parts(source_path: str) -> tuple[str, ...]:
55+
relative = PurePosixPath(source_path).relative_to(SDK_ROOT)
56+
if relative.name == "__init__.py":
57+
return relative.parent.parts
58+
return relative.with_suffix("").parts
59+
60+
61+
def expected_test_patterns(
62+
source_path: str, test_root: PurePosixPath
63+
) -> tuple[str, ...]:
64+
module_parts = _module_parts(source_path)
65+
if not module_parts:
66+
return (f"{test_root}/test_agentrun.py",)
67+
68+
top_level = module_parts[0]
69+
leaf = module_parts[-1]
70+
nearest_package = module_parts[-2] if len(module_parts) > 1 else top_level
71+
patterns = [
72+
f"{test_root}/{top_level}/",
73+
f"{test_root}/test_{top_level}.py",
74+
f"{test_root}/**/test_{top_level}.py",
75+
f"{test_root}/**/test_{nearest_package}.py",
76+
f"{test_root}/**/test_{leaf}.py",
77+
]
78+
return tuple(dict.fromkeys(patterns))
79+
80+
81+
def has_matching_test_evidence(
82+
source_path: str,
83+
changed_test_paths: Iterable[str],
84+
test_root: PurePosixPath,
85+
) -> bool:
86+
module_parts = _module_parts(source_path)
87+
if not module_parts:
88+
return any(
89+
is_test_evidence(path, test_root) for path in changed_test_paths
90+
)
91+
92+
top_level = module_parts[0]
93+
leaf = module_parts[-1]
94+
nearest_package = module_parts[-2] if len(module_parts) > 1 else top_level
95+
acceptable_names = {
96+
f"test_{top_level}.py",
97+
f"test_{nearest_package}.py",
98+
f"test_{leaf}.py",
99+
}
100+
101+
for path in changed_test_paths:
102+
posix_path = PurePosixPath(path)
103+
if not is_test_evidence(path, test_root):
104+
continue
105+
if posix_path.name in acceptable_names:
106+
return True
107+
if posix_path.is_relative_to(test_root / top_level):
108+
return True
109+
return False
110+
111+
112+
def find_missing_test_evidence(
113+
changed_files: Sequence[ChangedFile],
114+
) -> list[MissingTestEvidence]:
115+
changed_sources = [
116+
changed.path
117+
for changed in changed_files
118+
if changed.status != "D" and is_sdk_source(changed.path)
119+
]
120+
changed_tests = [
121+
changed.path
122+
for changed in changed_files
123+
if changed.status != "D"
124+
and (
125+
is_unit_test_evidence(changed.path)
126+
or is_e2e_test_evidence(changed.path)
127+
)
128+
]
129+
130+
missing: list[MissingTestEvidence] = []
131+
required_evidence = (
132+
("unit", UNITTEST_ROOT),
133+
("e2e", E2E_ROOT),
134+
)
135+
for source_path in changed_sources:
136+
for evidence_type, test_root in required_evidence:
137+
if has_matching_test_evidence(
138+
source_path, changed_tests, test_root
139+
):
140+
continue
141+
missing.append(
142+
MissingTestEvidence(
143+
source_path=source_path,
144+
evidence_type=evidence_type,
145+
expected_patterns=expected_test_patterns(
146+
source_path, test_root
147+
),
148+
)
149+
)
150+
return missing
151+
152+
153+
def parse_changed_file_specs(specs: Sequence[str]) -> list[ChangedFile]:
154+
changed_files: list[ChangedFile] = []
155+
for spec in specs:
156+
if not spec:
157+
continue
158+
if ":" in spec and spec.split(":", 1)[0] in {
159+
"A",
160+
"C",
161+
"D",
162+
"M",
163+
"R",
164+
"T",
165+
}:
166+
status, path = spec.split(":", 1)
167+
else:
168+
status, path = "M", spec
169+
changed_files.append(ChangedFile(status=status[0], path=path.strip()))
170+
return changed_files
171+
172+
173+
def _run_git(args: Sequence[str]) -> str:
174+
result = subprocess.run(
175+
["git", *args],
176+
check=False,
177+
stdout=subprocess.PIPE,
178+
stderr=subprocess.PIPE,
179+
text=True,
180+
)
181+
if result.returncode != 0:
182+
raise RuntimeError(result.stderr.strip() or "git command failed")
183+
return result.stdout
184+
185+
186+
def _parse_git_name_status(output: str) -> list[ChangedFile]:
187+
changed_files: list[ChangedFile] = []
188+
for line in output.splitlines():
189+
if not line.strip():
190+
continue
191+
fields = line.split("\t")
192+
status = fields[0][0]
193+
path = fields[-1]
194+
changed_files.append(ChangedFile(status=status, path=path))
195+
return changed_files
196+
197+
198+
def git_ref_exists(ref: str) -> bool:
199+
result = subprocess.run(
200+
["git", "rev-parse", "--verify", "--quiet", ref],
201+
check=False,
202+
stdout=subprocess.DEVNULL,
203+
stderr=subprocess.DEVNULL,
204+
)
205+
return result.returncode == 0
206+
207+
208+
def get_changed_files(base_ref: str) -> list[ChangedFile]:
209+
changed: list[ChangedFile] = []
210+
if not git_ref_exists(base_ref):
211+
raise RuntimeError(
212+
f"Base ref {base_ref!r} was not found. Fetch the PR base ref or "
213+
"pass explicit --changed-file entries."
214+
)
215+
base = _run_git(["merge-base", base_ref, "HEAD"]).strip()
216+
changed.extend(
217+
_parse_git_name_status(
218+
_run_git([
219+
"diff",
220+
"--name-status",
221+
"--diff-filter=ACMRT",
222+
base,
223+
"HEAD",
224+
])
225+
)
226+
)
227+
228+
changed.extend(
229+
_parse_git_name_status(
230+
_run_git(
231+
["diff", "--name-status", "--diff-filter=ACMRT", "HEAD", "--"]
232+
)
233+
)
234+
)
235+
236+
deduped: dict[str, ChangedFile] = {}
237+
for changed_file in changed:
238+
deduped[changed_file.path] = changed_file
239+
return list(deduped.values())
240+
241+
242+
def build_parser() -> argparse.ArgumentParser:
243+
parser = argparse.ArgumentParser(
244+
description=(
245+
"Fail when changed agentrun source files do not have "
246+
"matching unit and e2e test evidence in the same change."
247+
)
248+
)
249+
parser.add_argument(
250+
"--base-ref",
251+
default=DEFAULT_BASE_REF,
252+
help=(
253+
"Git base ref for committed PR changes. Default:"
254+
f" {DEFAULT_BASE_REF}"
255+
),
256+
)
257+
parser.add_argument(
258+
"--changed-file",
259+
action="append",
260+
default=[],
261+
metavar="[STATUS:]PATH",
262+
help=(
263+
"Explicit changed file for tests or custom CI integrations. "
264+
"Status defaults to M."
265+
),
266+
)
267+
return parser
268+
269+
270+
def main(argv: Sequence[str] | None = None) -> int:
271+
args = build_parser().parse_args(argv)
272+
try:
273+
changed_files = (
274+
parse_changed_file_specs(args.changed_file)
275+
if args.changed_file
276+
else get_changed_files(args.base_ref)
277+
)
278+
except RuntimeError as exc:
279+
print(
280+
f"SDK test evidence gate could not inspect git diff: {exc}",
281+
file=sys.stderr,
282+
)
283+
return 2
284+
285+
missing = find_missing_test_evidence(changed_files)
286+
if not missing:
287+
print("SDK test evidence gate passed.")
288+
return 0
289+
290+
print("SDK test evidence gate failed.", file=sys.stderr)
291+
print(
292+
"Every changed agentrun/**/*.py file must include matching "
293+
"tests/unittests and tests/e2e evidence.",
294+
file=sys.stderr,
295+
)
296+
for item in missing:
297+
print(
298+
f"\nMissing {item.evidence_type} test evidence for:"
299+
f" {item.source_path}",
300+
file=sys.stderr,
301+
)
302+
print("Expected one of:", file=sys.stderr)
303+
for pattern in item.expected_patterns:
304+
print(f" - {pattern}", file=sys.stderr)
305+
return 1
306+
307+
308+
if __name__ == "__main__":
309+
raise SystemExit(main())

0 commit comments

Comments
 (0)