128 lines
3.4 KiB
Python
128 lines
3.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate issue QA workflow artifacts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
ISSUES_DIR = ROOT / "issues"
|
|
|
|
REQUIRED_SECTIONS = (
|
|
"Title",
|
|
"Severity",
|
|
"Environment",
|
|
"Summary",
|
|
"Reproduction Steps",
|
|
"Expected Result",
|
|
"Actual Result",
|
|
"Root Cause Analysis",
|
|
"Fix Plan",
|
|
"Implementation Notes",
|
|
"Verification",
|
|
"Status History",
|
|
"Closure",
|
|
)
|
|
|
|
CLOSED_STATES = {"Verification Passed", "Closed"}
|
|
|
|
|
|
def issue_files() -> list[Path]:
|
|
return sorted(ISSUES_DIR.glob("issue[0-9]*.md"))
|
|
|
|
|
|
def has_section(content: str, name: str) -> bool:
|
|
pattern = rf"(?m)^##\s+{re.escape(name)}\s*$"
|
|
return bool(re.search(pattern, content))
|
|
|
|
|
|
def current_status(content: str) -> str | None:
|
|
match = re.search(r"(?m)^- Current Status:\s*`?([^`\n]+)`?\s*$", content)
|
|
return match.group(1).strip() if match else None
|
|
|
|
|
|
def screenshot_refs(content: str) -> list[str]:
|
|
pattern = r"issues/screenshots/[A-Za-z0-9._-]+\.png"
|
|
return sorted(set(re.findall(pattern, content)))
|
|
|
|
|
|
def has_before_ref(refs: list[str], issue_num: str) -> bool:
|
|
legacy = f"issues/screenshots/issue{issue_num}.png"
|
|
return any("-before-" in ref for ref in refs) or legacy in refs
|
|
|
|
|
|
def has_after_ref(refs: list[str]) -> bool:
|
|
return any("-after-" in ref for ref in refs)
|
|
|
|
|
|
def command_check_present(content: str) -> bool:
|
|
required = (
|
|
"`cargo check --workspace`",
|
|
"`cargo test --workspace`",
|
|
"`just qa-validate`",
|
|
)
|
|
return all(token in content for token in required)
|
|
|
|
|
|
def verify_issue(path: Path) -> list[str]:
|
|
errors: list[str] = []
|
|
content = path.read_text(encoding="utf-8")
|
|
issue_num_match = re.search(r"issue([0-9]+)\.md$", path.name)
|
|
issue_num = issue_num_match.group(1) if issue_num_match else "?"
|
|
|
|
for section in REQUIRED_SECTIONS:
|
|
if not has_section(content, section):
|
|
errors.append(f"{path}: missing section '## {section}'")
|
|
|
|
refs = screenshot_refs(content)
|
|
for ref in refs:
|
|
if not (ROOT / ref).exists():
|
|
errors.append(f"{path}: missing screenshot file: {ref}")
|
|
|
|
if not has_before_ref(refs, issue_num):
|
|
errors.append(
|
|
f"{path}: missing before screenshot reference "
|
|
"(use issueN-before-...png or legacy issueN.png)"
|
|
)
|
|
|
|
status = current_status(content)
|
|
if status in CLOSED_STATES and not has_after_ref(refs):
|
|
errors.append(
|
|
f"{path}: status '{status}' requires at least one after screenshot reference"
|
|
)
|
|
|
|
if status in CLOSED_STATES and not command_check_present(content):
|
|
errors.append(
|
|
f"{path}: status '{status}' requires command checklist entries for "
|
|
"cargo check, cargo test, and just qa-validate"
|
|
)
|
|
|
|
return errors
|
|
|
|
|
|
def main() -> int:
|
|
files = issue_files()
|
|
if not files:
|
|
print("error: no issue files found under issues/", file=sys.stderr)
|
|
return 1
|
|
|
|
failures: list[str] = []
|
|
for path in files:
|
|
failures.extend(verify_issue(path))
|
|
|
|
if failures:
|
|
for failure in failures:
|
|
print(f"error: {failure}", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"qa validation passed ({len(files)} issue file(s) checked)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|
|
|