Skip to content

Commit c3a9dd3

Browse files
[ci:change] CI Failure Bot: limit sent data and context when possible #611
Closes #611 --------- Co-authored-by: Federico Capoano <f.capoano@openwisp.io>
1 parent 5906760 commit c3a9dd3

2 files changed

Lines changed: 305 additions & 42 deletions

File tree

.github/actions/bot-ci-failure/analyze_failure.py

Lines changed: 132 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,142 @@
11
import os
2+
import re
23
import secrets
34
import sys
45

56
from google import genai
67
from 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

9111
def 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

33142
def 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

Comments
 (0)