11import os
2+ import re
23import secrets
34import sys
45
56from google import genai
67from google .genai import types
78
9+ # Keywords that indicate an automated test failure (as opposed to
10+ # QA-only / commit-message-only failures).
11+ TEST_FAILURE_MARKERS = (
12+ "FAIL:" ,
13+ "ERROR:" ,
14+ "FAILED (" ,
15+ "Traceback (most recent call last):" ,
16+ "AssertionError" ,
17+ )
18+
19+
20+ def _normalize_for_dedup (text ):
21+ """Normalize a log body so near-duplicate CI outputs hash identically.
22+
23+ Strips version strings, timestamps, and platform info that differ
24+ across matrix jobs but do not represent genuinely different failures.
25+ The original body is NOT modified -- this is used only for the dedup key.
26+ """
27+ t = text
28+ # Python version: "Python 3.10.5" -> "Python X"
29+ t = re .sub (r"Python \d+\.\d+(?:\.\d+)?" , "Python X" , t )
30+ # pytest/tool versions: "pytest-7.2.0" -> "pytest-X"
31+ t = re .sub (
32+ r"(pytest|django|pip|setuptools|wheel)-\d+[\d.]*" , r"\1-X" , t , flags = re .I
33+ )
34+ # Generic semver-like version numbers (e.g., "7.2.0") preceded by - or /
35+ t = re .sub (r"(?<=[-/])\d+\.\d+(?:\.\d+)+" , "X" , t )
36+ # Timestamps: 2024-03-14 10:23:45 or 2024-03-14T10:23:45
37+ t = re .sub (r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}" , "TIMESTAMP" , t )
38+ # "platform linux -- Python X, pytest-X, ..." entire line
39+ t = re .sub (r"^platform\s+\S+\s+--\s+.*$" , "PLATFORM_LINE" , t , flags = re .MULTILINE )
40+ # Elapsed time: "in 1.234s"
41+ t = re .sub (r"in \d+\.\d+s" , "in X.Xs" , t )
42+ return t
43+
44+
45+ def _extract_failed_tests (body ):
46+ """Return only the failing test sections from a test-runner log.
47+
48+ Keeps every block that sits between two separator lines (====…)
49+ and contains at least one failure marker.
50+ """
51+ # Split on the "======…" separators that unittest / pytest emit.
52+ blocks = re .split (r"(?:={50,})" , body )
53+ failed = [
54+ block .strip ()
55+ for block in blocks
56+ if any (m in block for m in TEST_FAILURE_MARKERS )
57+ ]
58+ if failed :
59+ sep = "\n " + "=" * 70 + "\n "
60+ return sep + sep .join (failed ) + sep
61+ # If we couldn't isolate individual blocks, return the whole body
62+ # so the LLM still has something to work with.
63+ return body
64+
65+
66+ def process_error_logs (content ):
67+ """Post-process raw CI logs.
68+
69+ Returns
70+ -------
71+ (processed_text, tests_failed) : tuple[str, bool]
72+ *processed_text* – deduplicated, failure-only log.
73+ *tests_failed* – ``True`` when at least one job contains a real
74+ test failure (as opposed to a QA / commit-message check).
75+ """
76+ tests_failed = False
77+ final_blocks = []
78+ seen_bodies = set ()
79+ # The workflow writes each job as ``===== JOB <id> =====``.
80+ job_splits = re .split (r"(===== JOB \d+ =====)" , content )
81+ # Build (header, body) pairs.
82+ jobs = []
83+ if len (job_splits ) > 1 :
84+ preamble = job_splits [0 ].strip ()
85+ if preamble :
86+ jobs .append (("" , preamble ))
87+ for i in range (1 , len (job_splits ), 2 ):
88+ header = job_splits [i ]
89+ body = job_splits [i + 1 ] if i + 1 < len (job_splits ) else ""
90+ jobs .append ((header , body ))
91+ else :
92+ jobs = [("" , content )]
93+ for header , body in jobs :
94+ if not body .strip ():
95+ continue
96+ # Deduplicate – skip if we already saw a near-identical body.
97+ body_key = _normalize_for_dedup (body .strip ())
98+ if body_key in seen_bodies :
99+ continue
100+ seen_bodies .add (body_key )
101+ # Detect real test failures and keep only the failing parts.
102+ job_has_test_failure = any (m in body for m in TEST_FAILURE_MARKERS )
103+ if job_has_test_failure :
104+ tests_failed = True
105+ body = _extract_failed_tests (body )
106+ block = f"{ header } \n { body .strip ()} " if header else body .strip ()
107+ final_blocks .append (block )
108+ return "\n \n " .join (final_blocks ), tests_failed
109+
8110
9111def get_error_logs ():
112+ """Read and process CI failure logs.
113+
114+ Returns
115+ -------
116+ (logs_text, tests_failed) : tuple[str, bool]
117+ """
10118 log_file = "failed_logs.txt"
11119 if not os .path .exists (log_file ):
12- return "No failed logs found."
120+ return "No failed logs found." , False
13121 try :
14122 with open (log_file , "r" , encoding = "utf-8" ) as f :
15123 content = f .read ()
16- TARGET_MAX = 30000
17- if len (content ) <= TARGET_MAX :
18- return content
19- truncation_marker = (
20- f"\n \n ... [LOGS TRUNCATED: "
21- f"{ len (content ) - TARGET_MAX } characters removed] ...\n \n "
22- )
23- actual_allowed_chars = TARGET_MAX - len (truncation_marker )
24- head_size = int (actual_allowed_chars * 0.2 )
25- tail_size = int (actual_allowed_chars * 0.8 )
26- head = content [:head_size ]
27- tail = content [- tail_size :]
28- return head + truncation_marker + tail
124+ processed , tests_failed = process_error_logs (content )
125+ TARGET_MAX = 30000
126+ if len (processed ) <= TARGET_MAX :
127+ return processed , tests_failed
128+ truncation_marker = (
129+ f"\n \n ... [LOGS TRUNCATED: "
130+ f"{ len (processed ) - TARGET_MAX } characters removed] ...\n \n "
131+ )
132+ actual_allowed_chars = TARGET_MAX - len (truncation_marker )
133+ head_size = int (actual_allowed_chars * 0.2 )
134+ tail_size = int (actual_allowed_chars * 0.8 )
135+ head = processed [:head_size ]
136+ tail = processed [- tail_size :]
137+ return head + truncation_marker + tail , tests_failed
29138 except Exception as e :
30- return f"Error reading logs: { e } "
139+ return f"Error reading logs: { e } " , False
31140
32141
33142def get_repo_context (base_dir = "pr_code" , max_chars = 500000 ):
@@ -80,7 +189,6 @@ def get_repo_context(base_dir="pr_code", max_chars=500000):
80189 current_length += len (file_xml )
81190 if not context_parts :
82191 return "No relevant source files found in repository."
83-
84192 return "" .join (context_parts )
85193
86194
@@ -89,33 +197,34 @@ def main():
89197 if not api_key :
90198 print ("::warning::Skipping: No API Key found." , file = sys .stderr )
91199 return
92-
93200 client = genai .Client (
94201 api_key = api_key ,
95202 http_options = types .HttpOptions (
96203 retry_options = types .HttpRetryOptions (attempts = 4 )
97204 ),
98205 )
99- error_log = get_error_logs ()
206+ error_log , tests_failed = get_error_logs ()
100207 if error_log .startswith ("No failed logs" ) or error_log .startswith (
101208 "Error reading logs"
102209 ):
103210 print ("::warning::Skipping: No failure logs to analyse." , file = sys .stderr )
104211 return
105-
106- repo_context = get_repo_context ()
212+ # Only fetch the full repository code context when automated tests
213+ # actually failed. For QA-only or commit-message failures the code
214+ # is not needed and would waste prompt tokens.
215+ if tests_failed :
216+ repo_context = get_repo_context ()
217+ else :
218+ repo_context = "Code context omitted (no test failures detected)."
107219 pr_author = os .environ .get ("PR_AUTHOR" , "contributor" )
108220 actor = os .environ .get ("ACTOR" , "" ).strip () or pr_author
109221 commit_sha = os .environ .get ("COMMIT_SHA" , "unknown" )
110222 short_sha = commit_sha [:7 ] if commit_sha != "unknown" else "unknown"
111-
112223 if pr_author .lower () == actor .lower ():
113224 greeting = f"Hello @{ pr_author } ,"
114225 else :
115226 greeting = f"Hello @{ pr_author } and @{ actor } ,"
116-
117227 tag_id = secrets .token_hex (4 )
118-
119228 system_instruction = f"""
120229 You are an automated CI Failure helper bot for the OpenWISP project.
121230 Your goal is to analyze CI failure logs and provide helpful, actionable feedback.
@@ -176,7 +285,6 @@ def main():
176285 4. Use Markdown for formatting. Do not include introductory filler text
177286 before the header.
178287 """
179-
180288 prompt = f"""
181289 Analyze the following CI failure and provide the appropriate remediation
182290 according to your instructions.
@@ -191,7 +299,6 @@ def main():
191299 { repo_context }
192300 </code_context_{ tag_id } >
193301 """
194-
195302 raw_model = os .environ .get ("GEMINI_MODEL" , "" ).strip ()
196303 gemini_model = raw_model if raw_model else "gemini-2.5-flash-lite"
197304 try :
0 commit comments