11"""Utility functions for JUnit XML AI analysis enrichment.
22
33Source: https://github.com/myk-org/jenkins-job-insight/blob/main/examples/pytest-junitxml/conftest_junit_ai_utils.py
4+
5+ These functions handle server communication and XML enrichment.
6+ They are not tied to pytest and can be used independently.
47"""
58
69import logging
710import os
8- import shutil
911from pathlib import Path
10- from typing import Any
11- from xml .etree import ElementTree as ET
12- from xml .etree .ElementTree import Element
1312
1413import requests
1514from dotenv import load_dotenv
@@ -52,100 +51,37 @@ def setup_ai_analysis(session) -> None:
5251
5352
5453def enrich_junit_xml (session ) -> None :
55- """Parse failures from JUnit XML, send for AI analysis, and enrich the XML.
54+ """Read JUnit XML, send to server for analysis, write enriched XML back .
5655
57- Reads the JUnit XML that pytest already generated, extracts all failed
58- testcases, sends them to the JJI server for AI analysis , and injects
59- the analysis results back into the same XML .
56+ Reads the JUnit XML that pytest generated, POSTs the raw content to the
57+ JJI server's /analyze-failures endpoint , and writes the enriched XML
58+ (with analysis results) back to the same file .
6059
6160 Args:
6261 session: The pytest session containing config options.
6362 """
6463 xml_path_raw = getattr (session .config .option , "xmlpath" , None )
65- if not xml_path_raw or not Path (xml_path_raw ).exists ():
64+ if not xml_path_raw :
65+ logger .warning ("xunit file not found; pass --junitxml. Skipping AI analysis enrichment" )
6666 return
6767
6868 xml_path = Path (xml_path_raw )
69+ if not xml_path .exists ():
70+ logger .warning (
71+ "xunit file not found under %s. Skipping AI analysis enrichment" ,
72+ xml_path_raw ,
73+ )
74+ return
6975
7076 ai_provider = os .environ .get ("JJI_AI_PROVIDER" )
7177 ai_model = os .environ .get ("JJI_AI_MODEL" )
7278 if not ai_provider or not ai_model :
7379 logger .warning ("JJI_AI_PROVIDER and JJI_AI_MODEL must be set, skipping AI analysis enrichment" )
7480 return
7581
76- failures = _extract_failures_from_xml (xml_path = xml_path )
77- if not failures :
78- logger .info ("jenkins-job-insight: No failures found in JUnit XML, skipping AI analysis" )
79- return
80-
8182 server_url = os .environ ["JJI_SERVER_URL" ]
82- payload : dict [str , Any ] = {
83- "failures" : failures ,
84- "ai_provider" : ai_provider ,
85- "ai_model" : ai_model ,
86- }
87-
88- analysis_map , html_report_url = _fetch_analysis_from_server (server_url = server_url , payload = payload )
89- if not analysis_map :
90- return
91-
92- _apply_analysis_to_xml (xml_path = xml_path , analysis_map = analysis_map , html_report_url = html_report_url )
93-
94-
95- def _extract_failures_from_xml (xml_path : Path ) -> list [dict [str , str ]]:
96- """Extract test failures and errors from a JUnit XML file.
97-
98- Parses the XML and finds all testcase elements with failure or error
99- child elements, extracting test name, error message, and stack trace.
100-
101- Args:
102- xml_path: Path to the JUnit XML report file.
103-
104- Returns:
105- List of failure dicts with test_name, error_message, stack_trace, and status.
106- """
107- tree = ET .parse (xml_path )
108- failures : list [dict [str , str ]] = []
109-
110- for testcase in tree .iter ("testcase" ):
111- failure_elem = testcase .find ("failure" )
112- error_elem = testcase .find ("error" )
113- result_elem = failure_elem if failure_elem is not None else error_elem
83+ raw_xml = xml_path .read_text ()
11484
115- if result_elem is None :
116- continue
117-
118- classname = testcase .get ("classname" , "" )
119- name = testcase .get ("name" , "" )
120- test_name = f"{ classname } .{ name } " if classname else name
121-
122- failures .append ({
123- "test_name" : test_name ,
124- "error_message" : result_elem .get ("message" , "" ),
125- "stack_trace" : result_elem .text or "" ,
126- "status" : "ERROR" if error_elem is not None and failure_elem is None else "FAILED" ,
127- })
128-
129- return failures
130-
131-
132- def _fetch_analysis_from_server (
133- server_url : str , payload : dict [str , Any ]
134- ) -> tuple [dict [tuple [str , str ], dict [str , Any ]], str ]:
135- """Send collected failures to the JJI server and return the analysis map.
136-
137- Args:
138- server_url: The JJI server base URL.
139- payload: Request payload containing failures and AI config.
140-
141- Returns:
142- Tuple of (analysis_map, html_report_url).
143- analysis_map: Mapping of (classname, test_name) to analysis results.
144- html_report_url: The HTML report URL, extracted from the server response
145- or constructed from job_id and server_url when the response omits it.
146- Empty string if neither is available.
147- Returns ({}, "") on request failure.
148- """
14985 try :
15086 timeout_value = int (os .environ .get ("JJI_TIMEOUT" , "600" ))
15187 except ValueError :
@@ -154,206 +90,23 @@ def _fetch_analysis_from_server(
15490
15591 try :
15692 response = requests .post (
157- f"{ server_url .rstrip ('/' )} /analyze-failures" , json = payload , timeout = timeout_value , verify = False
93+ f"{ server_url .rstrip ('/' )} /analyze-failures" ,
94+ json = {
95+ "raw_xml" : raw_xml ,
96+ "ai_provider" : ai_provider ,
97+ "ai_model" : ai_model ,
98+ },
99+ timeout = timeout_value ,
100+ verify = False ,
158101 )
159102 response .raise_for_status ()
160103 result = response .json ()
161- except (requests .RequestException , ValueError ) as exc :
162- error_detail = ""
163- if isinstance (exc , requests .RequestException ) and exc .response is not None :
164- try :
165- error_detail = f" Response: { exc .response .text } "
166- except Exception as detail_exc :
167- logger .debug ("Could not extract response detail: %s" , detail_exc )
168- logger .error ("Server request failed: %s%s" , exc , error_detail )
169- return {}, ""
170-
171- job_id = result .get ("job_id" , "" )
172- html_report_url = result .get ("html_report_url" ) or (
173- f"{ server_url .rstrip ('/' )} /results/{ job_id } .html" if job_id else ""
174- )
175-
176- analysis_map : dict [tuple [str , str ], dict [str , Any ]] = {}
177- for failure in result .get ("failures" , []):
178- test_name = failure .get ("test_name" , "" )
179- analysis = failure .get ("analysis" , {})
180- if test_name and analysis :
181- # test_name is "classname.name" from XML extraction; split on last dot
182- dot_idx = test_name .rfind ("." )
183- if dot_idx > 0 :
184- analysis_map [(test_name [:dot_idx ], test_name [dot_idx + 1 :])] = analysis
185- else :
186- analysis_map [("" , test_name )] = analysis
187-
188- return analysis_map , html_report_url
189-
190-
191- def _apply_analysis_to_xml (
192- xml_path : Path ,
193- analysis_map : dict [tuple [str , str ], dict [str , Any ]],
194- html_report_url : str = "" ,
195- ) -> None :
196- """Apply AI analysis results to JUnit XML testcase elements.
197-
198- Uses exact (classname, name) matching since failures are extracted from
199- the same XML file, guaranteeing identical attribute values.
200- Backs up the original XML before modification and restores it on failure.
201-
202- Args:
203- xml_path: Path to the JUnit XML report file.
204- analysis_map: Mapping of (classname, test_name) to analysis results.
205- html_report_url: URL to the HTML report, added as a testsuite-level property.
206- """
207- backup_path = xml_path .with_suffix (".xml.bak" )
208- shutil .copy2 (xml_path , backup_path )
209-
210- try :
211- tree = ET .parse (xml_path )
212- matched_keys : set [tuple [str , str ]] = set ()
213- for testcase in tree .iter ("testcase" ):
214- key = (testcase .get ("classname" , "" ), testcase .get ("name" , "" ))
215- analysis = analysis_map .get (key )
216- if analysis :
217- _inject_analysis (testcase , analysis )
218- matched_keys .add (key )
219-
220- unmatched = set (analysis_map .keys ()) - matched_keys
221- if unmatched :
222- logger .warning (
223- "jenkins-job-insight: %d analysis results did not match any testcase: %s" ,
224- len (unmatched ),
225- unmatched ,
226- )
227-
228- # Add html_report_url as a testsuite-level property
229- if html_report_url :
230- for testsuite in tree .iter ("testsuite" ):
231- ts_props = testsuite .find ("properties" )
232- if ts_props is None :
233- ts_props = ET .Element ("properties" )
234- testsuite .insert (0 , ts_props )
235- _add_property (ts_props , "html_report_url" , html_report_url )
236-
237- tree .write (str (xml_path ), encoding = "unicode" , xml_declaration = True )
238- backup_path .unlink () # Success - remove backup
239- except Exception :
240- # Restore original XML from backup
241- shutil .copy2 (backup_path , xml_path )
242- backup_path .unlink ()
243- raise
244-
245-
246- def _inject_analysis (testcase : Element , analysis : dict [str , Any ]) -> None :
247- """Inject AI analysis into a JUnit XML testcase element.
248-
249- Adds structured properties (classification, code fix, bug report) and a
250- human-readable summary to the testcase's system-out section.
251-
252- Args:
253- testcase: The XML testcase element to enrich.
254- analysis: Analysis dict with classification, details, affected_tests, etc.
255- """
256- # Add structured properties
257- properties = testcase .find ("properties" )
258- if properties is None :
259- properties = ET .SubElement (testcase , "properties" )
260-
261- _add_property (properties , "ai_classification" , analysis .get ("classification" , "" ))
262- _add_property (properties , "ai_details" , analysis .get ("details" , "" ))
263-
264- affected = analysis .get ("affected_tests" , [])
265- if affected :
266- _add_property (properties , "ai_affected_tests" , ", " .join (affected ))
267-
268- # Code fix properties
269- code_fix = analysis .get ("code_fix" )
270- if code_fix and isinstance (code_fix , dict ):
271- _add_property (properties , "ai_code_fix_file" , code_fix .get ("file" , "" ))
272- _add_property (properties , "ai_code_fix_line" , str (code_fix .get ("line" , "" )))
273- _add_property (properties , "ai_code_fix_change" , code_fix .get ("change" , "" ))
274-
275- # Product bug properties
276- bug_report = analysis .get ("product_bug_report" )
277- if bug_report and isinstance (bug_report , dict ):
278- _add_property (properties , "ai_bug_title" , bug_report .get ("title" , "" ))
279- _add_property (properties , "ai_bug_severity" , bug_report .get ("severity" , "" ))
280- _add_property (properties , "ai_bug_component" , bug_report .get ("component" , "" ))
281- _add_property (properties , "ai_bug_description" , bug_report .get ("description" , "" ))
282-
283- # Jira match properties
284- jira_matches = bug_report .get ("jira_matches" , [])
285- for idx , match in enumerate (jira_matches ):
286- if isinstance (match , dict ):
287- _add_property (properties , f"ai_jira_match_{ idx } _key" , match .get ("key" , "" ))
288- _add_property (properties , f"ai_jira_match_{ idx } _summary" , match .get ("summary" , "" ))
289- _add_property (properties , f"ai_jira_match_{ idx } _status" , match .get ("status" , "" ))
290- _add_property (properties , f"ai_jira_match_{ idx } _url" , match .get ("url" , "" ))
291- _add_property (
292- properties ,
293- f"ai_jira_match_{ idx } _priority" ,
294- match .get ("priority" , "" ),
295- )
296- score = match .get ("score" )
297- if score is not None :
298- _add_property (properties , f"ai_jira_match_{ idx } _score" , str (score ))
299-
300- # Add human-readable system-out
301- text = _format_analysis_text (analysis )
302- if text :
303- system_out = testcase .find ("system-out" )
304- if system_out is None :
305- system_out = ET .SubElement (testcase , "system-out" )
306- system_out .text = text
307- else :
308- # Append to existing system-out
309- existing = system_out .text or ""
310- system_out .text = f"{ existing } \n \n --- AI Analysis ---\n { text } " if existing else text
311-
312-
313- def _add_property (properties_elem : Element , name : str , value : str ) -> None :
314- """Add a property sub-element if value is non-empty."""
315- if value :
316- prop = ET .SubElement (properties_elem , "property" )
317- prop .set ("name" , name )
318- prop .set ("value" , value )
319-
320-
321- def _format_analysis_text (analysis : dict [str , Any ]) -> str :
322- """Format analysis dict as human-readable text for system-out."""
323- parts = []
324-
325- classification = analysis .get ("classification" , "" )
326- if classification :
327- parts .append (f"Classification: { classification } " )
328-
329- details = analysis .get ("details" , "" )
330- if details :
331- parts .append (f"\n { details } " )
332-
333- code_fix = analysis .get ("code_fix" )
334- if code_fix and isinstance (code_fix , dict ):
335- parts .append ("\n Code Fix:" )
336- parts .append (f" File: { code_fix .get ('file' , '' )} " )
337- parts .append (f" Line: { code_fix .get ('line' , '' )} " )
338- parts .append (f" Change: { code_fix .get ('change' , '' )} " )
339-
340- bug_report = analysis .get ("product_bug_report" )
341- if bug_report and isinstance (bug_report , dict ):
342- parts .append ("\n Product Bug:" )
343- parts .append (f" Title: { bug_report .get ('title' , '' )} " )
344- parts .append (f" Severity: { bug_report .get ('severity' , '' )} " )
345- parts .append (f" Component: { bug_report .get ('component' , '' )} " )
346- parts .append (f" Description: { bug_report .get ('description' , '' )} " )
347-
348- jira_matches = bug_report .get ("jira_matches" , [])
349- if jira_matches :
350- parts .append ("\n Possible Jira Matches:" )
351- for match in jira_matches :
352- if isinstance (match , dict ):
353- key = match .get ("key" , "" )
354- summary = match .get ("summary" , "" )
355- status = match .get ("status" , "" )
356- url = match .get ("url" , "" )
357- parts .append (f" { key } : { summary } [{ status } ] { url } " )
104+ except Exception as ex :
105+ logger .exception (f"Failed to enrich JUnit XML, original preserved. { ex } " )
106+ return
358107
359- return "\n " .join (parts ) if parts else ""
108+ if enriched_xml := result .get ("enriched_xml" ):
109+ xml_path .write_text (enriched_xml )
110+ logger .info ("JUnit XML enriched with AI analysis: %s" , xml_path )
111+ else :
112+ logger .info ("No enriched XML returned (no failures or analysis failed)" )
0 commit comments