Files
TheRock/skills/rocm-pr-quality/tools/pr_quality_check.py
T
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

497 lines
17 KiB
Python

#!/usr/bin/env python3
# Copyright Advanced Micro Devices, Inc.
# SPDX-License-Identifier: MIT
"""Deterministic subset of the ROCm PR-quality MUST checks.
The skill invokes it locally for fast feedback. It covers only the *mechanical* checks --
description completeness, work-tracking presence (M4), related-link presence/resolution (M5),
a product-code-without-tests heuristic (M2), CI currency, and stale-base/adjacent-file overlap.
Semantic judgments -- test substance, the mutation question, and M3 (never disable tests to
green CI) -- stay with the agent and the human reviewer; they are intentionally NOT enforced here.
Advisory by default (always exits 0). Pass --enforce to exit non-zero on blockers.
Usage:
python pr_quality_check.py --pr <number|url> [--repo owner/name] [--profile hipblaslt]
[--repo-path .] [--check-links] [--enforce] [--json]
Requires: gh (authenticated). The stale-base check uses local git when --repo-path is given
(exact), otherwise falls back to the GitHub compare API (no clone needed; the API caps the file
list at 300, so on a long-diverged base it reports "inconclusive" rather than a false "fine").
M4 distinguishes a real work-tracking item (Jira key / closing-issue / issue link) from an
incidental cross-reference or timeline mention, which it surfaces as a soft note, not a blocker.
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from datetime import datetime, timezone
# --- Profiles: generic defaults plus component overlays ------------------------------------
PROFILES = {
"base": {
"product_globs": [r"src/", r"library/", r"include/"],
"test_globs": [r"test", r"clients/", r"/tests?/"],
"high_coupling": [],
# Strong work-tracking signals (a real ticket/issue), vs. incidental cross-references.
"tracker_regexes": [r"\b[A-Z][A-Z0-9]+-\d+\b"],
"issue_link_regexes": [r"https?://\S+/issues/\d+"],
},
"hipblaslt": {
"product_globs": [r"projects/hipblaslt/", r"tensilelite/"],
"test_globs": [r"clients/tests", r"test", r"/tests?/"],
"high_coupling": [
r"KernelWriter.*\.py",
r"KernelWriterAssembly\.py",
r"Components/",
r"sgpr",
r"register",
],
"tracker_regexes": [r"\bAIHPBLAS-\d+\b", r"\bROCM-\d+\b"],
"issue_link_regexes": [r"https?://\S+/issues/\d+"],
},
}
REQUIRED_SECTIONS = ["Summary", "Risk", "Related", "Testing"]
WAIVER_RE = re.compile(r"\bW-[A-Z\-]+\b")
CI_STALE_DAYS = 3
class Finding:
def __init__(self, rule: str, severity: str, message: str):
self.rule = rule
self.severity = severity # BLOCKING | IMPORTANT | INFO
self.message = message
def as_dict(self):
return {"rule": self.rule, "severity": self.severity, "message": self.message}
def run(cmd: list[str], cwd: str | None = None) -> tuple[int, str, str]:
# Force UTF-8 decoding; gh/git output is UTF-8 even on Windows (default cp1252 corrupts it).
p = subprocess.run(
cmd, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace"
)
return p.returncode, (p.stdout or ""), (p.stderr or "")
def gh_pr_json(pr: str, repo: str | None) -> dict:
fields = (
"title,body,files,baseRefName,headRefName,headRefOid,url,statusCheckRollup,"
"commits,number,closingIssuesReferences"
)
cmd = ["gh", "pr", "view", pr, "--json", fields]
if repo:
cmd += ["--repo", repo]
code, out, err = run(cmd)
if code != 0:
sys.stderr.write(f"ERROR: gh pr view failed: {err.strip()}\n")
sys.exit(3)
return json.loads(out)
def gh_api_json(path: str):
code, out, err = run(["gh", "api", path])
if code != 0:
return None
try:
return json.loads(out)
except json.JSONDecodeError:
return None
def resolve_repo(repo: str | None) -> str | None:
if repo:
return repo
code, out, _ = run(
["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]
)
return out.strip() if code == 0 and out.strip() else None
def cross_reference_numbers(pr: str, repo: str | None) -> list[str]:
"""Best-effort: PR/issue numbers that cross-reference this PR via the timeline.
A cross-reference (e.g. 'mentioned this pull request #7326') is NOT a work-tracking
item, but it is worth surfacing so M4 degrades to a soft note rather than a hard block.
"""
repo = resolve_repo(repo)
if not repo:
return []
m = re.search(r"(\d+)$", pr)
num = m.group(1) if m else pr
data = gh_api_json(f"repos/{repo}/issues/{num}/timeline?per_page=100")
if not isinstance(data, list):
return []
refs = []
for ev in data:
if ev.get("event") == "cross-referenced":
src = (ev.get("source") or {}).get("issue") or {}
if src.get("number"):
refs.append(str(src["number"]))
return sorted(set(refs))
def matches_any(path: str, patterns: list[str]) -> bool:
return any(re.search(p, path, re.IGNORECASE) for p in patterns)
def check_description(body: str, findings: list[Finding]) -> None:
body = body or ""
missing = [
s for s in REQUIRED_SECTIONS if not re.search(rf"#+\s*{s}", body, re.IGNORECASE)
]
if missing:
findings.append(
Finding(
"DESC",
"IMPORTANT",
f"PR body missing section(s): {', '.join(missing)}.",
)
)
for empty in re.findall(r"(Devices?|Link):\s*N/?A", body, re.IGNORECASE):
findings.append(
Finding(
"DESC",
"INFO",
"Empty placeholder field present; omit it instead of 'N/A'.",
)
)
break
def check_tracking(
data: dict, prof: dict, findings: list[Finding], cross_refs: list[str]
) -> None:
haystack = " ".join(
[data.get("title", ""), data.get("body", ""), data.get("headRefName", "")]
)
body = data.get("body", "") or ""
has_jira = any(re.search(rx, haystack) for rx in prof["tracker_regexes"])
has_issue_link = any(
re.search(rx, body) for rx in prof.get("issue_link_regexes", [])
)
closing = data.get("closingIssuesReferences") or []
has_waiver = bool(WAIVER_RE.search(body))
body_refs = re.findall(r"(?<!\w)#(\d+)\b", body)
mentions = sorted(set(body_refs) | set(cross_refs))
if has_jira or has_issue_link or closing:
findings.append(Finding("M4", "INFO", "Work-tracking reference found."))
elif has_waiver:
findings.append(
Finding(
"M4",
"INFO",
"No tracker, but a waiver is declared; reviewer to adjudicate.",
)
)
elif mentions:
# A cross-reference / mention is not itself a tracker -- soft note, not a hard block.
shown = ", ".join("#" + m for m in mentions[:5])
findings.append(
Finding(
"M4",
"IMPORTANT",
f"No work-tracking ticket, but cross-reference(s)/mention(s) found "
f"({shown}). A related PR or mention is not work tracking -- confirm "
"a ticket/issue or declare a credible no-tracker waiver.",
)
)
else:
findings.append(
Finding(
"M4",
"BLOCKING",
"No work-tracking reference and no cross-reference or declared "
"waiver. Add a tracker, or a credible no-tracker waiver.",
)
)
def check_related_links(
data: dict, findings: list[Finding], check_links: bool, repo: str | None
) -> None:
body = data.get("body", "") or ""
urls = re.findall(r"https?://[^\s)]+", body)
issue_refs = re.findall(r"(?<!\w)#(\d+)\b", body)
if not urls and not issue_refs:
findings.append(
Finding(
"M5",
"IMPORTANT",
"No related links found in the PR body. Link the work item, the "
"defect fixed, and directly related PRs.",
)
)
return
if check_links:
for num in set(issue_refs):
cmd = ["gh", "issue", "view", num, "--json", "number"]
if repo:
cmd += ["--repo", repo]
code, _, _ = run(cmd)
if code != 0:
# could be a PR rather than an issue
cmd2 = ["gh", "pr", "view", num, "--json", "number"]
if repo:
cmd2 += ["--repo", repo]
code2, _, _ = run(cmd2)
if code2 != 0:
findings.append(
Finding(
"M5",
"IMPORTANT",
f"Reference #{num} did not resolve to an issue or PR.",
)
)
def check_tests(data: dict, prof: dict, findings: list[Finding]) -> None:
files = [f.get("path", "") for f in data.get("files", [])]
product = [f for f in files if matches_any(f, prof["product_globs"])]
tests = [f for f in files if matches_any(f, prof["test_globs"])]
docs_only = files and all(f.endswith((".md", ".rst", ".txt")) for f in files)
has_waiver = bool(WAIVER_RE.search(data.get("body", "") or ""))
if product and not tests and not docs_only and not has_waiver:
findings.append(
Finding(
"M2",
"BLOCKING",
f"{len(product)} product file(s) changed but no test files touched "
"and no waiver declared. Add tests, a safe-default flag + tracker, "
"or a written waiver. (Substance is judged separately by the agent.)",
)
)
def check_ci_currency(data: dict, findings: list[Finding]) -> None:
rollup = data.get("statusCheckRollup") or []
times = []
for c in rollup:
ts = c.get("completedAt") or c.get("startedAt")
if ts:
try:
times.append(datetime.fromisoformat(ts.replace("Z", "+00:00")))
except ValueError:
pass
if not times:
findings.append(
Finding(
"CI", "INFO", "No CI completion timestamps found to assess currency."
)
)
return
newest = max(times)
age_days = (datetime.now(timezone.utc) - newest).total_seconds() / 86400.0
if age_days > CI_STALE_DAYS:
findings.append(
Finding(
"CI",
"IMPORTANT",
f"Newest CI result is ~{age_days:.1f} days old (> {CI_STALE_DAYS}). "
"Consider a rebase + re-run before merge.",
)
)
else:
findings.append(
Finding("CI", "INFO", f"Newest CI result ~{age_days:.1f} days old.")
)
def _report_overlap(
overlap: set[str], prof: dict, findings: list[Finding], how: str
) -> None:
if not overlap:
findings.append(
Finding(
"STALE-BASE",
"INFO",
f"No adjacent-file overlap with base ({how}); merge is fine.",
)
)
return
hot = (
[f for f in overlap if matches_any(f, prof["high_coupling"])]
if prof["high_coupling"]
else []
)
if hot:
findings.append(
Finding(
"STALE-BASE",
"BLOCKING",
"Overlap on high-coupling file(s) since divergence "
f"({how}): {', '.join(sorted(hot))}. Rebase + re-run before merge.",
)
)
else:
findings.append(
Finding(
"STALE-BASE",
"IMPORTANT",
f"Overlap with base on {len(overlap)} file(s) ({how}); "
"consider rebase + re-run.",
)
)
def check_stale_base_api(
data: dict, prof: dict, repo: str | None, findings: list[Finding]
) -> None:
"""No local clone: use the compare API to find the merge-base, then the files the base
branch advanced with since divergence, and intersect with the PR's files."""
repo = resolve_repo(repo)
base = data.get("baseRefName", "develop")
head = data.get("headRefOid")
pr_files = {f.get("path", "") for f in data.get("files", [])}
if not repo or not head:
findings.append(
Finding(
"STALE-BASE", "INFO", "Stale-base check skipped (need repo + head SHA)."
)
)
return
cmp1 = gh_api_json(f"repos/{repo}/compare/{base}...{head}")
if not cmp1 or not (cmp1.get("merge_base_commit") or {}).get("sha"):
findings.append(
Finding(
"STALE-BASE",
"INFO",
"Stale-base check skipped (merge-base not resolvable via API; "
"head may be from a deleted/fork branch -- use --repo-path).",
)
)
return
mb = cmp1["merge_base_commit"]["sha"]
cmp2 = gh_api_json(f"repos/{repo}/compare/{mb}...{base}")
if not cmp2:
findings.append(
Finding(
"STALE-BASE", "INFO", "Stale-base check skipped (base compare failed)."
)
)
return
base_changed = {f.get("filename", "") for f in (cmp2.get("files") or [])}
overlap = pr_files & base_changed
truncated = len(cmp2.get("files") or []) >= 300
if truncated and not overlap:
# The base advanced past the API's 300-file cap, so "no overlap" is not trustworthy.
findings.append(
Finding(
"STALE-BASE",
"IMPORTANT",
"Inconclusive: base advanced past the API's 300-file limit, so the "
"adjacent-file check is incomplete. Re-run nearer merge time, or use "
"--repo-path <clone> for an exact result.",
)
)
return
_report_overlap(overlap, prof, findings, "API (truncated)" if truncated else "API")
def check_stale_base(
data: dict,
prof: dict,
repo_path: str | None,
repo: str | None,
findings: list[Finding],
) -> None:
if not repo_path:
check_stale_base_api(data, prof, repo, findings)
return
base = data.get("baseRefName", "develop")
pr_files = {f.get("path", "") for f in data.get("files", [])}
code, mb, err = run(["git", "merge-base", f"origin/{base}", "HEAD"], cwd=repo_path)
if code != 0:
code, mb, err = run(["git", "merge-base", base, "HEAD"], cwd=repo_path)
if code != 0:
findings.append(
Finding(
"STALE-BASE", "INFO", f"Could not compute merge-base: {err.strip()}"
)
)
return
merge_base = mb.strip()
code, out, err = run(
["git", "diff", "--name-only", f"{merge_base}..origin/{base}"], cwd=repo_path
)
if code != 0:
findings.append(
Finding("STALE-BASE", "INFO", f"Could not diff base: {err.strip()}")
)
return
base_changed = set(filter(None, out.splitlines()))
_report_overlap(pr_files & base_changed, prof, findings, "git")
def main() -> None:
ap = argparse.ArgumentParser(
description="ROCm PR quality deterministic checks (advisory)."
)
ap.add_argument("--pr", required=True, help="PR number or URL")
ap.add_argument("--repo", help="owner/name (gh infers from cwd if omitted)")
ap.add_argument(
"--profile", default="base", choices=sorted(PROFILES), help="overlay profile"
)
ap.add_argument("--repo-path", help="local clone path for the stale-base check")
ap.add_argument(
"--check-links", action="store_true", help="resolve #refs via gh (network)"
)
ap.add_argument(
"--enforce", action="store_true", help="exit non-zero on BLOCKING findings"
)
ap.add_argument("--json", action="store_true", help="emit JSON")
args = ap.parse_args()
prof = PROFILES[args.profile]
data = gh_pr_json(args.pr, args.repo)
cross_refs = cross_reference_numbers(args.pr, args.repo)
findings: list[Finding] = []
check_description(data.get("body", ""), findings)
check_tracking(data, prof, findings, cross_refs)
check_related_links(data, findings, args.check_links, args.repo)
check_tests(data, prof, findings)
check_ci_currency(data, findings)
check_stale_base(data, prof, args.repo_path, args.repo, findings)
blockers = [f for f in findings if f.severity == "BLOCKING"]
warns = [f for f in findings if f.severity == "IMPORTANT"]
if args.json:
print(
json.dumps(
{
"pr": data.get("number"),
"url": data.get("url"),
"profile": args.profile,
"blockers": len(blockers),
"warnings": len(warns),
"findings": [f.as_dict() for f in findings],
},
indent=2,
)
)
else:
print(f"PR #{data.get('number')} [{args.profile}] - {data.get('url')}")
order = {"BLOCKING": 0, "IMPORTANT": 1, "INFO": 2}
for f in sorted(findings, key=lambda x: order[x.severity]):
print(f" [{f.severity:9}] {f.rule:11} {f.message}")
print(
f"Summary: {len(blockers)} blocking, {len(warns)} important. "
"Substance/M3 judged separately by the agent + human reviewer."
)
sys.exit(2 if (args.enforce and blockers) else 0)
if __name__ == "__main__":
main()