@@ -139,29 +139,8 @@ def _record_action(action: str, outcome: str, component: str, detail: dict) -> N
139139 )
140140
141141
142- def create_planfile_ticket (alert : dict , * , source : str = "healing-webhook" ) -> dict :
143- """Create a planfile ticket for an alert.
144-
145- The ticket body is produced by ticket_builder.build_ticket_payload and
146- is *LLM-agnostic* — any coding agent (Windsurf/Cursor/Claude Code/aider)
147- can consume it verbatim via `planfile ticket show <ID>`.
148-
149- Returns a small dict describing the outcome; never raises so the
150- healing pipeline isn't blocked by a planfile CLI issue.
151- """
152- if not PLANFILE_ENABLED :
153- return {"skipped" : "PLANFILE_ENABLED=false" }
154-
155- try :
156- payload = build_ticket_payload (alert , repo = REPO_PATH , source = source )
157- except Exception as exc : # noqa: BLE001
158- log .warning ("ticket_builder failed: %s" , exc )
159- TICKETS_CREATED .labels (severity = "unknown" , outcome = "build_failed" ).inc ()
160- return {"error" : f"ticket_builder failed: { exc } " }
161-
162- # Enrich the ticket description with a vallm pre-flight summary of the
163- # affected files. This gives the LLM agent immediate insight into whether
164- # the files are syntactically clean or already broken (saves a round trip).
142+ def _enrich_ticket_with_vallm (alert : dict , payload : dict ) -> None :
143+ """Add vallm pre-flight summary to ticket description."""
165144 try :
166145 labels = alert .get ("labels" , {}) or {}
167146 component = labels .get ("component" , "unknown" )
@@ -179,10 +158,9 @@ def create_planfile_ticket(alert: dict, *, source: str = "healing-webhook") -> d
179158 except Exception as exc : # noqa: BLE001
180159 log .debug ("vallm enrichment skipped: %s" , exc )
181160
182- severity = next (
183- (lbl .split (":" , 1 )[1 ] for lbl in payload ["labels" ] if lbl .startswith ("severity:" )),
184- "unknown" ,
185- )
161+
162+ def _build_planfile_command (payload : dict ) -> list [str ]:
163+ """Build planfile ticket create command from payload."""
186164 cmd = [
187165 PLANFILE_BIN ,
188166 "ticket" ,
@@ -199,21 +177,28 @@ def create_planfile_ticket(alert: dict, *, source: str = "healing-webhook") -> d
199177 ]
200178 for label in payload ["labels" ]:
201179 cmd .extend (["--label" , label ])
180+ return cmd
181+
182+
183+ def _extract_ticket_id_from_stdout (stdout : str ) -> str | None :
184+ """Extract PLF-* ticket ID from planfile stdout."""
185+ for line in (stdout or "" ).splitlines ():
186+ if "PLF-" in line :
187+ for word in line .split ():
188+ if word .startswith ("PLF-" ):
189+ return word .strip (":,." )
190+ return None
202191
192+
193+ def _execute_planfile_create (cmd : list [str ], severity : str ) -> dict :
194+ """Execute planfile ticket create command and return result."""
203195 try :
204196 proc = subprocess .run (
205197 cmd , capture_output = True , text = True , cwd = REPO_PATH , timeout = 15
206198 )
207199 outcome = "success" if proc .returncode == 0 else "failed"
208200 TICKETS_CREATED .labels (severity = severity , outcome = outcome ).inc ()
209- # Extract the new ticket ID from planfile's stdout when possible.
210- new_id = None
211- for line in (proc .stdout or "" ).splitlines ():
212- if "PLF-" in line :
213- for word in line .split ():
214- if word .startswith ("PLF-" ):
215- new_id = word .strip (":,." )
216- break
201+ new_id = _extract_ticket_id_from_stdout (proc .stdout or "" )
217202 log .info ("planfile ticket create -> %s (%s)" , outcome , new_id or "?" )
218203 return {
219204 "outcome" : outcome ,
@@ -231,6 +216,36 @@ def create_planfile_ticket(alert: dict, *, source: str = "healing-webhook") -> d
231216 return {"error" : str (exc )}
232217
233218
219+ def create_planfile_ticket (alert : dict , * , source : str = "healing-webhook" ) -> dict :
220+ """Create a planfile ticket for an alert.
221+
222+ The ticket body is produced by ticket_builder.build_ticket_payload and
223+ is *LLM-agnostic* — any coding agent (Windsurf/Cursor/Claude Code/aider)
224+ can consume it verbatim via `planfile ticket show <ID>`.
225+
226+ Returns a small dict describing the outcome; never raises so the
227+ healing pipeline isn't blocked by a planfile CLI issue.
228+ """
229+ if not PLANFILE_ENABLED :
230+ return {"skipped" : "PLANFILE_ENABLED=false" }
231+
232+ try :
233+ payload = build_ticket_payload (alert , repo = REPO_PATH , source = source )
234+ except Exception as exc : # noqa: BLE001
235+ log .warning ("ticket_builder failed: %s" , exc )
236+ TICKETS_CREATED .labels (severity = "unknown" , outcome = "build_failed" ).inc ()
237+ return {"error" : f"ticket_builder failed: { exc } " }
238+
239+ _enrich_ticket_with_vallm (alert , payload )
240+
241+ severity = next (
242+ (lbl .split (":" , 1 )[1 ] for lbl in payload ["labels" ] if lbl .startswith ("severity:" )),
243+ "unknown" ,
244+ )
245+ cmd = _build_planfile_command (payload )
246+ return _execute_planfile_create (cmd , severity )
247+
248+
234249def _run_docker (image : str , cmd : list [str ], timeout : int = 120 ) -> tuple [int , str , str ]:
235250 """Run a one-shot docker container, bind-mount the repo read-write."""
236251 argv = [
@@ -451,6 +466,38 @@ def heal_vallm_validate(component: str, detail: dict) -> dict:
451466# is breached we still create a ticket — the budget check is enforced by
452467# scripts/redup-check.sh exit code (non-zero = breach).
453468
469+ def _parse_redup_summary (payload : dict ) -> dict :
470+ """Parse redup-check.sh JSON payload into summary dict."""
471+ s = payload .get ("summary" , {}) or {}
472+ summary = {
473+ "groups" : int (s .get ("total_groups" , 0 )),
474+ "saved_lines" : int (s .get ("total_saved_lines" , 0 )),
475+ "top_groups" : [],
476+ }
477+ # Top 3 groups by fragment count for ticket context
478+ groups = sorted (
479+ payload .get ("groups" , []) or [],
480+ key = lambda g : len (g .get ("fragments" , []) or []),
481+ reverse = True ,
482+ )[:3 ]
483+ summary ["top_groups" ] = [
484+ {
485+ "fragments" : len (g .get ("fragments" , []) or []),
486+ "files" : sorted ({f .get ("file" , "?" ) for f in (g .get ("fragments" ) or [])})[:5 ],
487+ "function" : (g .get ("fragments" ) or [{}])[0 ].get ("function_name" , "(module)" ),
488+ }
489+ for g in groups
490+ ]
491+ return summary
492+
493+
494+ def _update_redup_metrics (summary : dict , breach : bool ) -> None :
495+ """Update Prometheus metrics for redup check results."""
496+ REDUP_GROUPS .set (summary ["groups" ])
497+ REDUP_SAVED_LINES .set (summary ["saved_lines" ])
498+ REDUP_BUDGET_BREACH .set (1 if breach else 0 )
499+
500+
454501def _run_redup_check (timeout : int = 180 ) -> dict :
455502 """Run redup-check.sh and parse the filtered JSON report.
456503
@@ -478,29 +525,11 @@ def _run_redup_check(timeout: int = 180) -> dict:
478525 import json as _json
479526 with open (filtered_json , "r" , encoding = "utf-8" ) as fh :
480527 payload = _json .load (fh )
481- s = payload .get ("summary" , {}) or {}
482- summary ["groups" ] = int (s .get ("total_groups" , 0 ))
483- summary ["saved_lines" ] = int (s .get ("total_saved_lines" , 0 ))
484- # Top 3 groups by fragment count for ticket context
485- groups = sorted (
486- payload .get ("groups" , []) or [],
487- key = lambda g : len (g .get ("fragments" , []) or []),
488- reverse = True ,
489- )[:3 ]
490- summary ["top_groups" ] = [
491- {
492- "fragments" : len (g .get ("fragments" , []) or []),
493- "files" : sorted ({f .get ("file" , "?" ) for f in (g .get ("fragments" ) or [])})[:5 ],
494- "function" : (g .get ("fragments" ) or [{}])[0 ].get ("function_name" , "(module)" ),
495- }
496- for g in groups
497- ]
528+ summary = _parse_redup_summary (payload )
498529 except Exception as exc : # noqa: BLE001
499530 log .debug ("redup filtered report parse failed: %s" , exc )
500531
501- REDUP_GROUPS .set (summary ["groups" ])
502- REDUP_SAVED_LINES .set (summary ["saved_lines" ])
503- REDUP_BUDGET_BREACH .set (1 if breach else 0 )
532+ _update_redup_metrics (summary , breach )
504533
505534 return {
506535 "ok" : not breach ,
0 commit comments