Skip to content

Commit ab6d2eb

Browse files
fix: fuzzer issue gen (#6481)
## Does this PR closes an open issue or discussion? <!-- This helps us keep track of fixed issues and changes. --> - Closes #. ## What changes are included in this PR? <!-- What changes are included here, if an issue or discussion are attached, there's no need to duplicate the details. --> ## What is the rationale for this change? <!-- Why do you propose this change, and why did you choose this approach. This helps reviewers and other readers understand changes, creates a shared understanding of the issue and codebase, and improves their ability to work with this change and offer better suggestions. --> ## How is this change tested? <!-- Changes should be tested, we expect changes to fit in one of the following categories: 1. Verifying existing behavior is maintained. 2. For serialization related changes - Compatibility should be maintained or explicitly broken. 3. For new behavior and functionality, this helps us maintaining that desired behavior in the future. --> ## Are there any user-facing changes? <!-- Does the change affect users in what of the following ways: 1. Breaks public APIs in some way. 2. Changes the underlying behavior of one of the integrations. 3. Should some documentation be changed to reflect this change? In the case some public API is changed in a breaking way, make sure to add the appropriate label. --> --------- Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
1 parent 5de9826 commit ab6d2eb

6 files changed

Lines changed: 129 additions & 52 deletions

File tree

.github/scripts/fuzz_report/cli.py

Lines changed: 99 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
import json
66
import os
77
import re
8+
import shlex
89
import subprocess
910
import sys
11+
import time
1012
from pathlib import Path
1113

1214
from .dedup import check_duplicate
@@ -40,6 +42,30 @@ def _write_github_output(key: str, value: str) -> None:
4042
f.write(f"{key}={value}\n")
4143

4244

45+
def _run_gh(cmd: list[str], *, retries: int = 1, **kwargs) -> subprocess.CompletedProcess:
46+
"""Run a gh CLI command with logging and retry on failure.
47+
48+
Prints the command before execution and surfaces stderr on failure.
49+
Retries up to ``retries`` times (default 1) with a short back-off.
50+
"""
51+
print(f"+ {shlex.join(cmd)}", file=sys.stderr)
52+
last_exc: subprocess.CalledProcessError | None = None
53+
for attempt in range(1 + retries):
54+
if attempt > 0:
55+
wait = 5 * attempt
56+
print(f"Retrying in {wait}s (attempt {attempt + 1}/{1 + retries})...", file=sys.stderr)
57+
time.sleep(wait)
58+
try:
59+
return subprocess.run(cmd, check=True, **kwargs)
60+
except subprocess.CalledProcessError as exc:
61+
last_exc = exc
62+
stderr_text = (
63+
exc.stderr if isinstance(exc.stderr, str) else (exc.stderr or b"").decode()
64+
)
65+
print(f"gh command failed (exit {exc.returncode}): {stderr_text}", file=sys.stderr)
66+
raise last_exc # type: ignore[misc]
67+
68+
4369
def _load_crash_info(path: str | Path) -> CrashInfo:
4470
"""Load CrashInfo from a JSON file."""
4571
crash_data = json.loads(Path(path).read_text())
@@ -53,6 +79,21 @@ def _find_crash_file(crash_dir: str, crash_name: str) -> str | None:
5379
return None
5480

5581

82+
def _truncate(text: str, max_chars: int) -> str:
83+
"""Truncate text to max_chars, appending a note if truncated."""
84+
if len(text) <= max_chars:
85+
return text
86+
return text[:max_chars] + f"\n... ({len(text) - max_chars} chars truncated)"
87+
88+
89+
# GitHub issue body limit is 65536 chars. Reserve ~5k for the fixed template
90+
# chrome (headings, summary table, reproduction steps, etc.) and split the
91+
# remaining budget between the two variable-length fields.
92+
_BODY_BUDGET = 60000
93+
_TEMPLATE_OVERHEAD = 5000
94+
_FIELD_BUDGET = _BODY_BUDGET - _TEMPLATE_OVERHEAD # 55k split between the two
95+
96+
5697
def _build_template_variables(
5798
crash_info: CrashInfo,
5899
var_args: list[tuple[str, str]] | None = None,
@@ -64,11 +105,28 @@ def _build_template_variables(
64105
for key, value in var_args:
65106
variables[key] = value
66107

108+
panic_msg = crash_info.panic_message
109+
stack_trace = crash_info.stack_trace_raw
110+
111+
# Truncate the two large fields so their combined size fits the budget.
112+
combined = len(panic_msg) + len(stack_trace)
113+
if combined > _FIELD_BUDGET:
114+
# Give panic_message up to half, stack_trace gets the rest.
115+
msg_limit = min(len(panic_msg), _FIELD_BUDGET // 2)
116+
trace_limit = _FIELD_BUDGET - msg_limit
117+
panic_msg = _truncate(panic_msg, msg_limit)
118+
stack_trace = _truncate(stack_trace, trace_limit)
119+
print(
120+
f"Warning: Truncated issue fields to fit body limit "
121+
f"(panic_message={msg_limit}, stack_trace={trace_limit})",
122+
file=sys.stderr,
123+
)
124+
67125
# Auto-populate from crash info (don't override explicit -v args)
68126
auto_vars = {
69-
"PANIC_MESSAGE": crash_info.panic_message,
127+
"PANIC_MESSAGE": panic_msg,
70128
"CRASH_LOCATION": crash_info.crash_location,
71-
"STACK_TRACE_RAW": crash_info.stack_trace_raw,
129+
"STACK_TRACE_RAW": stack_trace,
72130
"DEBUG_OUTPUT": crash_info.debug_output,
73131
"SEED_HASH": crash_info.seed_hash,
74132
"STACK_TRACE_HASH": crash_info.stack_trace_hash,
@@ -128,7 +186,7 @@ def _update_recurrence_count(repo: str, issue_number: int | str) -> int:
128186
Returns the new count.
129187
"""
130188
# List all comments on the issue
131-
result = subprocess.run(
189+
result = _run_gh(
132190
[
133191
"gh",
134192
"api",
@@ -139,7 +197,6 @@ def _update_recurrence_count(repo: str, issue_number: int | str) -> int:
139197
],
140198
capture_output=True,
141199
text=True,
142-
check=True,
143200
)
144201

145202
existing_id = None
@@ -161,7 +218,7 @@ def _update_recurrence_count(repo: str, issue_number: int | str) -> int:
161218
if existing_id:
162219
# Update existing comment (not atomic — race is acceptable since
163220
# fuzz CI jobs are serialized)
164-
subprocess.run(
221+
_run_gh(
165222
[
166223
"gh",
167224
"api",
@@ -171,19 +228,17 @@ def _update_recurrence_count(repo: str, issue_number: int | str) -> int:
171228
"-f",
172229
f"body={body}",
173230
],
174-
check=True,
175231
)
176232
else:
177233
# Create new recurrence comment
178-
subprocess.run(
234+
_run_gh(
179235
[
180236
"gh",
181237
"api",
182238
f"repos/{repo}/issues/{issue_number}/comments",
183239
"-f",
184240
f"body={body}",
185241
],
186-
check=True,
187242
)
188243

189244
return new_count
@@ -302,7 +357,7 @@ def cmd_report(args: argparse.Namespace) -> int:
302357
body_file = Path("comment_body.md")
303358
body_file.write_text(body)
304359

305-
subprocess.run(
360+
_run_gh(
306361
[
307362
"gh",
308363
"issue",
@@ -313,7 +368,6 @@ def cmd_report(args: argparse.Namespace) -> int:
313368
"--body-file",
314369
str(body_file),
315370
],
316-
check=True,
317371
)
318372
print(f"Commented on #{existing_issue}", file=sys.stderr)
319373
_write_github_output("issue_number", str(existing_issue))
@@ -325,7 +379,11 @@ def cmd_report(args: argparse.Namespace) -> int:
325379
body_file = Path("issue_body.md")
326380
body_file.write_text(body)
327381

328-
result = subprocess.run(
382+
print(f"Issue title: {title}", file=sys.stderr)
383+
print(f"Issue body size: {len(body)} chars", file=sys.stderr)
384+
print(f"Repo: {args.repo}", file=sys.stderr)
385+
386+
result = _run_gh(
329387
[
330388
"gh",
331389
"issue",
@@ -339,7 +397,6 @@ def cmd_report(args: argparse.Namespace) -> int:
339397
"--body-file",
340398
str(body_file),
341399
],
342-
check=True,
343400
capture_output=True,
344401
text=True,
345402
)
@@ -349,6 +406,36 @@ def cmd_report(args: argparse.Namespace) -> int:
349406
print(f"Created issue #{issue_number}: {issue_url}", file=sys.stderr)
350407
_write_github_output("issue_number", issue_number)
351408

409+
# Post full debug output as a follow-up comment (collapsed).
410+
debug_output = variables.get("DEBUG_OUTPUT", "")
411+
if debug_output and debug_output != "(not set)":
412+
comment_body = (
413+
"<details>\n<summary>Debug Output</summary>\n\n"
414+
f"```\n{debug_output}\n```\n</details>"
415+
)
416+
# Truncate comment to GitHub's limit too.
417+
if len(comment_body) > _BODY_BUDGET:
418+
comment_body = (
419+
comment_body[: _BODY_BUDGET - 50] + "\n```\n</details>\n\n*Truncated*"
420+
)
421+
comment_file = Path("debug_comment.md")
422+
comment_file.write_text(comment_body)
423+
try:
424+
_run_gh(
425+
[
426+
"gh",
427+
"issue",
428+
"comment",
429+
issue_number,
430+
"--repo",
431+
args.repo,
432+
"--body-file",
433+
str(comment_file),
434+
],
435+
)
436+
except subprocess.CalledProcessError:
437+
print("Warning: failed to post debug output comment", file=sys.stderr)
438+
352439
return 0
353440

354441

.github/scripts/fuzz_report/dedup.py

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -176,26 +176,6 @@ def check_error_pattern(message_hash: str, error_variant: str, issues: list[dict
176176
},
177177
)
178178

179-
# Second try: same error variant (lower confidence)
180-
if error_variant and error_variant != "unknown":
181-
for issue in issues:
182-
body = issue.get("body", "")
183-
if error_variant in body:
184-
return DedupResult(
185-
duplicate=True,
186-
check="error_pattern",
187-
confidence="medium",
188-
issue_number=issue["number"],
189-
issue_url=issue.get("url"),
190-
issue_title=issue.get("title"),
191-
reason=f"Same error variant type: {error_variant}",
192-
debug={
193-
"message_hash": message_hash,
194-
"error_variant": error_variant,
195-
"matched_issue": issue["number"],
196-
},
197-
)
198-
199179
return DedupResult(
200180
duplicate=False,
201181
check="error_pattern",

.github/scripts/fuzz_report/templates/new_issue.md

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,19 @@
99
{{PANIC_MESSAGE}}
1010
```
1111

12-
**Stack Trace**:
12+
<details>
13+
<summary>Stack Trace</summary>
14+
1315
```
1416
{{STACK_TRACE_RAW}}
1517
```
18+
</details>
1619
{% if CLAUDE_ANALYSIS %}
1720

1821
### Root Cause Analysis
1922

2023
{{CLAUDE_ANALYSIS}}
2124
{% endif %}
22-
{% if DEBUG_OUTPUT %}
23-
24-
<details>
25-
<summary>Debug Output</summary>
26-
27-
```
28-
{{DEBUG_OUTPUT}}
29-
```
30-
</details>
31-
{% endif %}
3225

3326
### Summary
3427

.github/scripts/fuzz_report/tests/test_dedup.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,15 +144,13 @@ def test_message_hash_match(self):
144144
assert result.duplicate is True
145145
assert result.confidence == "high"
146146

147-
def test_variant_match(self):
147+
def test_variant_only_does_not_match(self):
148148
result = check_error_pattern(
149149
"nomatchhash",
150150
"ScalarMismatch",
151151
EXISTING_ISSUES,
152152
)
153-
assert result.duplicate is True
154-
assert result.confidence == "medium"
155-
assert result.issue_number == 101
153+
assert result.duplicate is False
156154

157155
def test_no_match(self):
158156
result = check_error_pattern("nomatch", "UnknownVariant", EXISTING_ISSUES)

.github/scripts/fuzz_report/tests/test_template.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,12 +197,12 @@ def test_claude_analysis_hidden_when_empty(self, new_issue_template):
197197
rendered = render_template(new_issue_template, vars_no_analysis, use_env=False)
198198
assert "Root Cause Analysis" not in rendered
199199

200-
def test_debug_output_in_details(self, new_issue_template):
201-
"""Debug output should be inside a <details> block."""
200+
def test_stack_trace_in_details(self, new_issue_template):
201+
"""Stack trace should be inside a <details> block."""
202202
rendered = render_template(new_issue_template, self.SAMPLE_VARS, use_env=False)
203203
assert "<details>" in rendered
204-
assert "Debug Output" in rendered
205-
assert "Array { dtype: Int32, len: 10 }" in rendered
204+
assert "Stack Trace" in rendered
205+
assert "Debug Output" not in rendered
206206

207207
def test_related_comment_target_pattern(self, related_comment_template):
208208
"""Related comment template should also have compatible Target pattern."""

.github/workflows/report-fuzz-crash.yml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,32 @@ jobs:
6464
run: pip install -e .github/scripts
6565

6666
- name: Extract crash info
67+
id: extract
6768
run: |
6869
python3 -m fuzz_report extract \
6970
logs/fuzz_output.log \
7071
--crash-dir crash_artifacts \
7172
--crash-name "${{ inputs.crash_file }}" \
7273
-o crash_info.json
7374
75+
# Validate that extraction found a real crash.
76+
error_variant=$(jq -r '.error_variant' crash_info.json)
77+
panic_message=$(jq -r '.panic_message' crash_info.json)
78+
echo "error_variant=$error_variant"
79+
echo "panic_message=$panic_message"
80+
if [ "$error_variant" = "unknown" ] && [ "$panic_message" = "unknown" ]; then
81+
echo "::notice::No crash info found in fuzzer output — nothing to report."
82+
echo "crash_found=false" >> "$GITHUB_OUTPUT"
83+
else
84+
echo "crash_found=true" >> "$GITHUB_OUTPUT"
85+
fi
86+
87+
- name: Skip if no crash found
88+
if: steps.extract.outputs.crash_found != 'true'
89+
run: |
90+
echo "No parseable crash in fuzzer output — skipping remaining steps."
91+
exit 1
92+
7493
- name: Fetch existing fuzzer issues
7594
env:
7695
GH_TOKEN: ${{ secrets.gh_token }}
@@ -116,7 +135,7 @@ jobs:
116135
claude_args: |
117136
--model claude-opus-4-6
118137
--max-turns 5
119-
--allowedTools "Read,Write,Bash(cat:*),Bash(jq:*)"
138+
--allowedTools "Read,Write,Grep,Glob,Bash(cat:*),Bash(jq:*),Bash(head:*),Bash(xxd:*)"
120139
121140
- name: Create or comment on issue
122141
id: report

0 commit comments

Comments
 (0)