Skip to content

Commit 05a8bf6

Browse files
authored
Update files for ai-generated functionality (#4000)
##### Short description: Update files to match the source files under myk-org/jenkins-job-insight#33 The xml is passed as-is for analysis ##### More details: ##### What this PR does / why we need it: ##### Which issue(s) this PR fixes: ##### Special notes for reviewer: ##### jira-ticket: <!-- full-ticket-url needs to be provided. This would add a link to the pull request to the jira and close it when the pull request is merged If the task is not tracked by a Jira ticket, just write "NONE". --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Improvements** * AI test enrichment now only runs when failures occur, reducing work for all-passing test runs. * Enrichment is simplified to send the full test report for server-side analysis and return an enriched report. * Failure-safe handling ensures enrichment errors are logged without affecting test outcomes or overwriting original results. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 4c960c5 commit 05a8bf6

File tree

2 files changed

+40
-284
lines changed

2 files changed

+40
-284
lines changed

conftest.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -853,11 +853,14 @@ def pytest_sessionfinish(session, exitstatus):
853853
# Enrich JUnit XML with AI analysis after all tests complete.
854854
# Source: https://github.com/myk-org/jenkins-job-insight/blob/main/examples/pytest-junitxml/conftest_junit_ai.py
855855
if session.config.option.analyze_with_ai:
856-
try:
857-
enrich_junit_xml(session=session)
858-
# Do not fail on any AI analysis failures
859-
except Exception:
860-
LOGGER.exception("Failed to enrich JUnit XML, original preserved")
856+
if exitstatus == 0:
857+
LOGGER.info("No test failures (exit code %d), skipping AI analysis", exitstatus)
858+
859+
else:
860+
try:
861+
enrich_junit_xml(session)
862+
except Exception:
863+
LOGGER.exception("Failed to enrich JUnit XML, original preserved")
861864

862865
session.config.option.log_listener.stop()
863866

utilities/junit_ai_utils.py

Lines changed: 32 additions & 279 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
"""Utility functions for JUnit XML AI analysis enrichment.
22
33
Source: 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

69
import logging
710
import os
8-
import shutil
911
from pathlib import Path
10-
from typing import Any
11-
from xml.etree import ElementTree as ET
12-
from xml.etree.ElementTree import Element
1312

1413
import requests
1514
from dotenv import load_dotenv
@@ -52,100 +51,37 @@ def setup_ai_analysis(session) -> None:
5251

5352

5453
def 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("\nCode 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("\nProduct 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("\nPossible 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

Comments
 (0)