55import json
66import os
77import re
8+ import shlex
89import subprocess
910import sys
11+ import time
1012from pathlib import Path
1113
1214from .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+
4369def _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+
5697def _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
0 commit comments