|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Retest open fuzzer issues and close those whose crashes no longer reproduce. |
| 3 | +
|
| 4 | +Usage: |
| 5 | + python3 close_fixed_fuzzer_issues.py --target file_io |
| 6 | + python3 close_fixed_fuzzer_issues.py --target file_io --dry-run |
| 7 | +""" |
| 8 | + |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +import argparse |
| 12 | +import json |
| 13 | +import os |
| 14 | +import re |
| 15 | +import subprocess |
| 16 | +import sys |
| 17 | +import tempfile |
| 18 | +from dataclasses import dataclass |
| 19 | + |
| 20 | +CLEANUP_MARKER = "Auto-checked by weekly fuzzer issue cleanup" |
| 21 | + |
| 22 | + |
| 23 | +@dataclass |
| 24 | +class FuzzerIssue: |
| 25 | + number: int |
| 26 | + title: str |
| 27 | + target: str |
| 28 | + crash_file: str |
| 29 | + artifact_url: str |
| 30 | + body: str |
| 31 | + |
| 32 | + |
| 33 | +def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: |
| 34 | + """Run a command, printing it for visibility.""" |
| 35 | + print(f" $ {' '.join(cmd)}", flush=True) |
| 36 | + return subprocess.run(cmd, **kwargs) |
| 37 | + |
| 38 | + |
| 39 | +def fetch_open_fuzzer_issues(repo: str) -> list[dict]: |
| 40 | + """Fetch all open issues with the 'fuzzer' label.""" |
| 41 | + result = run( |
| 42 | + [ |
| 43 | + "gh", |
| 44 | + "issue", |
| 45 | + "list", |
| 46 | + "--repo", |
| 47 | + repo, |
| 48 | + "--label", |
| 49 | + "fuzzer", |
| 50 | + "--state", |
| 51 | + "open", |
| 52 | + "--json", |
| 53 | + "number,title,body,url", |
| 54 | + "--limit", |
| 55 | + "200", |
| 56 | + ], |
| 57 | + capture_output=True, |
| 58 | + text=True, |
| 59 | + ) |
| 60 | + if result.returncode != 0: |
| 61 | + print(f"ERROR: Failed to fetch issues: {result.stderr}", file=sys.stderr) |
| 62 | + sys.exit(1) |
| 63 | + return json.loads(result.stdout) |
| 64 | + |
| 65 | + |
| 66 | +def parse_issue(issue: dict) -> FuzzerIssue | None: |
| 67 | + """Extract target, crash file, and artifact URL from an issue body.""" |
| 68 | + body = issue.get("body", "") |
| 69 | + |
| 70 | + target_match = re.search(r"\*\*Target\*\*:\s*`([^`]+)`", body) |
| 71 | + crash_file_match = re.search(r"\*\*Crash File\*\*:\s*`([^`]+)`", body) |
| 72 | + artifact_url_match = re.search(r"\*\*Crash Artifact\*\*:\s*(https://\S+)", body) |
| 73 | + |
| 74 | + if not target_match or not crash_file_match: |
| 75 | + return None |
| 76 | + |
| 77 | + return FuzzerIssue( |
| 78 | + number=issue["number"], |
| 79 | + title=issue["title"], |
| 80 | + target=target_match.group(1), |
| 81 | + crash_file=crash_file_match.group(1), |
| 82 | + artifact_url=artifact_url_match.group(1) if artifact_url_match else "", |
| 83 | + body=body, |
| 84 | + ) |
| 85 | + |
| 86 | + |
| 87 | +def extract_run_id(artifact_url: str) -> str | None: |
| 88 | + """Extract the workflow run ID from an artifact URL like .../runs/12345/...""" |
| 89 | + match = re.search(r"runs/(\d+)", artifact_url) |
| 90 | + return match.group(1) if match else None |
| 91 | + |
| 92 | + |
| 93 | +def has_cleanup_comment(repo: str, issue_number: int) -> bool: |
| 94 | + """Check if the issue already has an 'Artifact Unavailable' cleanup comment.""" |
| 95 | + result = run( |
| 96 | + [ |
| 97 | + "gh", |
| 98 | + "api", |
| 99 | + f"repos/{repo}/issues/{issue_number}/comments", |
| 100 | + "--jq", |
| 101 | + f'[.[] | select(.body | contains("{CLEANUP_MARKER}"))] | length', |
| 102 | + ], |
| 103 | + capture_output=True, |
| 104 | + text=True, |
| 105 | + ) |
| 106 | + if result.returncode != 0: |
| 107 | + return False |
| 108 | + try: |
| 109 | + return int(result.stdout.strip()) > 0 |
| 110 | + except ValueError: |
| 111 | + return False |
| 112 | + |
| 113 | + |
| 114 | +def close_artifact_unavailable(repo: str, issue_number: int, dry_run: bool) -> None: |
| 115 | + """Comment that the crash artifact is no longer available and close the issue.""" |
| 116 | + body = ( |
| 117 | + f"## Artifact Unavailable\n\n" |
| 118 | + f"The crash artifact for this issue is no longer available " |
| 119 | + f"(artifacts expire after 30 days). The crash can no longer be " |
| 120 | + f"automatically retested.\n\n" |
| 121 | + f"If this issue is still relevant, please reproduce manually and " |
| 122 | + f"re-open this issue.\n\n" |
| 123 | + f"---\n*{CLEANUP_MARKER}*" |
| 124 | + ) |
| 125 | + if dry_run: |
| 126 | + print(f" [dry-run] Would close #{issue_number} as artifact unavailable") |
| 127 | + return |
| 128 | + run( |
| 129 | + ["gh", "issue", "comment", str(issue_number), "--repo", repo, "--body", body], |
| 130 | + check=True, |
| 131 | + ) |
| 132 | + run( |
| 133 | + ["gh", "issue", "close", str(issue_number), "--repo", repo, "--reason", "completed"], |
| 134 | + check=True, |
| 135 | + ) |
| 136 | + |
| 137 | + |
| 138 | +def close_issue_as_fixed(repo: str, issue_number: int, dry_run: bool) -> None: |
| 139 | + """Close the issue with a comment explaining the crash no longer reproduces.""" |
| 140 | + body = ( |
| 141 | + f"## Crash No Longer Reproduces\n\n" |
| 142 | + f"This crash was retested against the latest `main` branch and " |
| 143 | + f"the fuzzer completed successfully (exit code 0).\n\n" |
| 144 | + f"The underlying bug appears to have been fixed. Closing this issue.\n\n" |
| 145 | + f"If the crash reappears, the fuzzer will automatically open a new issue.\n\n" |
| 146 | + f"---\n*{CLEANUP_MARKER}*" |
| 147 | + ) |
| 148 | + if dry_run: |
| 149 | + print(f" [dry-run] Would close #{issue_number} as fixed") |
| 150 | + return |
| 151 | + run( |
| 152 | + ["gh", "issue", "comment", str(issue_number), "--repo", repo, "--body", body], |
| 153 | + check=True, |
| 154 | + ) |
| 155 | + run( |
| 156 | + ["gh", "issue", "close", str(issue_number), "--repo", repo, "--reason", "completed"], |
| 157 | + check=True, |
| 158 | + ) |
| 159 | + |
| 160 | + |
| 161 | +def build_fuzz_target(target: str) -> bool: |
| 162 | + """Build the fuzz target once. Returns True on success.""" |
| 163 | + print(f"\nBuilding fuzz target: {target}") |
| 164 | + env = os.environ.copy() |
| 165 | + env["RUSTFLAGS"] = "--cfg vortex_nightly" |
| 166 | + result = run( |
| 167 | + ["cargo", "+nightly", "fuzz", "build", "--dev", "--sanitizer=none", target], |
| 168 | + env=env, |
| 169 | + ) |
| 170 | + return result.returncode == 0 |
| 171 | + |
| 172 | + |
| 173 | +def retest_crash(target: str, crash_path: str, timeout_secs: int = 120) -> str: |
| 174 | + """Run the fuzz target with the crash file. Returns 'fixed', 'reproduces', or 'timeout'.""" |
| 175 | + env = os.environ.copy() |
| 176 | + env["RUSTFLAGS"] = "--cfg vortex_nightly" |
| 177 | + try: |
| 178 | + result = run( |
| 179 | + [ |
| 180 | + "cargo", |
| 181 | + "+nightly", |
| 182 | + "fuzz", |
| 183 | + "run", |
| 184 | + "--dev", |
| 185 | + "--sanitizer=none", |
| 186 | + target, |
| 187 | + crash_path, |
| 188 | + "--", |
| 189 | + "-runs=1", |
| 190 | + "-rss_limit_mb=0", |
| 191 | + ], |
| 192 | + env=env, |
| 193 | + timeout=timeout_secs, |
| 194 | + ) |
| 195 | + if result.returncode == 0: |
| 196 | + return "fixed" |
| 197 | + else: |
| 198 | + return "reproduces" |
| 199 | + except subprocess.TimeoutExpired: |
| 200 | + return "timeout" |
| 201 | + |
| 202 | + |
| 203 | +def main() -> None: |
| 204 | + parser = argparse.ArgumentParser( |
| 205 | + description="Retest open fuzzer issues and close fixed ones.", |
| 206 | + ) |
| 207 | + parser.add_argument( |
| 208 | + "--target", |
| 209 | + required=True, |
| 210 | + help="Fuzz target to process (e.g., file_io)", |
| 211 | + ) |
| 212 | + parser.add_argument( |
| 213 | + "--repo", |
| 214 | + default=os.environ.get("GITHUB_REPOSITORY", ""), |
| 215 | + help="GitHub repository (owner/name). Defaults to $GITHUB_REPOSITORY.", |
| 216 | + ) |
| 217 | + parser.add_argument( |
| 218 | + "--dry-run", |
| 219 | + action="store_true", |
| 220 | + help="Print actions without modifying issues.", |
| 221 | + ) |
| 222 | + args = parser.parse_args() |
| 223 | + |
| 224 | + if not args.repo: |
| 225 | + print("ERROR: --repo is required (or set GITHUB_REPOSITORY)", file=sys.stderr) |
| 226 | + sys.exit(1) |
| 227 | + |
| 228 | + print(f"Processing fuzzer issues for target={args.target} in {args.repo}") |
| 229 | + if args.dry_run: |
| 230 | + print("DRY RUN: no issues will be modified\n") |
| 231 | + |
| 232 | + # 1. Fetch open fuzzer issues |
| 233 | + raw_issues = fetch_open_fuzzer_issues(args.repo) |
| 234 | + print(f"Found {len(raw_issues)} open fuzzer issue(s)") |
| 235 | + |
| 236 | + # 2. Parse and filter to matching target |
| 237 | + issues: list[FuzzerIssue] = [] |
| 238 | + for raw in raw_issues: |
| 239 | + parsed = parse_issue(raw) |
| 240 | + if parsed and parsed.target == args.target: |
| 241 | + issues.append(parsed) |
| 242 | + |
| 243 | + print(f"Found {len(issues)} issue(s) matching target={args.target}\n") |
| 244 | + if not issues: |
| 245 | + print("Nothing to do.") |
| 246 | + return |
| 247 | + |
| 248 | + # 3. Build the fuzz target once |
| 249 | + if not build_fuzz_target(args.target): |
| 250 | + print("ERROR: Failed to build fuzz target", file=sys.stderr) |
| 251 | + sys.exit(1) |
| 252 | + print() |
| 253 | + |
| 254 | + # 4. Process each issue |
| 255 | + summary: dict[str, list[int]] = { |
| 256 | + "closed": [], |
| 257 | + "still_reproduces": [], |
| 258 | + "artifact_unavailable": [], |
| 259 | + "timeout": [], |
| 260 | + "error": [], |
| 261 | + } |
| 262 | + |
| 263 | + for issue in issues: |
| 264 | + print(f"--- Issue #{issue.number}: {issue.title}") |
| 265 | + |
| 266 | + # Extract run ID from artifact URL |
| 267 | + if not issue.artifact_url: |
| 268 | + print(" No artifact URL found in issue body") |
| 269 | + if not has_cleanup_comment(args.repo, issue.number): |
| 270 | + close_artifact_unavailable(args.repo, issue.number, args.dry_run) |
| 271 | + else: |
| 272 | + print(" Already commented about artifact unavailability, skipping") |
| 273 | + summary["artifact_unavailable"].append(issue.number) |
| 274 | + continue |
| 275 | + |
| 276 | + run_id = extract_run_id(issue.artifact_url) |
| 277 | + if not run_id: |
| 278 | + print(f" Could not extract run ID from: {issue.artifact_url}") |
| 279 | + summary["error"].append(issue.number) |
| 280 | + continue |
| 281 | + |
| 282 | + # Download artifact into a temp directory |
| 283 | + with tempfile.TemporaryDirectory() as tmpdir: |
| 284 | + artifact_name = f"{args.target}-crash-artifacts" |
| 285 | + dl_result = run( |
| 286 | + [ |
| 287 | + "gh", |
| 288 | + "run", |
| 289 | + "download", |
| 290 | + run_id, |
| 291 | + "--name", |
| 292 | + artifact_name, |
| 293 | + "--repo", |
| 294 | + args.repo, |
| 295 | + "--dir", |
| 296 | + tmpdir, |
| 297 | + ], |
| 298 | + capture_output=True, |
| 299 | + text=True, |
| 300 | + ) |
| 301 | + |
| 302 | + if dl_result.returncode != 0: |
| 303 | + print(f" Artifact download failed: {dl_result.stderr.strip()}") |
| 304 | + if not has_cleanup_comment(args.repo, issue.number): |
| 305 | + close_artifact_unavailable(args.repo, issue.number, args.dry_run) |
| 306 | + else: |
| 307 | + print(" Already commented about artifact unavailability, skipping") |
| 308 | + summary["artifact_unavailable"].append(issue.number) |
| 309 | + continue |
| 310 | + |
| 311 | + # Locate crash file |
| 312 | + crash_path = os.path.join(tmpdir, args.target, issue.crash_file) |
| 313 | + if not os.path.isfile(crash_path): |
| 314 | + # Try without target subdirectory (artifact structure may vary) |
| 315 | + crash_path = os.path.join(tmpdir, issue.crash_file) |
| 316 | + if not os.path.isfile(crash_path): |
| 317 | + print(f" Crash file not found: {issue.crash_file}") |
| 318 | + summary["error"].append(issue.number) |
| 319 | + continue |
| 320 | + |
| 321 | + # Retest |
| 322 | + print(f" Retesting crash file: {issue.crash_file}") |
| 323 | + result = retest_crash(args.target, crash_path) |
| 324 | + |
| 325 | + if result == "fixed": |
| 326 | + print(" Crash NO LONGER reproduces — closing issue") |
| 327 | + close_issue_as_fixed(args.repo, issue.number, args.dry_run) |
| 328 | + summary["closed"].append(issue.number) |
| 329 | + elif result == "reproduces": |
| 330 | + print(" Crash STILL reproduces — leaving open") |
| 331 | + summary["still_reproduces"].append(issue.number) |
| 332 | + elif result == "timeout": |
| 333 | + print(" Retest TIMED OUT — skipping") |
| 334 | + summary["timeout"].append(issue.number) |
| 335 | + |
| 336 | + print() |
| 337 | + |
| 338 | + # 5. Print summary |
| 339 | + print("=" * 60) |
| 340 | + print("SUMMARY") |
| 341 | + print("=" * 60) |
| 342 | + print(f" Closed (fixed): {summary['closed'] or 'none'}") |
| 343 | + print(f" Still reproduces: {summary['still_reproduces'] or 'none'}") |
| 344 | + print(f" Artifact unavailable: {summary['artifact_unavailable'] or 'none'}") |
| 345 | + print(f" Timeout: {summary['timeout'] or 'none'}") |
| 346 | + print(f" Error: {summary['error'] or 'none'}") |
| 347 | + |
| 348 | + |
| 349 | +if __name__ == "__main__": |
| 350 | + main() |
0 commit comments