44import argparse
55import json
66import os
7+ import re
78import subprocess
89import sys
910from pathlib import Path
1415
1516TEMPLATES_DIR = Path (__file__ ).parent / "templates"
1617
18+ # Marker used to find/update the single recurrence-tracking comment.
19+ _RECURRENCE_MARKER = "<!-- fuzzer-recurrence-tracker -->"
20+ _RECURRENCE_COUNT_RE = r"<!-- fuzzer-recurrence-tracker count:(\d+) -->"
21+ # Variables that must be set (non-empty) before creating or commenting on an issue.
22+ REQUIRED_REPORT_VARIABLES = ["FUZZ_TARGET" , "CRASH_FILE" , "ARTIFACT_URL" ]
23+
1724
1825def parse_var_arg (var_str : str ) -> tuple [str , str ]:
1926 """Parse a -v KEY=VALUE argument into (key, value)."""
@@ -80,7 +87,14 @@ def _build_template_variables(
8087def _determine_action (
8188 dedup_path : str | Path | None ,
8289) -> tuple [str , dict | None ]:
83- """Determine action from dedup result. Returns (action, dedup_dict)."""
90+ """Determine action from dedup result. Returns (action, dedup_dict).
91+
92+ Actions:
93+ create – new issue
94+ skip – exact duplicate, do nothing
95+ update_count – high-confidence duplicate, bump recurrence counter
96+ comment – medium-confidence duplicate, post full comment
97+ """
8498 if not dedup_path or not Path (dedup_path ).exists ():
8599 return "create" , None
86100
@@ -91,9 +105,90 @@ def _determine_action(
91105 if dedup .get ("confidence" ) == "exact" :
92106 return "skip" , dedup
93107
108+ if dedup .get ("confidence" ) == "high" :
109+ return "update_count" , dedup
110+
94111 return "comment" , dedup
95112
96113
114+ def _render_recurrence_body (count : int ) -> str :
115+ """Render the minimal recurrence-tracking comment body."""
116+ return (
117+ f"Seen **{ count } ** time{ 's' if count != 1 else '' } \n \n "
118+ f"<!-- fuzzer-recurrence-tracker count:{ count } -->"
119+ )
120+
121+
122+ def _update_recurrence_count (repo : str , issue_number : int | str ) -> int :
123+ """Find-or-create the recurrence comment, incrementing its count.
124+
125+ Uses a compare-and-swap pattern: reads the current count from the
126+ existing comment (if any), increments it, and writes back.
127+
128+ Returns the new count.
129+ """
130+ # List all comments on the issue
131+ result = subprocess .run (
132+ [
133+ "gh" ,
134+ "api" ,
135+ f"repos/{ repo } /issues/{ issue_number } /comments" ,
136+ "--paginate" ,
137+ "--jq" ,
138+ f'.[] | select(.body | contains("{ _RECURRENCE_MARKER } ")) | {{id: .id, body: .body}}' ,
139+ ],
140+ capture_output = True ,
141+ text = True ,
142+ check = True ,
143+ )
144+
145+ existing_id = None
146+ current_count = 0
147+
148+ for line in result .stdout .strip ().splitlines ():
149+ if not line :
150+ continue
151+ comment = json .loads (line )
152+ existing_id = comment ["id" ]
153+ m = re .search (_RECURRENCE_COUNT_RE , comment ["body" ])
154+ if m :
155+ current_count = int (m .group (1 ))
156+ break
157+
158+ new_count = current_count + 1
159+ body = _render_recurrence_body (new_count )
160+
161+ if existing_id :
162+ # Update existing comment (not atomic — race is acceptable since
163+ # fuzz CI jobs are serialized)
164+ subprocess .run (
165+ [
166+ "gh" ,
167+ "api" ,
168+ f"repos/{ repo } /issues/comments/{ existing_id } " ,
169+ "-X" ,
170+ "PATCH" ,
171+ "-f" ,
172+ f"body={ body } " ,
173+ ],
174+ check = True ,
175+ )
176+ else :
177+ # Create new recurrence comment
178+ subprocess .run (
179+ [
180+ "gh" ,
181+ "api" ,
182+ f"repos/{ repo } /issues/{ issue_number } /comments" ,
183+ "-f" ,
184+ f"body={ body } " ,
185+ ],
186+ check = True ,
187+ )
188+
189+ return new_count
190+
191+
97192def cmd_extract (args : argparse .Namespace ) -> int :
98193 """Extract crash info from log file."""
99194 if not Path (args .log_file ).exists ():
@@ -142,6 +237,16 @@ def cmd_check_duplicate(args: argparse.Namespace) -> int:
142237 return 0
143238
144239
240+ def _validate_required_variables (variables : dict [str , str ]) -> list [str ]:
241+ """Return the names of any required variables that are missing or empty."""
242+ missing = []
243+ for name in REQUIRED_REPORT_VARIABLES :
244+ val = variables .get (name , "" )
245+ if not val or val == "(not set)" :
246+ missing .append (name )
247+ return missing
248+
249+
145250def cmd_report (args : argparse .Namespace ) -> int :
146251 """Create or comment on a GitHub issue based on crash + dedup results."""
147252 if not Path (args .crash_info ).exists ():
@@ -163,11 +268,32 @@ def cmd_report(args: argparse.Namespace) -> int:
163268 variables = _build_template_variables (crash_info , args .var , claude_analysis )
164269 existing_issue = dedup .get ("issue_number" ) if dedup else None
165270
271+ # Validate required variables before creating/commenting (skip is fine without them)
272+ if action != "skip" :
273+ missing = _validate_required_variables (variables )
274+ if missing :
275+ print (
276+ f"Error: Required variables not set: { ', ' .join (missing )} " ,
277+ file = sys .stderr ,
278+ )
279+ _write_github_output ("validation_failed" , "true" )
280+ _write_github_output ("missing_variables" , ", " .join (missing ))
281+ return 1
282+
166283 if action == "skip" :
167284 print (f"Exact duplicate of #{ existing_issue } , skipping." , file = sys .stderr )
168285 _write_github_output ("issue_number" , str (existing_issue ))
169286 return 0
170287
288+ if action == "update_count" :
289+ new_count = _update_recurrence_count (args .repo , existing_issue )
290+ print (
291+ f"Updated recurrence count on #{ existing_issue } to { new_count } " ,
292+ file = sys .stderr ,
293+ )
294+ _write_github_output ("issue_number" , str (existing_issue ))
295+ return 0
296+
171297 if action == "comment" :
172298 variables .setdefault ("DEDUP_REASON" , dedup .get ("reason" , "" ))
173299 variables .setdefault ("DEDUP_CONFIDENCE" , dedup .get ("confidence" , "" ))
@@ -245,6 +371,7 @@ def cmd_dry_run(args: argparse.Namespace) -> int:
245371 print (f" panic_message: { crash_info .panic_message } " , file = sys .stderr )
246372 print (f" crash_type: { crash_info .crash_type } " , file = sys .stderr )
247373 print (f" seed_hash: { crash_info .seed_hash } " , file = sys .stderr )
374+ print (f" stack_frames: { crash_info .stack_frames [:5 ]} " , file = sys .stderr )
248375 print (file = sys .stderr )
249376
250377 # Step 2: Dedup (if issues file provided)
@@ -261,6 +388,8 @@ def cmd_dry_run(args: argparse.Namespace) -> int:
261388 print (f" confidence: { dedup_result .confidence } " , file = sys .stderr )
262389 print (f" issue: #{ dedup_result .issue_number } " , file = sys .stderr )
263390 print (f" reason: { dedup_result .reason } " , file = sys .stderr )
391+ if dedup_result .debug :
392+ print (f" debug: { json .dumps (dedup_result .debug , indent = 4 )} " , file = sys .stderr )
264393 print (file = sys .stderr )
265394
266395 # Write dedup to temp file so _determine_action can read it
@@ -279,6 +408,15 @@ def cmd_dry_run(args: argparse.Namespace) -> int:
279408 variables = _build_template_variables (crash_info , args .var , claude_analysis )
280409 existing_issue = dedup .get ("issue_number" ) if dedup else None
281410
411+ # Validate required variables (same check as real report)
412+ if action != "skip" :
413+ missing = _validate_required_variables (variables )
414+ if missing :
415+ print (
416+ f"Warning: Required variables not set: { ', ' .join (missing )} " ,
417+ file = sys .stderr ,
418+ )
419+
282420 print (f"=== Action: { action .upper ()} ===" , file = sys .stderr )
283421
284422 if action == "skip" :
@@ -288,6 +426,15 @@ def cmd_dry_run(args: argparse.Namespace) -> int:
288426 )
289427 return 0
290428
429+ if action == "update_count" :
430+ print (
431+ f"(would update recurrence count on #{ existing_issue } )" ,
432+ file = sys .stderr ,
433+ )
434+ print (file = sys .stderr )
435+ print (_render_recurrence_body (1 ))
436+ return 0
437+
291438 if action == "comment" :
292439 template_path = TEMPLATES_DIR / "related_comment.md"
293440 variables .setdefault ("DEDUP_REASON" , dedup .get ("reason" , "" ))
0 commit comments