Skip to content

Commit 954311f

Browse files
chore[fuzz]: auto close fuzzer issues (#6378)
Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
1 parent 63b5547 commit 954311f

File tree

2 files changed

+409
-0
lines changed

2 files changed

+409
-0
lines changed
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
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

Comments
 (0)