GRM-55: fix: strengthen review process with deeper checks and structured checklist
This commit is contained in:
@@ -141,32 +141,18 @@ def has_approval_review(client: GiteaClient, pr_number: str) -> bool:
|
||||
"LGTM" or "OK"). This ensures the reviewer actually reviewed the PR
|
||||
rather than rubber-stamping it.
|
||||
|
||||
Falls back to checking that no REQUEST_CHANGES reviews are pending
|
||||
when self-approval is not possible (single-token workflow).
|
||||
Returns False if no APPROVE review is found — the caller should
|
||||
block the merge in that case.
|
||||
"""
|
||||
reviews = client.get_pr_reviews(pr_number)
|
||||
has_approved = False
|
||||
has_changes_requested = False
|
||||
|
||||
for r in reviews:
|
||||
state = r.get("state", "")
|
||||
if state == "APPROVED":
|
||||
body = str(r.get("body", "")).strip()
|
||||
if len(body) > 20 or r.get("comments", []):
|
||||
has_approved = True
|
||||
elif state == "REQUEST_CHANGES":
|
||||
has_changes_requested = True
|
||||
return True
|
||||
|
||||
if has_approved:
|
||||
return True
|
||||
# In single-token workflows, self-approval is not allowed.
|
||||
# Allow merge if no changes are requested (the automated pr-review
|
||||
# job and CI quality gate serve as the review enforcement).
|
||||
if not has_changes_requested:
|
||||
click.echo(
|
||||
_("No APPROVE review found, but no REQUEST_CHANGES either. Proceeding (single-token workflow fallback).")
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
+113
-3
@@ -10,9 +10,11 @@ Checks performed:
|
||||
2. Best practices — no bare except, no print() (use click.echo), no TODO/FIXME
|
||||
left in merged code, no functions > 50 lines
|
||||
3. Security — no secrets in code, no shell=True, no eval/exec
|
||||
4. Documentation — new CLI commands documented, new modules in architecture.md
|
||||
5. Test coverage — 100% enforced by pytest-cov (checked in quality job)
|
||||
6. Commit conventions — conventional commit format on branch commits
|
||||
4. i18n — no raw English strings in click.echo() without _() wrapper
|
||||
5. Resource management — no open() without with statement, no subprocess without cleanup
|
||||
6. Documentation — new CLI commands documented, new modules in architecture.md
|
||||
7. Test coverage — 100% enforced by pytest-cov (checked in quality job)
|
||||
8. Commit conventions — conventional commit format on branch commits
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 scripts/ci/pr_review.py <pr_number> <owner/repo>
|
||||
@@ -206,6 +208,112 @@ def check_security(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
result.add_summary("- Security: OK")
|
||||
|
||||
|
||||
def check_i18n(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check that user-facing strings are wrapped in _().
|
||||
|
||||
Detects ``click.echo()`` calls with raw string literals that are not
|
||||
wrapped in ``_()``. Only checks ``src/`` files, not tests or scripts.
|
||||
"""
|
||||
# Pattern: click.echo("...") or click.echo(f"...") without _() wrapper
|
||||
raw_echo_re = re.compile(r'click\.echo\s*\(\s*["\']([^"\']+)["\']')
|
||||
raw_fstring_re = re.compile(r'click\.echo\s*\(\s*f["\']')
|
||||
# Also check click.ClickException and raise with string
|
||||
raw_exception_re = re.compile(r'click\.ClickException\s*\(\s*["\']([^"\']+)["\']')
|
||||
|
||||
for f in files:
|
||||
path = f.get("filename", "")
|
||||
if not is_python_file(path) or not path.startswith("src/"):
|
||||
continue
|
||||
|
||||
patch = f.get("patch", "")
|
||||
if not patch:
|
||||
continue
|
||||
|
||||
lines = patch.split("\n")
|
||||
current_line = 0
|
||||
for line in lines:
|
||||
if line.startswith("@@"):
|
||||
match = re.search(r"\+(\d+)", line)
|
||||
if match:
|
||||
current_line = int(match.group(1)) - 1
|
||||
continue
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
current_line += 1
|
||||
content = line[1:]
|
||||
|
||||
# Skip comments and docstrings
|
||||
stripped = content.strip()
|
||||
if stripped.startswith("#") or stripped.startswith('"""') or stripped.startswith("'''"):
|
||||
continue
|
||||
|
||||
# Check for raw strings in click.echo without _()
|
||||
for regex, msg in [
|
||||
(raw_echo_re, "click.echo() with raw string — wrap in _() for i18n"),
|
||||
(raw_fstring_re, "click.echo() with f-string — wrap in _() for i18n"),
|
||||
(raw_exception_re, "ClickException with raw string — wrap in _() for i18n"),
|
||||
]:
|
||||
if regex.search(content):
|
||||
result.add_issue(path, current_line, msg, "warning")
|
||||
|
||||
if not any("i18n" in i["body"] for i in result.issues):
|
||||
result.add_summary("- i18n: OK")
|
||||
|
||||
|
||||
def check_resource_management(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check for resource leaks: open() without with, subprocess without cleanup.
|
||||
|
||||
Detects:
|
||||
- ``open()`` calls not in a ``with`` statement
|
||||
- ``subprocess.Popen()`` without ``.wait()`` or ``.communicate()``
|
||||
"""
|
||||
# Pattern: open("...") not preceded by "with" on the same line
|
||||
open_re = re.compile(r"(?<!with\s)\bopen\s*\(")
|
||||
popen_re = re.compile(r"subprocess\.Popen\s*\(")
|
||||
|
||||
for f in files:
|
||||
path = f.get("filename", "")
|
||||
if not is_python_file(path):
|
||||
continue
|
||||
|
||||
patch = f.get("patch", "")
|
||||
if not patch:
|
||||
continue
|
||||
|
||||
lines = patch.split("\n")
|
||||
current_line = 0
|
||||
for line in lines:
|
||||
if line.startswith("@@"):
|
||||
match = re.search(r"\+(\d+)", line)
|
||||
if match:
|
||||
current_line = int(match.group(1)) - 1
|
||||
continue
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
current_line += 1
|
||||
content = line[1:]
|
||||
|
||||
# Skip comments
|
||||
if content.strip().startswith("#"):
|
||||
continue
|
||||
|
||||
# Check for open() without with
|
||||
if open_re.search(content) and "with " not in content:
|
||||
result.add_issue(
|
||||
path, current_line, "open() without with statement — potential resource leak", "warning"
|
||||
)
|
||||
|
||||
# Check for Popen without communicate/wait on same line
|
||||
if popen_re.search(content) and ".communicate" not in content and ".wait" not in content:
|
||||
result.add_issue(
|
||||
path,
|
||||
current_line,
|
||||
"subprocess.Popen() without immediate .communicate() or .wait() — ensure cleanup",
|
||||
"warning",
|
||||
)
|
||||
|
||||
if not any("resource" in i["body"].lower() for i in result.issues):
|
||||
result.add_summary("- Resource management: OK")
|
||||
|
||||
|
||||
def check_function_length(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check that no new function is excessively long (> 50 lines)."""
|
||||
for f in files:
|
||||
@@ -363,6 +471,8 @@ def run_review(client: GiteaClient, pr_number: str) -> ReviewResult:
|
||||
check_architecture_compliance(files, result)
|
||||
check_best_practices(files, result)
|
||||
check_security(files, result)
|
||||
check_i18n(files, result)
|
||||
check_resource_management(files, result)
|
||||
check_function_length(files, result)
|
||||
check_documentation(files, result)
|
||||
check_test_coverage(files, result)
|
||||
|
||||
+24
-3
@@ -99,6 +99,12 @@ def parse_comments(comments_json: str | None, comments_stdin: bool) -> list[dict
|
||||
default=False,
|
||||
help="Required for APPROVE: confirms all REVIEW_CHECKLIST.md categories reviewed.",
|
||||
)
|
||||
@click.option(
|
||||
"--checklist-categories",
|
||||
default="",
|
||||
help="Comma-separated list of checklist categories reviewed (e.g., '1,2,3,4,5,6,7,8,9,10,11,12,13'). "
|
||||
"Required for APPROVE: must list at least 8 of 13 categories.",
|
||||
)
|
||||
def main(
|
||||
pr_number: str,
|
||||
repo: str,
|
||||
@@ -107,6 +113,7 @@ def main(
|
||||
comments_json: str | None,
|
||||
comments_stdin: bool,
|
||||
checklist_confirmed: bool,
|
||||
checklist_categories: str,
|
||||
) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
@@ -128,11 +135,25 @@ def main(
|
||||
"Review every category in REVIEW_CHECKLIST.md before approving."
|
||||
)
|
||||
)
|
||||
if len(body.strip()) <= 20 and not comments:
|
||||
# Validate that at least 8 of 13 checklist categories were reviewed
|
||||
categories = [c.strip() for c in checklist_categories.split(",") if c.strip()] if checklist_categories else []
|
||||
if len(categories) < 8:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"APPROVE review body must be substantive (> 20 characters) "
|
||||
"or include inline comments. Trivial approvals are rejected."
|
||||
"APPROVE requires --checklist-categories with at least 8 of 13 categories reviewed. "
|
||||
"Provide comma-separated category numbers (e.g., '1,2,3,4,5,6,7,8'). "
|
||||
"Got {count} categories: {cats}",
|
||||
count=len(categories),
|
||||
cats=checklist_categories or "(none)",
|
||||
)
|
||||
)
|
||||
if len(body.strip()) <= 50 and not comments:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"APPROVE review body must be substantive (> 50 characters) "
|
||||
"or include inline comments. Trivial approvals are rejected. "
|
||||
"Current body is {len} characters.",
|
||||
len=len(body.strip()),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user