Files
cleveragents-core/scripts/tdd_quality_gate.py
CoreRasurae e8d2f76466
CI / push-validation (pull_request) Successful in 45s
CI / helm (pull_request) Successful in 57s
CI / build (pull_request) Successful in 1m8s
CI / lint (pull_request) Successful in 1m31s
CI / tdd_quality_gate (pull_request) Failing after 1m26s
CI / quality (pull_request) Successful in 1m30s
CI / typecheck (pull_request) Successful in 1m43s
CI / security (pull_request) Successful in 1m42s
CI / e2e_tests (pull_request) Failing after 4m10s
CI / integration_tests (pull_request) Successful in 5m2s
CI / unit_tests (pull_request) Successful in 6m6s
CI / docker (pull_request) Successful in 1m32s
CI / coverage (pull_request) Successful in 12m12s
CI / status-check (push) Blocked by required conditions
CI / status-check (pull_request) Failing after 3s
CI / tdd_quality_gate (push) Has been skipped
CI / benchmark-regression (push) Failing after 1m8s
CI / build (push) Successful in 1m2s
CI / lint (push) Successful in 1m16s
CI / helm (push) Successful in 45s
CI / push-validation (push) Successful in 45s
CI / quality (push) Successful in 1m37s
CI / typecheck (push) Successful in 2m1s
CI / security (push) Successful in 2m1s
CI / integration_tests (push) Successful in 3m34s
CI / unit_tests (push) Successful in 5m14s
CI / docker (push) Successful in 1m37s
CI / coverage (push) Failing after 19m17s
CI / benchmark-publish (push) Successful in 1h20m43s
CI / e2e_tests (push) Successful in 4m13s
feat(ci): implement TDD bug tag quality gate for bug fix PRs
Add an automated quality gate that enforces TDD bug fix workflow rules
on pull requests. The gate parses PR descriptions for bug-closing
keywords (Closes/Fixes/Resolves #N, ISSUES CLOSED: #N), searches the
codebase for corresponding TDD tests tagged @tdd_bug_N, and verifies
that @tdd_expected_fail tags have been removed.

Key components:
- scripts/tdd_quality_gate.py: Main quality gate script with PR
  description parsing, TDD test discovery, and tag removal verification.
  All public functions validate arguments fail-fast and are statically
  typed.
- noxfile.py: New tdd_quality_gate session that reads PR_DESCRIPTION
  from the environment and runs the quality gate script.
- .forgejo/workflows/ci.yml: New tdd_quality_gate CI job that runs
  only on pull_request events, passing the PR body as PR_DESCRIPTION.
- features/tdd_quality_gate.feature: 46 Behave scenarios covering PR
  parsing, TDD test search, tag removal verification, full gate logic,
  robot diff handling, edge cases, argument validation, bool guards,
  co-located bug false-positive guard, and main() CLI entry point.
- features/steps/tdd_quality_gate_steps.py: Step definitions for all
  Behave scenarios using temporary directories for isolation.
- robot/tdd_quality_gate.robot: 15 Robot Framework integration tests
  exercising the gate end-to-end via a helper subprocess.
- robot/helper_tdd_quality_gate.py: Helper script for Robot tests with
  sentinel-based sub-commands.

Review-round fixes applied:
- check_expected_fail_removed now uses _contains_tag_token for
  word-boundary matching (avoids false positives on partial tag names)
- Diff expected-fail removal detection tracks flags at file level
  instead of per-hunk (fixes false negatives when tags span hunks)
- parse_bug_refs filters out issue number zero
- Redundant double error reporting eliminated (file-level check
  short-circuits the diff-level check)
- run_quality_gate returns (errors, bug_refs) tuple to avoid
  redundant re-parsing in main()
- Regex compilation cached via functools.lru_cache
- Nox session no longer installs the full project (stdlib only)
- CI checkout uses fetch-depth: 0 for reliable merge-base resolution

Review-round 2 fixes applied:
- _diff_has_expected_fail_removal_for_bug now requires the removed
  line to contain both the expected-fail tag and the specific bug tag
  (fixes false positives when two bugs share the same test file)
- check_expected_fail_removed error messages use the correct tag
  prefix per file type (@tdd_bug_N for .feature, tdd_bug_N for .robot)
- bool values rejected by bug-number validation guards in
  find_tdd_tests, check_expected_fail_removed, and
  _diff_has_expected_fail_removal_for_bug
- File-read error handling catches UnicodeDecodeError alongside OSError
  (root-safe unreadable-file handling via invalid-UTF-8 test fixture)
- Temp directory cleanup added to after_scenario hook in environment.py
- 8 new Behave scenarios: bool type guards (2), co-located bug
  false-positive regression (1), run_quality_gate argument validation
  (3), and main() CLI entry point exit codes (2)

Review-round 3 fixes applied:
- Synthetic PR diff helper (_default_pr_diff_for_bug_refs) now
  auto-detects .robot vs .feature file type from the temp search
  tree and generates the matching diff format (fixes under-tested
  robot-format diff code path in multi-bug integration scenarios)
- check_expected_fail_removed test step now filters files by bug
  tag via find_tdd_tests before checking (matches production path
  in run_quality_gate)
- after_scenario temp directory cleanup no longer sets
  context.temp_dir = None (fixes cleanup conflict with
  cli_init_yes_flag_steps.py cleanup functions that run after hooks)
- 2 new Behave scenarios: multi-line PR description parsing, and
  non-string pr_diff type guard for run_quality_gate

ISSUES CLOSED: #629
2026-05-12 00:22:49 +01:00

403 lines
13 KiB
Python

#!/usr/bin/env python3
"""TDD bug tag quality gate for bug fix PRs.
Enforces the TDD bug fix workflow rules described in CONTRIBUTING.md:
1. Parses the PR description for closing keywords that reference bug issues
(``Closes #N``, ``Fixes #N``, ``Resolves #N``, ``ISSUES CLOSED: #N``).
2. Searches the codebase for tests tagged ``@tdd_bug_N`` (Behave ``.feature``
files) or ``tdd_bug_N`` (Robot ``.robot`` files).
3. Verifies that every such test has had its ``@tdd_expected_fail`` /
``tdd_expected_fail`` tag removed — the fix PR must remove the
expected-fail marker as proof the bug is now fixed.
Exit codes:
0 — All checks passed (or PR references no bugs).
1 — One or more violations detected.
Usage:
PR_DESCRIPTION="Fixes #42" python scripts/tdd_quality_gate.py
Or via nox::
PR_DESCRIPTION="Fixes #42" nox -s tdd_quality_gate
"""
from __future__ import annotations
import functools
import os
import re
import subprocess
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# PR description parsing
# ---------------------------------------------------------------------------
# Matches: Closes #N, Fixes #N, Resolves #N (case-insensitive)
_CLOSING_KEYWORD_RE = re.compile(
r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s+#(\d+)",
re.IGNORECASE,
)
# Matches: ISSUES CLOSED: #N, #M, ...
_ISSUES_CLOSED_RE = re.compile(
r"ISSUES\s+CLOSED\s*:\s*((?:#\d+[\s,]*)+)",
re.IGNORECASE,
)
# Extracts individual issue numbers from the ISSUES CLOSED value
_ISSUE_NUMBER_RE = re.compile(r"#(\d+)")
@functools.lru_cache(maxsize=64)
def _tag_token_pattern(tag: str) -> re.Pattern[str]:
"""Return a compiled regex that matches ``tag`` as a full token."""
escaped = re.escape(tag)
return re.compile(rf"(?<![A-Za-z0-9_]){escaped}(?![A-Za-z0-9_])")
def _contains_tag_token(content: str, tag: str) -> bool:
"""Return True when ``tag`` appears as a full token in ``content``."""
return _tag_token_pattern(tag).search(content) is not None
def _collect_pr_diff(search_root: Path, base_ref: str) -> str:
"""Collect unified diff between the PR branch and the base branch."""
if not isinstance(search_root, Path):
raise TypeError(f"search_root must be a Path, got {type(search_root).__name__}")
if not isinstance(base_ref, str):
raise TypeError(f"base_ref must be a str, got {type(base_ref).__name__}")
if base_ref.strip() == "":
raise ValueError("base_ref must not be empty")
ranges = (f"origin/{base_ref}...HEAD", f"{base_ref}...HEAD")
for ref_range in ranges:
try:
proc = subprocess.run(
[
"git",
"diff",
"--no-color",
ref_range,
"--",
"*.feature",
"*.robot",
],
cwd=search_root,
check=True,
capture_output=True,
text=True,
)
return proc.stdout
except (OSError, subprocess.CalledProcessError):
continue
raise RuntimeError(
"Unable to compute PR diff against base branch. "
"Ensure git history for the base branch is available and retry."
)
def _diff_has_expected_fail_removal_for_bug(pr_diff: str, bug_number: int) -> bool:
"""Return True when PR diff removes expected-fail for ``bug_number``."""
if not isinstance(pr_diff, str):
raise TypeError(f"pr_diff must be a str, got {type(pr_diff).__name__}")
if (
isinstance(bug_number, bool)
or not isinstance(bug_number, int)
or bug_number < 1
):
raise ValueError(f"bug_number must be a positive integer, got {bug_number!r}")
current_suffix = ""
in_hunk = False
file_has_bug_tag = False
file_removed_expected_fail = False
for line in pr_diff.splitlines():
if line.startswith("+++ "):
if file_has_bug_tag and file_removed_expected_fail:
return True
raw_path = line[4:]
if raw_path.startswith("b/"):
raw_path = raw_path[2:]
current_suffix = Path(raw_path).suffix.lower()
in_hunk = False
file_has_bug_tag = False
file_removed_expected_fail = False
continue
if current_suffix not in {".feature", ".robot"}:
continue
if line.startswith("@@"):
in_hunk = True
continue
if not in_hunk:
continue
if not line or line[0] not in {" ", "+", "-"}:
continue
content = line[1:]
if current_suffix == ".feature":
bug_tag = f"@tdd_bug_{bug_number}"
expected_fail_tag = "@tdd_expected_fail"
else:
bug_tag = f"tdd_bug_{bug_number}"
expected_fail_tag = "tdd_expected_fail"
if _contains_tag_token(content, bug_tag):
file_has_bug_tag = True
if (
line[0] == "-"
and _contains_tag_token(content, expected_fail_tag)
and _contains_tag_token(content, bug_tag)
):
file_removed_expected_fail = True
return file_has_bug_tag and file_removed_expected_fail
def parse_bug_refs(pr_description: str) -> list[int]:
"""Extract bug issue numbers from PR closing keywords.
Recognises ``Closes #N``, ``Fixes #N``, ``Resolves #N``
(case-insensitive) and ``ISSUES CLOSED: #N, #M``.
Returns a deduplicated, sorted list of issue numbers.
"""
if not isinstance(pr_description, str):
raise TypeError(
f"pr_description must be a str, got {type(pr_description).__name__}"
)
refs: set[int] = set()
# Standard closing keywords
for match in _CLOSING_KEYWORD_RE.finditer(pr_description):
num = int(match.group(1))
if num > 0:
refs.add(num)
# ISSUES CLOSED: #N, #M block
for block_match in _ISSUES_CLOSED_RE.finditer(pr_description):
block = block_match.group(1)
for num_match in _ISSUE_NUMBER_RE.finditer(block):
num = int(num_match.group(1))
if num > 0:
refs.add(num)
return sorted(refs)
# ---------------------------------------------------------------------------
# TDD test search
# ---------------------------------------------------------------------------
def find_tdd_tests(
bug_number: int,
search_root: Path,
) -> list[Path]:
"""Find test files tagged with ``@tdd_bug_<bug_number>``.
Searches ``.feature`` files for ``@tdd_bug_<N>`` and ``.robot``
files for ``tdd_bug_<N>``.
Returns a list of paths that contain the tag.
"""
if (
isinstance(bug_number, bool)
or not isinstance(bug_number, int)
or bug_number < 1
):
raise ValueError(f"bug_number must be a positive integer, got {bug_number!r}")
if not isinstance(search_root, Path):
raise TypeError(f"search_root must be a Path, got {type(search_root).__name__}")
tag_behave = f"@tdd_bug_{bug_number}"
tag_robot = f"tdd_bug_{bug_number}"
matches: list[Path] = []
# Search .feature files
for feature_file in sorted(search_root.rglob("*.feature")):
try:
content = feature_file.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
if _contains_tag_token(content, tag_behave):
matches.append(feature_file)
# Search .robot files
for robot_file in sorted(search_root.rglob("*.robot")):
try:
content = robot_file.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
if _contains_tag_token(content, tag_robot):
matches.append(robot_file)
return matches
# ---------------------------------------------------------------------------
# Tag removal verification
# ---------------------------------------------------------------------------
def check_expected_fail_removed(
test_files: list[Path],
bug_number: int,
) -> list[str]:
"""Verify ``@tdd_expected_fail`` has been removed from test files.
Returns a list of error messages for files that still contain the
expected-fail tag.
"""
if not isinstance(test_files, list):
raise TypeError(f"test_files must be a list, got {type(test_files).__name__}")
if (
isinstance(bug_number, bool)
or not isinstance(bug_number, int)
or bug_number < 1
):
raise ValueError(f"bug_number must be a positive integer, got {bug_number!r}")
errors: list[str] = []
for path in test_files:
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
suffix = path.suffix.lower()
if suffix == ".feature":
tag = "@tdd_expected_fail"
elif suffix == ".robot":
tag = "tdd_expected_fail"
else:
continue
if _contains_tag_token(content, tag):
bug_tag_display = (
f"@tdd_bug_{bug_number}"
if suffix == ".feature"
else f"tdd_bug_{bug_number}"
)
errors.append(
f"Bug fix PR must remove the {tag} tag from tests tagged "
f"{bug_tag_display}. "
f"See CONTRIBUTING.md > Bug Fix Workflow."
)
return errors
# ---------------------------------------------------------------------------
# Main gate logic
# ---------------------------------------------------------------------------
def run_quality_gate(
pr_description: str,
search_root: Path,
*,
pr_diff: str | None = None,
base_ref: str = "master",
) -> tuple[list[str], list[int]]:
"""Run the full TDD quality gate.
Returns a ``(errors, bug_refs)`` tuple. An empty error list means all
checks passed. ``bug_refs`` contains the parsed bug issue numbers so
callers can avoid re-parsing the PR description.
"""
if not isinstance(pr_description, str):
raise TypeError(
f"pr_description must be a str, got {type(pr_description).__name__}"
)
if not isinstance(search_root, Path):
raise TypeError(f"search_root must be a Path, got {type(search_root).__name__}")
if pr_diff is not None and not isinstance(pr_diff, str):
raise TypeError(f"pr_diff must be a str or None, got {type(pr_diff).__name__}")
if not isinstance(base_ref, str):
raise TypeError(f"base_ref must be a str, got {type(base_ref).__name__}")
if base_ref.strip() == "":
raise ValueError("base_ref must not be empty")
bug_refs = parse_bug_refs(pr_description)
if not bug_refs:
return [], bug_refs
if pr_diff is None:
try:
pr_diff = _collect_pr_diff(search_root, base_ref)
except RuntimeError as exc:
return [str(exc)], bug_refs
all_errors: list[str] = []
for bug_num in bug_refs:
test_files = find_tdd_tests(bug_num, search_root)
if not test_files:
all_errors.append(
f"No TDD test found for bug #{bug_num}. "
f"The TDD workflow requires a test tagged @tdd_bug_{bug_num} "
f"to exist before the bug can be fixed. "
f"See CONTRIBUTING.md > Bug Fix Workflow."
)
continue
removal_errors = check_expected_fail_removed(test_files, bug_num)
all_errors.extend(removal_errors)
# Only check the diff when the file-level check found no tag issues;
# otherwise the diff error would be redundant.
if not removal_errors and not _diff_has_expected_fail_removal_for_bug(
pr_diff, bug_num
):
all_errors.append(
"No removal of @tdd_expected_fail / tdd_expected_fail detected "
f"in PR diff for bug #{bug_num}. "
"The bug fix PR must remove the expected-fail tag in this branch. "
"See CONTRIBUTING.md > Bug Fix Workflow."
)
return all_errors, bug_refs
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def main() -> int:
"""CLI entry point. Reads PR_DESCRIPTION from the environment."""
pr_description = os.environ.get("PR_DESCRIPTION", "")
base_ref = os.environ.get("PR_BASE_REF", "master")
search_root = Path.cwd()
errors, bug_refs = run_quality_gate(pr_description, search_root, base_ref=base_ref)
if errors:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
return 1
if bug_refs:
print(f"TDD quality gate passed for bug(s): {bug_refs}")
else:
print("TDD quality gate: no bug references found in PR description (pass).")
return 0
if __name__ == "__main__":
sys.exit(main())