This is an automated email from the ASF dual-hosted git repository.
dspavlov pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ignite-teamcity-bot.git
The following commit(s) were added to refs/heads/master by this push:
new 63eaafb8 IGNITE-28635 Add AI prompt log wait fallback (#219)
63eaafb8 is described below
commit 63eaafb8b3706d11c68721828e638da0b05c015c
Author: ignitetcbot <[email protected]>
AuthorDate: Sat May 9 15:10:29 2026 +0300
IGNITE-28635 Add AI prompt log wait fallback (#219)
add cached-only fallback for long TeamCity/log processing
improve AI prompt loading dialog and no-wait mode
---------
Codex co-authored-by: Dmitriy Pavlov <[email protected]>
---
.../ignite/ci/web/rest/pr/GetPrTestFailures.java | 7 +-
.../rest/tracked/GetTrackedBranchTestResults.java | 6 +-
ignite-tc-helper-web/src/main/webapp/current.html | 31 -
.../src/main/webapp/js/common-1.7.js | 326 +++++++
ignite-tc-helper-web/src/main/webapp/pr.html | 31 -
.../ci/tcbot/chain/PrChainsProcessorTest.java | 28 +
.../org/apache/ignite/ci/web/CommonScriptTest.java | 55 ++
.../engine/build/SingleBuildResultsService.java | 15 +-
.../engine/build/TestFailuresAiPromptBuilder.java | 1022 ++++++++++++++++++--
.../ignite/tcbot/engine/chain/FullChainRunCtx.java | 22 +
.../ignite/tcbot/engine/chain/MultBuildRunCtx.java | 30 +
.../tcbot/engine/chain/SingleBuildRunCtx.java | 40 +
.../ignite/tcbot/engine/pr/PrChainsProcessor.java | 73 +-
.../tracked/TrackedBranchChainsProcessor.java | 75 +-
.../build/TestFailuresAiPromptBuilderTest.java | 99 ++
15 files changed, 1704 insertions(+), 156 deletions(-)
diff --git
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/pr/GetPrTestFailures.java
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/pr/GetPrTestFailures.java
index 0d9c22a0..4bb9085a 100644
---
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/pr/GetPrTestFailures.java
+++
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/pr/GetPrTestFailures.java
@@ -125,6 +125,7 @@ public class GetPrTestFailures {
* @param maxDetailsChars Max chars per TeamCity failure details block.
Non-positive means default cap.
* @param testName Optional full test name filter.
* @param promptSuiteId Optional suite id filter.
+ * @param waitForTc Wait for fresh TeamCity context and build log
processing.
*/
@GET
@Path("results/aiPrompt")
@@ -138,7 +139,8 @@ public class GetPrTestFailures {
@Nullable @QueryParam("baseBranchForTc") String baseBranchForTc,
@Nullable @QueryParam("maxDetailsChars") Integer maxDetailsChars,
@Nullable @QueryParam("testName") String testName,
- @Nullable @QueryParam("promptSuiteId") String promptSuiteId) {
+ @Nullable @QueryParam("promptSuiteId") String promptSuiteId,
+ @Nullable @QueryParam("waitForTc") Boolean waitForTc) {
final TcBotApplicationContext appCtx =
CtxListener.getApplicationContext(ctx);
return
appCtx.getInstance(PrChainsProcessor.class).getPrFailuresAiPrompt(
@@ -151,6 +153,7 @@ public class GetPrTestFailures {
baseBranchForTc,
TestFailuresAiPromptBuilder.restMaxDetailsChars(maxDetailsChars),
testName,
- promptSuiteId);
+ promptSuiteId,
+ waitForTc == null || waitForTc);
}
}
diff --git
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/tracked/GetTrackedBranchTestResults.java
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/tracked/GetTrackedBranchTestResults.java
index b88d0cff..a29b0f40 100644
---
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/tracked/GetTrackedBranchTestResults.java
+++
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/tracked/GetTrackedBranchTestResults.java
@@ -96,7 +96,8 @@ public class GetTrackedBranchTestResults {
@Nullable @QueryParam("count") Integer mergeCnt,
@Nullable @QueryParam("maxDetailsChars") Integer maxDetailsChars,
@Nullable @QueryParam("testName") String testName,
- @Nullable @QueryParam("promptSuiteId") String promptSuiteId) {
+ @Nullable @QueryParam("promptSuiteId") String promptSuiteId,
+ @Nullable @QueryParam("waitForTc") Boolean waitForTc) {
int actualMergeBuilds = (mergeCnt == null || mergeCnt < 1) ? 1 :
mergeCnt;
return CtxListener.getApplicationContext(ctx)
@@ -109,7 +110,8 @@ public class GetTrackedBranchTestResults {
SortOption.parseStringValue(sortOption),
maxDetailsChars,
testName,
- promptSuiteId);
+ promptSuiteId,
+ waitForTc == null || waitForTc);
}
@GET
diff --git a/ignite-tc-helper-web/src/main/webapp/current.html
b/ignite-tc-helper-web/src/main/webapp/current.html
index 48e71eb9..c7cbce7c 100644
--- a/ignite-tc-helper-web/src/main/webapp/current.html
+++ b/ignite-tc-helper-web/src/main/webapp/current.html
@@ -360,37 +360,6 @@ function openAiPromptForSuite(suiteId) {
openAiPrompt(aiPromptUrlForSuite(suiteId));
}
-function openAiPrompt(url) {
- let promptWindow = window.open("about:blank", "_blank");
- if (promptWindow) {
- promptWindow.document.title = "AI prompt";
- promptWindow.document.body.innerHTML = "<pre>Loading AI
prompt...</pre>";
- }
-
- $.ajax({
- url: url,
- timeout: 300000,
- success: function (result) {
- writeAiPromptWindow(promptWindow, result);
- },
- error: function (xhr, status, error) {
- showErrInLoadStatus(xhr, status, error);
- writeAiPromptWindow(promptWindow, "AI prompt request failed: " +
status + "\n\n" + xhr.responseText);
- }
- });
-}
-
-function writeAiPromptWindow(promptWindow, text) {
- if (!promptWindow)
- return;
-
- promptWindow.document.body.innerHTML = "";
-
- let pre = promptWindow.document.createElement("pre");
- pre.textContent = text;
- promptWindow.document.body.appendChild(pre);
-}
-
</script>
diff --git a/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js
b/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js
index 69bd7af0..0ecb431c 100644
--- a/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js
+++ b/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js
@@ -126,6 +126,332 @@ function showErrInLoadStatus(jqXHR, exception) {
}
}
+function openAiPrompt(url) {
+ openTextCommandDialog({
+ dialogId: "aiPromptDialog",
+ statusId: "aiPromptStatus",
+ logId: "aiPromptProgressLog",
+ errorId: "aiPromptError",
+ title: "Generating AI prompt",
+ initialMode: true,
+ requestUrl: function (waitForTc) {
+ return aiPromptUrlWithWaitForTc(url, waitForTc);
+ },
+ timeoutMs: 70000,
+ skip: {
+ isVisible: function (waitForTc) {
+ return waitForTc;
+ },
+ nextMode: false,
+ buttonText: "Use current context now",
+ runningText: "Using current context...",
+ stepText: "Building prompt from current cached context."
+ },
+ statusText: function (waitForTc) {
+ return waitForTc ? "Generating prompt..." : "Generating prompt
from current context...";
+ },
+ progressMessage: aiPromptProgressMessage,
+ openButtonText: "Open prompt",
+ downloadButtonText: "Download .txt",
+ downloadFileName: "ai-prompt.txt",
+ readyStatusText: "AI prompt is ready.",
+ readyStepText: "Prompt text is ready. Use Open prompt or Download
.txt.",
+ failureStatusText: "AI prompt request failed.",
+ failureMessagePrefix: "AI prompt request failed: "
+ });
+}
+
+function openTextCommandDialog(options) {
+ let state = createTextCommandDialog(options);
+
+ requestTextCommand(options, state, options.initialMode);
+}
+
+function requestTextCommand(options, state, mode, firstStep) {
+ if (state.timer)
+ clearInterval(state.timer);
+
+ let skip = options.skip;
+ let skipVisible = skip != null && (skip.isVisible == null ||
skip.isVisible(mode));
+
+ state.skipBtn.toggle(skipVisible).prop("disabled", false)
+ .text(skip == null ? "" : skip.buttonText);
+ state.openBtn.hide();
+ state.downloadBtn.hide();
+ state.errorBlock.hide();
+
+ state.skipBtn.off("click");
+
+ if (skipVisible) {
+ state.skipBtn.on("click", function () {
+ if (state.xhr)
+ state.xhr.abort();
+
+ let nextMode = typeof skip.nextMode === "function" ?
skip.nextMode(mode) : skip.nextMode;
+
+ state.skipBtn.prop("disabled", true).text(skip.runningText);
+ requestTextCommand(options, state, nextMode, skip.stepText);
+ });
+ }
+
+ startTextCommandProgress(options, state, mode, firstStep);
+
+ state.xhr = $.ajax({
+ url: options.requestUrl(mode),
+ timeout: options.timeoutMs == null ? 70000 : options.timeoutMs,
+ success: function (result) {
+ finishTextCommandDialog(options, state, result);
+ },
+ error: function (jqXHR, status, error) {
+ if (status === "abort")
+ return;
+
+ failTextCommandDialog(options, state, jqXHR, status, error);
+ }
+ });
+}
+
+function createTextCommandDialog(options) {
+ let dialog = $("#" + options.dialogId);
+
+ if (dialog.length > 0)
+ dialog.remove();
+
+ dialog = $("<div>", {id: options.dialogId});
+
+ let status = $("<div>", {
+ id: options.statusId,
+ css: {
+ "font-weight": "600",
+ "margin-bottom": "12px"
+ }
+ });
+
+ let log = $("<div>", {
+ id: options.logId,
+ css: {
+ "background": "#f7f7f7",
+ "border": "1px solid #d8d8d8",
+ "border-radius": "4px",
+ "font-family": "monospace",
+ "line-height": "1.45",
+ "max-height": "260px",
+ "min-height": "145px",
+ "overflow-y": "auto",
+ "padding": "10px",
+ "white-space": "pre-wrap"
+ }
+ });
+
+ let errorBlock = $("<pre>", {
+ id: options.errorId,
+ css: {
+ "background": "#fff2f2",
+ "border": "1px solid #d09090",
+ "border-radius": "4px",
+ "display": "none",
+ "margin-top": "12px",
+ "max-height": "180px",
+ "overflow": "auto",
+ "padding": "10px",
+ "white-space": "pre-wrap"
+ }
+ });
+
+ let actions = $("<div>", {
+ css: {
+ "display": "flex",
+ "gap": "8px",
+ "justify-content": "flex-end",
+ "margin-top": "14px"
+ }
+ });
+
+ let skipBtn = $("<button>", {type: "button", text: options.skip == null ?
"" : options.skip.buttonText});
+ let openBtn = $("<button>", {type: "button", text: options.openButtonText
|| "Open"}).hide();
+ let downloadBtn = $("<button>", {type: "button", text:
options.downloadButtonText || "Download"}).hide();
+
+ actions.append(skipBtn, openBtn, downloadBtn);
+ dialog.append(status, log, errorBlock, actions);
+ $("body").append(dialog);
+
+ let state = {
+ dialog: dialog,
+ status: status,
+ log: log,
+ errorBlock: errorBlock,
+ skipBtn: skipBtn,
+ openBtn: openBtn,
+ downloadBtn: downloadBtn,
+ resultUrl: null,
+ timer: null,
+ xhr: null
+ };
+
+ dialog.dialog({
+ close: function () {
+ closeTextCommandDialog(state);
+ },
+ modal: true,
+ resizable: false,
+ title: options.title,
+ width: Math.min(options.width || 620, $(window).width() - 40)
+ });
+
+ openBtn.on("click", function () {
+ openTextCommandResult(state);
+ });
+
+ downloadBtn.on("click", function () {
+ downloadTextCommandResult(options, state);
+ });
+
+ return state;
+}
+
+function aiPromptUrlWithWaitForTc(url, waitForTc) {
+ return url + (url.indexOf("?") >= 0 ? "&" : "?") + "waitForTc=" +
waitForTc;
+}
+
+function startTextCommandProgress(options, state, mode, firstStep) {
+ let idx = 0;
+ let startedTs = Date.now();
+
+ state.status.text(options.statusText == null ? "Running command..." :
options.statusText(mode));
+ state.log.empty();
+
+ if (firstStep)
+ appendTextCommandStep(state, firstStep);
+
+ function showNextStatus() {
+ let message = options.progressMessage == null
+ ? defaultTextCommandProgressMessage(mode, idx, Date.now() -
startedTs)
+ : options.progressMessage(mode, idx, Date.now() - startedTs);
+
+ appendTextCommandStep(state, message);
+
+ idx++;
+ }
+
+ showNextStatus();
+
+ state.timer = setInterval(showNextStatus, options.progressIntervalMs ||
5000);
+}
+
+function defaultTextCommandProgressMessage(mode, idx, elapsedMs) {
+ let elapsedSec = Math.round(elapsedMs / 1000);
+
+ if (idx === 0)
+ return "Sending request to the bot server.";
+
+ return "No response yet after " + elapsedSec + "s. Command is still
running.";
+}
+
+function aiPromptProgressMessage(waitForTc, idx, elapsedMs) {
+ let elapsedSec = Math.round(elapsedMs / 1000);
+
+ if (!waitForTc) {
+ if (idx === 0)
+ return "Sending no-wait request to the bot server.";
+
+ if (idx === 1)
+ return "Using cached chain context and cached log analysis only.";
+
+ return "No prompt response yet after " + elapsedSec
+ + "s. Bot is still building from cache; no new TeamCity/log wait
was requested.";
+ }
+
+ if (idx === 0)
+ return "Sending request to the bot server.";
+
+ if (idx === 1)
+ return "Bot is loading the TeamCity build list and dependency chain.";
+
+ if (idx === 2)
+ return "No prompt response yet after " + elapsedSec
+ + "s. Bot may still be downloading build/test metadata from
TeamCity.";
+
+ if (idx === 3)
+ return "No prompt response yet after " + elapsedSec
+ + "s. Bot may be loading build logs for failed or incomplete
suites.";
+
+ if (idx === 4)
+ return "No prompt response yet after " + elapsedSec
+ + "s. Bot may be parsing build logs and attaching cached log
analysis to the prompt.";
+
+ if (elapsedSec < 65)
+ return "No prompt response yet after " + elapsedSec
+ + "s. Fresh context/log wait timeout is 60s; you can use current
cached context now.";
+
+ return "No prompt response yet after " + elapsedSec
+ + "s. TeamCity loading or build-log processing is taking longer than
expected; you can use current context now.";
+}
+
+function appendTextCommandStep(state, text) {
+ let line = $("<div>").text("> " + text);
+ state.log.append(line);
+ state.log.scrollTop(state.log[0].scrollHeight);
+}
+
+function finishTextCommandDialog(options, state, result) {
+ if (state.timer)
+ clearInterval(state.timer);
+
+ if (state.resultUrl)
+ URL.revokeObjectURL(state.resultUrl);
+
+ state.resultUrl = URL.createObjectURL(new Blob([result], {
+ type: options.resultMimeType || "text/plain;charset=utf-8"
+ }));
+ state.status.text(options.readyStatusText || "Command result is ready.");
+ appendTextCommandStep(state, options.readyStepText || "Command result is
ready.");
+ state.skipBtn.hide();
+ state.openBtn.toggle(options.showOpenButton !== false);
+ state.downloadBtn.toggle(options.showDownloadButton !== false);
+}
+
+function failTextCommandDialog(options, state, jqXHR, status, error) {
+ if (state.timer)
+ clearInterval(state.timer);
+
+ state.status.text(options.failureStatusText || "Command request failed.");
+ state.skipBtn.hide();
+ state.errorBlock.text((options.failureMessagePrefix || "Command request
failed: ")
+ + status + "\n\n" + jqXHR.responseText).show();
+ appendTextCommandStep(state, "Request failed: " + (error || status));
+ showErrInLoadStatus(jqXHR, status);
+}
+
+function openTextCommandResult(state) {
+ if (!state.resultUrl)
+ return false;
+
+ return window.open(state.resultUrl, "_blank") != null;
+}
+
+function downloadTextCommandResult(options, state) {
+ if (!state.resultUrl)
+ return;
+
+ let link = document.createElement("a");
+ link.href = state.resultUrl;
+ link.download = options.downloadFileName || "command-result.txt";
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+}
+
+function closeTextCommandDialog(state) {
+ if (state.timer)
+ clearInterval(state.timer);
+
+ if (state.xhr && state.xhr.readyState !== 4)
+ state.xhr.abort();
+
+ if (state.resultUrl)
+ URL.revokeObjectURL(state.resultUrl);
+}
+
//requires element on page: <div id="version"></div>
function showVersionInfo(result) {
diff --git a/ignite-tc-helper-web/src/main/webapp/pr.html
b/ignite-tc-helper-web/src/main/webapp/pr.html
index ca6f8f58..2c6d2945 100644
--- a/ignite-tc-helper-web/src/main/webapp/pr.html
+++ b/ignite-tc-helper-web/src/main/webapp/pr.html
@@ -144,37 +144,6 @@ function openAiPromptForSuite(suiteId) {
openAiPrompt(aiPromptUrlForSuite(suiteId));
}
-function openAiPrompt(url) {
- let promptWindow = window.open("about:blank", "_blank");
- if (promptWindow) {
- promptWindow.document.title = "AI prompt";
- promptWindow.document.body.innerHTML = "<pre>Loading AI
prompt...</pre>";
- }
-
- $.ajax({
- url: url,
- timeout: 300000,
- success: function (result) {
- writeAiPromptWindow(promptWindow, result);
- },
- error: function (xhr, status, error) {
- showErrInLoadStatus(xhr, status, error);
- writeAiPromptWindow(promptWindow, "AI prompt request failed: " +
status + "\n\n" + xhr.responseText);
- }
- });
-}
-
-function writeAiPromptWindow(promptWindow, text) {
- if (!promptWindow)
- return;
-
- promptWindow.document.body.innerHTML = "";
-
- let pre = promptWindow.document.createElement("pre");
- pre.textContent = text;
- promptWindow.document.body.appendChild(pre);
-}
-
function checkForUpdate() {
var curFailuresUrl = "rest/pr/updates" + parmsForRest();
diff --git
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/tcbot/chain/PrChainsProcessorTest.java
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/tcbot/chain/PrChainsProcessorTest.java
index a832185e..b4fcd4fe 100644
---
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/tcbot/chain/PrChainsProcessorTest.java
+++
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/tcbot/chain/PrChainsProcessorTest.java
@@ -33,6 +33,7 @@ import org.apache.ignite.tcbot.persistence.IStringCompactor;
import org.apache.ignite.tcignited.ITeamcityIgnitedProvider;
import org.apache.ignite.tcignited.SyncMode;
import org.apache.ignite.tcignited.build.TestCompactedV2;
+import org.apache.ignite.tcignited.buildlog.IBuildLogProcessor;
import org.apache.ignite.tcservice.ITeamcity;
import org.apache.ignite.tcservice.model.conf.BuildType;
import org.apache.ignite.tcservice.model.hist.BuildRef;
@@ -54,7 +55,13 @@ import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import static org.junit.Assert.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
/**
* Unit test for {@link PrChainsProcessor} and blockers detection. Emulates
builds using Mockito. Does not start an
@@ -185,6 +192,27 @@ public class PrChainsProcessorTest {
return suiteOpt.flatMap(suite ->
suite.testFailures().stream().filter(tf -> name.equals(tf.name)).findAny());
}
+ @Test
+ public void aiPromptNoWaitUsesCachedLogAnalysisOnly() {
+ IStringCompactor c = injector.getInstance(IStringCompactor.class);
+ IBuildLogProcessor logProcessor =
injector.getInstance(IBuildLogProcessor.class);
+
+ final String btId = "RunAll";
+ final String branch = "ignite-ai-prompt";
+
+ initBuildChain(c, btId, branch);
+
+ PrChainsProcessor prcp = injector.getInstance(PrChainsProcessor.class);
+
+ String prompt = prcp.getPrFailuresAiPrompt(mock(ITcBotUserCreds.class),
+ SRV_ID, btId, branch, null, 1, null, 1024, null, null, false);
+
+ assertTrue(prompt.contains("AI Prompt"));
+
+ verify(logProcessor, atLeastOnce()).getCachedBuildLogAnalysis(any(),
anyInt());
+ verify(logProcessor, never()).analyzeBuildLog(any(), anyInt(),
anyBoolean());
+ }
+
/**
* @param name Test failure Name to find.
*/
diff --git
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/CommonScriptTest.java
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/CommonScriptTest.java
new file mode 100644
index 00000000..7c878d9c
--- /dev/null
+++
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/CommonScriptTest.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.ci.web;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.Test;
+
+import static org.junit.Assert.assertTrue;
+
+public class CommonScriptTest {
+ @Test
+ public void aiPromptSkipButtonRequestsCurrentContext() throws IOException {
+ String js = readFile(commonJs());
+
+ assertTrue(js.contains("buttonText: \"Use current context now\""));
+ assertTrue(js.contains("nextMode: false"));
+ assertTrue(js.contains("requestTextCommand(options, state, nextMode,
skip.stepText)"));
+ assertTrue(js.contains("return aiPromptUrlWithWaitForTc(url,
waitForTc)"));
+ assertTrue(js.contains("\"waitForTc=\" + waitForTc"));
+ }
+
+ private static Path commonJs() {
+ Path projectPath = Paths.get("src/main/webapp/js/common-1.7.js");
+
+ if (Files.exists(projectPath))
+ return projectPath;
+
+ return
Paths.get("ignite-tc-helper-web/src/main/webapp/js/common-1.7.js");
+ }
+
+ private static String readFile(Path path) throws IOException {
+ return new String(Files.readAllBytes(path), StandardCharsets.UTF_8)
+ .replace("\r\n", "\n")
+ .replace('\r', '\n');
+ }
+}
diff --git
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/SingleBuildResultsService.java
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/SingleBuildResultsService.java
index eab55dc0..17b455a0 100644
---
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/SingleBuildResultsService.java
+++
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/SingleBuildResultsService.java
@@ -47,6 +47,9 @@ import org.apache.ignite.tcservice.ITeamcity;
* Displays single build at server by ID.
*/
public class SingleBuildResultsService {
+ /** Max time to wait for fresh AI prompt build context. */
+ private static final long AI_PROMPT_CONTEXT_WAIT_MS =
TimeUnit.MINUTES.toMillis(1);
+
@Inject BuildChainProcessor buildChainProcessor;
@Inject ITeamcityIgnitedProvider tcIgnitedProv;
@Inject BranchEquivalence branchEquivalence;
@@ -154,18 +157,19 @@ public class SingleBuildResultsService {
Future<FullChainRunCtx> live = null;
try {
- aiPromptMonitor.stage(reqId, "trying fresh context for up to 1s");
+ aiPromptMonitor.stage(reqId, "loading fresh build context from
TeamCity for up to "
+ + TimeUnit.MILLISECONDS.toSeconds(AI_PROMPT_CONTEXT_WAIT_MS) +
"s");
live = tcUpdatePool.getService().submit(() ->
- loadSingleBuildContext(srvCodeOrAlias, buildId, null,
liveSyncMode, prov, ProcessLogsMode.CACHED_ONLY));
+ loadSingleBuildContext(srvCodeOrAlias, buildId, null,
liveSyncMode, prov, ProcessLogsMode.ALL));
- return live.get(1, TimeUnit.SECONDS);
+ return live.get(AI_PROMPT_CONTEXT_WAIT_MS, TimeUnit.MILLISECONDS);
}
catch (TimeoutException e) {
if (live != null)
live.cancel(true);
- aiPromptMonitor.stage(reqId, "fresh context timed out, using stale
cache");
+ aiPromptMonitor.stage(reqId, "fresh TeamCity reload timed out,
loading best-effort cached context");
}
catch (InterruptedException e) {
if (live != null)
@@ -178,7 +182,8 @@ public class SingleBuildResultsService {
throw new IllegalStateException("Interrupted while loading fresh
TeamCity context", e);
}
catch (Exception e) {
- aiPromptMonitor.stage(reqId, "fresh context failed, using stale
cache: " + e.getMessage());
+ aiPromptMonitor.stage(reqId, "fresh TeamCity reload failed,
loading best-effort cached context: "
+ + e.getMessage());
}
return loadSingleBuildContext(srvCodeOrAlias, buildId, null,
SyncMode.NONE, prov, ProcessLogsMode.CACHED_ONLY);
diff --git
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilder.java
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilder.java
index f262ca7e..d5eebebf 100644
---
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilder.java
+++
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilder.java
@@ -18,12 +18,17 @@
package org.apache.ignite.tcbot.engine.build;
import com.google.common.base.Strings;
-import java.util.Comparator;
import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@@ -52,9 +57,64 @@ public class TestFailuresAiPromptBuilder {
/** Default limit for a single TeamCity failure details block. */
public static final int DFLT_MAX_DETAILS_CHARS = 40000;
+ /** Max cached log scanner chars to include in one log context block. */
+ private static final int LOG_CONTEXT_CHAR_BUDGET = 12000;
+
+ /** Max cached log scanner tail chars to include when all messages are too
large. */
+ private static final int LOG_CONTEXT_TAIL_CHAR_BUDGET = 5000;
+
+ /** Max suggested local search commands. */
+ private static final int SUGGESTED_SEARCHES_LIMIT = 5;
+
+ /** Max lines to keep from TeamCity stdout/stderr details sections. */
+ private static final int DETAILS_SECTION_TAIL_LINES = 25;
+
+ /** Max chars to keep from TeamCity stdout/stderr details sections. */
+ private static final int DETAILS_SECTION_TAIL_CHARS = 6000;
+
+ /** Throwable class in a log line, with an optional message. */
+ private static final Pattern THROWABLE_SIGNAL = Pattern.compile(
+
"\\b((?:[\\w$]+\\.)*[\\w$]*(?:Exception|Error))\\b(?:\\s*:\\s*([^\\r\\n]+))?");
+
+ /** Inline stack frame tail after a compacted exception message. */
+ private static final Pattern INLINE_STACK_TAIL =
Pattern.compile("\\s+at\\s+[\\w.$]+\\([^)]*\\).*$");
+
+ /** Java source location in stack trace. */
+ private static final Pattern JAVA_SOURCE_LOCATION =
Pattern.compile("\\(([^():]+\\.java):(\\d+)\\)");
+
+ /** Java stack frame. */
+ private static final Pattern STACK_FRAME = Pattern.compile(
+ "^\\s*at\\s+((?:[\\w$]+\\.)+)([\\w$]+)\\(([^():]+\\.java):(\\d+)\\)");
+
/** String compactor. */
private final IStringCompactor compactor;
+ /** Prompt section skeleton, ordered by diagnostic value rather than data
source shape. */
+ private enum Section {
+ FAST_SUMMARY("Fast Summary"),
+ FAILURE_SIGNAL("Failure Signal"),
+ SUGGESTED_LOCAL_SEARCHES("Suggested Local Searches"),
+ HISTORY("History"),
+ LOG_CONTEXT("Log Context"),
+ SUITE_CONTEXT("Suite / Build Context"),
+ TEST_CONTEXT("Test Context"),
+ CHANGE_CONTEXT("Changed Files / PR Context"),
+ CHAIN_CONTEXT("Chain Context"),
+ INCLUDED_SCOPE("Included Scope"),
+ INVESTIGATION_INSTRUCTIONS("Investigation Instructions"),
+ REQUIRED_FINAL_ANSWER("Required Final Answer");
+
+ /** Section title. */
+ private final String title;
+
+ /**
+ * @param title Section title.
+ */
+ Section(String title) {
+ this.title = title;
+ }
+ }
+
/**
* @param compactor String compactor.
*/
@@ -112,7 +172,8 @@ public class TestFailuresAiPromptBuilder {
String normalizedBaseBranch = normalizeBranch(baseBranchTc);
Integer baseBranchId =
compactor.getStringIdIfPresent(normalizedBaseBranch);
- appendHeader(res, tcIgnited, ctx, normalizedBaseBranch);
+ appendHeader(res);
+ appendChainContext(res, tcIgnited, ctx, normalizedBaseBranch);
AtomicInteger suiteCnt = new AtomicInteger();
AtomicInteger testCnt = new AtomicInteger();
@@ -138,7 +199,7 @@ public class TestFailuresAiPromptBuilder {
.collect(Collectors.toList());
if (failedTests.isEmpty())
- appendSuiteFailure(res, suite);
+ appendSuiteFailure(res, tcIgnited, suite, baseBranchId);
else {
for (TestCompactedMult test : failedTests) {
testCnt.incrementAndGet();
@@ -147,7 +208,7 @@ public class TestFailuresAiPromptBuilder {
}
});
- res.append("## Included Scope\n");
+ appendSection(res, Section.INCLUDED_SCOPE);
res.append("Failed suites included:
").append(suiteCnt.get()).append('\n');
res.append("Failed tests included:
").append(testCnt.get()).append('\n');
@@ -156,17 +217,33 @@ public class TestFailuresAiPromptBuilder {
/**
* @param res Result builder.
- * @param tcIgnited TeamCity facade.
- * @param ctx Full chain context.
- * @param normalizedBaseBranch Normalized base branch.
*/
- private void appendHeader(StringBuilder res, ITeamcityIgnited tcIgnited,
FullChainRunCtx ctx,
- @Nullable String normalizedBaseBranch) {
+ private void appendHeader(StringBuilder res) {
res.append("# AI Prompt: Investigate TeamCity Failure\n\n");
res.append("You are in the local checkout of the project that produced
this TeamCity failure. ");
res.append("Use this compact investigation brief to find the likely
root cause with minimal assumptions.\n\n");
+ }
+
+ /**
+ * @param res Result builder.
+ * @param section Section.
+ */
+ private void appendSection(StringBuilder res, Section section) {
+ if (res.length() > 0 && res.charAt(res.length() - 1) != '\n')
+ res.append('\n');
+
+ res.append("## ").append(section.title).append('\n');
+ }
- res.append("## Run Context\n");
+ /**
+ * @param res Result builder.
+ * @param tcIgnited TeamCity facade.
+ * @param ctx Full chain context.
+ * @param normalizedBaseBranch Normalized base branch.
+ */
+ private void appendChainContext(StringBuilder res, ITeamcityIgnited
tcIgnited, FullChainRunCtx ctx,
+ @Nullable String normalizedBaseBranch) {
+ appendSection(res, Section.CHAIN_CONTEXT);
appendLine(res, "TeamCity server", tcIgnited.serverCode());
appendLine(res, "Chain", ctx.suiteName());
appendLine(res, "Suite id", ctx.suiteId());
@@ -176,7 +253,6 @@ public class TestFailuresAiPromptBuilder {
appendLine(res, "Entry build", tcIgnited.host() +
"viewLog.html?buildId=" + ctx.getSuiteBuildId());
appendLine(res, "Duration", ctx.getDurationPrintable(suite -> true));
res.append('\n');
-
}
/**
@@ -189,7 +265,7 @@ public class TestFailuresAiPromptBuilder {
@Nullable Integer baseBranchId) {
IRunHistory baseHist = suite.history(tcIgnited, baseBranchId, null);
- res.append("## Run Context\n");
+ appendSection(res, Section.SUITE_CONTEXT);
res.append("Suite:
").append(nullToUnknown(suite.suiteName())).append('\n');
appendLine(res, "Suite id", suite.suiteId());
appendLine(res, "Build id", String.valueOf(suite.getBuildId()));
@@ -238,22 +314,36 @@ public class TestFailuresAiPromptBuilder {
}
}
+ /**
+ * @param res Result builder.
+ * @param compareTarget What to compare changed packages against.
+ */
+ private void appendChangeContext(StringBuilder res, String compareTarget) {
+ appendSection(res, Section.CHANGE_CONTEXT);
+ res.append("Changed files and patches are not available in this cached
TeamCity bot context. ");
+ res.append("Inspect the local checkout diff/PR metadata if needed,
then compare changed packages with the ");
+ res.append(compareTarget).append(".\n");
+ }
+
/**
* @param res Result builder.
* @param suite Suite context.
*/
private void appendLogChecks(StringBuilder res, MultBuildRunCtx suite) {
- List<Map.Entry<String, ITestLogCheckResult>> warnings =
suite.getLogsCheckResults()
+ List<Map.Entry<String, ITestLogCheckResult>> messages =
suite.getLogsCheckResults()
.flatMap(map -> map.entrySet().stream())
.filter(entry -> entry.getValue() != null &&
entry.getValue().hasWarns())
.collect(Collectors.toList());
- if (warnings.isEmpty())
+ if (messages.isEmpty()) {
+ appendLogAnalysisNote(res, suite);
+
return;
+ }
- res.append("Build log scanner warnings:\n");
+ res.append("Build log scanner messages:\n");
- for (Map.Entry<String, ITestLogCheckResult> entry : warnings) {
+ for (Map.Entry<String, ITestLogCheckResult> entry : messages) {
ITestLogCheckResult checkRes = entry.getValue();
res.append("- ").append(nullToUnknown(entry.getKey()));
@@ -276,8 +366,25 @@ public class TestFailuresAiPromptBuilder {
TestCompactedMult test, @Nullable Integer baseBranchId, int
maxDetailsChars) {
String fullName = test.getName();
IRunHistory testHist = test.history(tcIgnited, baseBranchId);
+ List<ITest> failedInvocations = test.getInvocationsStream()
+ .filter(Objects::nonNull)
+ .filter(invocation -> invocation.isFailedButNotMuted(compactor))
+ .collect(Collectors.toList());
+ String primaryDetails = failedInvocations.stream()
+ .map(ITest::getDetailsText)
+ .filter(details -> !Strings.isNullOrEmpty(details))
+ .map(this::cleanDetails)
+ .findFirst()
+ .orElse(null);
+ Integer primaryDuration = failedInvocations.stream()
+ .map(ITest::getDuration)
+ .filter(Objects::nonNull)
+ .findFirst()
+ .orElse(null);
- res.append("## Failure Signal\n");
+ appendFastSummary(res, suite, fullName, test.failuresCount(),
primaryDetails, primaryDuration);
+
+ appendSection(res, Section.FAILURE_SIGNAL);
appendLine(res, "Full test name", fullName);
appendLine(res, "Suite before colon", testSuitePart(fullName));
appendLine(res, "Test after colon", testCasePart(fullName));
@@ -285,19 +392,23 @@ public class TestFailuresAiPromptBuilder {
AtomicInteger invocationIdx = new AtomicInteger();
- test.getInvocationsStream()
- .filter(Objects::nonNull)
- .filter(invocation -> invocation.isFailedButNotMuted(compactor))
- .forEach(invocation -> appendInvocation(res, tcIgnited, suite,
invocation, fullName,
+ failedInvocations.forEach(invocation -> appendInvocation(res,
tcIgnited, suite, invocation, fullName,
invocationIdx.incrementAndGet(), maxDetailsChars));
- appendRelevantLogChecks(res, suite, fullName);
+ appendSuggestedLocalSearches(res, suite, fullName, failedInvocations);
- res.append("\n## Changed Files / PR Context\n");
- res.append("Changed files and patches are not available in this cached
TeamCity bot context. ");
- res.append("Inspect the local checkout diff/PR metadata if needed,
then compare changed packages with the failing test/module.\n");
+ if (testHist != null) {
+ appendSection(res, Section.HISTORY);
+ appendLine(res, "Base branch test failure rate",
testHist.getFailPercentPrintable() + "%");
+ appendLine(res, "Base branch flaky",
String.valueOf(testHist.isFlaky()));
+ appendLine(res, "Base branch latest runs",
String.valueOf(testHist.getLatestRunResults()));
+ appendLine(res, "Break boundary", breakBoundary(testHist));
+ appendLine(res, "Recent execution history",
recentHistory(testHist, 12));
+ }
- res.append("\n## Test Context\n");
+ appendRelevantLogChecks(res, suite, fullName);
+
+ appendSection(res, Section.TEST_CONTEXT);
appendLine(res, "Full test name", fullName);
appendLine(res, "Suite before colon", testSuitePart(fullName));
appendLine(res, "Test after colon", testCasePart(fullName));
@@ -307,44 +418,40 @@ public class TestFailuresAiPromptBuilder {
appendLine(res, "Investigated in TeamCity",
String.valueOf(test.isInvestigated()));
appendLine(res, "Possible blocker note",
test.getPossibleBlockerComment(testHist));
- if (testHist != null) {
- res.append("\n## History\n");
- appendLine(res, "Base branch test failure rate",
testHist.getFailPercentPrintable() + "%");
- appendLine(res, "Base branch flaky",
String.valueOf(testHist.isFlaky()));
- appendLine(res, "Base branch latest runs",
String.valueOf(testHist.getLatestRunResults()));
- appendLine(res, "Break boundary", breakBoundary(testHist));
- appendLine(res, "Recent execution history",
recentHistory(testHist, 12));
- }
+ appendChangeContext(res, "failing test/module");
- appendInvestigationInstructions(res);
+ appendInvestigationInstructions(res,
masterChangeCauseInstruction(suite.branchName(), testHist));
res.append('\n');
}
/**
* @param res Result builder.
+ * @param tcIgnited TeamCity facade.
* @param suite Suite context.
+ * @param baseBranchId Base branch compacted id.
*/
- private void appendSuiteFailure(StringBuilder res, MultBuildRunCtx suite) {
- res.append("## Failure Signal\n");
+ private void appendSuiteFailure(StringBuilder res, ITeamcityIgnited
tcIgnited, MultBuildRunCtx suite,
+ @Nullable Integer baseBranchId) {
+ appendSection(res, Section.FAILURE_SIGNAL);
res.append("No failed non-muted test was reported for this suite.
Investigate this as a suite/build-level failure.\n");
appendProblems(res, suite);
appendEmptyDetailsDiagnosis(res, suite);
- res.append("\n## Log Context\n");
- appendLogChecks(res, suite);
+ appendSuggestedLocalSearches(res, suite, suite.suiteName(),
Collections.emptyList());
- res.append("\n## Changed Files / PR Context\n");
- res.append("Changed files and patches are not available in this cached
TeamCity bot context. ");
- res.append("Inspect the local checkout diff/PR metadata if needed,
then compare changed packages with the failed suite/module.\n");
+ appendSection(res, Section.LOG_CONTEXT);
+ appendLogChecks(res, suite);
- res.append("\n## Test Context\n");
+ appendSection(res, Section.TEST_CONTEXT);
appendLine(res, "Suite id", suite.suiteId());
appendLine(res, "Suite name", suite.suiteName());
appendLine(res, "Failed tests reported",
String.valueOf(suite.failedTests()));
appendLine(res, "Total tests", String.valueOf(suite.totalTests()));
- appendInvestigationInstructions(res);
+ appendChangeContext(res, "failed suite/module");
+
+ appendInvestigationInstructions(res, null);
res.append('\n');
}
@@ -388,11 +495,155 @@ public class TestFailuresAiPromptBuilder {
res.append(limit(firstFailureSignal(cleanedDetails), 6000));
res.append("\n```\n");
- res.append("Failure details text from TeamCity:\n");
- res.append("```text\n");
- res.append(limit(cleanedDetails, maxDetailsChars));
- res.append("\n```\n");
+ String summary = relevantStdoutStderrSummary(cleanedDetails,
duration);
+
+ if (!Strings.isNullOrEmpty(summary)) {
+ res.append("Relevant stdout/stderr:\n");
+ res.append(summary);
+ }
+
+ String detailsTails = detailsTailsForPrompt(cleanedDetails);
+
+ if (!Strings.isNullOrEmpty(detailsTails)) {
+ res.append("<details>\n");
+ res.append("<summary>Raw TeamCity stdout/stderr tail
(secondary)</summary>\n\n");
+ res.append("```text\n");
+ res.append(limit(detailsTails, Math.min(maxDetailsChars,
DETAILS_SECTION_TAIL_CHARS)));
+ res.append("\n```\n");
+ res.append("</details>\n");
+ }
+ }
+ }
+
+ /**
+ * @param res Result builder.
+ * @param suite Suite context.
+ * @param fullName Full TeamCity test name.
+ * @param failuresCnt Failures in loaded context.
+ * @param details Cleaned primary invocation details.
+ * @param duration Primary invocation duration.
+ */
+ private void appendFastSummary(StringBuilder res, MultBuildRunCtx suite,
@Nullable String fullName, int failuresCnt,
+ @Nullable String details, @Nullable Integer duration) {
+ appendSection(res, Section.FAST_SUMMARY);
+ res.append(fastSummary(suite.suiteName(), suite.branchName(),
fullName, failuresCnt, details, duration));
+ res.append('\n');
+ }
+
+ /**
+ * @param suiteName Suite name.
+ * @param branch Branch.
+ * @param fullName Full TeamCity test name.
+ * @param failuresCnt Failures in loaded context.
+ * @param details Cleaned primary invocation details.
+ * @param duration Primary invocation duration.
+ * @return Compact diagnosis-first summary.
+ */
+ String fastSummary(@Nullable String suiteName, @Nullable String branch,
@Nullable String fullName, int failuresCnt,
+ @Nullable String details, @Nullable Integer duration) {
+ StringBuilder res = new StringBuilder();
+ ThrowableSignal throwable = firstThrowableSignal(details);
+ StackFrame nearestFrame = nearestProjectFrame(details,
simpleClassName(testCasePart(fullName)));
+
+ res.append(failuresCnt <= 1 ? "Single test failure" : failuresCnt + "
test failures");
+ res.append(" in ").append(nullToUnknown(suiteName));
+ res.append(" on ").append(nullToUnknown(branch)).append(".\n");
+
+ if (duration != null)
+ res.append("Failure happens quickly, within
~").append(duration).append(" ms.\n");
+
+ if (throwable != null) {
+ res.append("Exception: ").append(throwable.cls);
+
+ if (!Strings.isNullOrEmpty(throwable.msg))
+ res.append(": ").append(throwable.msg);
+
+ res.append(".\n");
+ }
+
+ if (nearestFrame != null)
+ res.append("Nearest project frame:
").append(nearestFrame.shortLocation()).append(".\n");
+
+ String likelyArea = likelyArea(details, nearestFrame);
+
+ if (!Strings.isNullOrEmpty(likelyArea))
+ res.append("Likely area: ").append(likelyArea).append(".\n");
+
+ return res.toString();
+ }
+
+ /**
+ * @param details Cleaned TeamCity details.
+ * @return First throwable signal.
+ */
+ @Nullable private ThrowableSignal firstThrowableSignal(@Nullable String
details) {
+ if (Strings.isNullOrEmpty(details))
+ return null;
+
+ Matcher matcher = THROWABLE_SIGNAL.matcher(details);
+
+ if (!matcher.find())
+ return null;
+
+ return new ThrowableSignal(simpleThrowableName(matcher.group(1)),
normalizeSearchMessage(matcher.group(2)));
+ }
+
+ /**
+ * @param details Cleaned TeamCity details.
+ * @param preferredClass Preferred test class.
+ * @return Nearest project stack frame.
+ */
+ @Nullable private StackFrame nearestProjectFrame(@Nullable String details,
@Nullable String preferredClass) {
+ if (Strings.isNullOrEmpty(details))
+ return null;
+
+ StackFrame firstIgniteFrame = null;
+
+ for (String line : details.split("\\r?\\n")) {
+ Matcher matcher = STACK_FRAME.matcher(line);
+
+ if (!matcher.find())
+ continue;
+
+ StackFrame frame = new StackFrame(matcher.group(1),
matcher.group(2), matcher.group(3), matcher.group(4));
+
+ if (!Strings.isNullOrEmpty(preferredClass) &&
frame.owner.contains("." + preferredClass + "."))
+ return frame;
+
+ if (firstIgniteFrame == null &&
frame.owner.startsWith("org.apache.ignite."))
+ firstIgniteFrame = frame;
}
+
+ return firstIgniteFrame;
+ }
+
+ /**
+ * @param details Cleaned TeamCity details.
+ * @param nearestFrame Nearest project frame.
+ * @return Likely area summary.
+ */
+ @Nullable private String likelyArea(@Nullable String details, @Nullable
StackFrame nearestFrame) {
+ if (Strings.isNullOrEmpty(details) && nearestFrame == null)
+ return null;
+
+ String lower = Strings.nullToEmpty(details).toLowerCase();
+ List<String> terms = new ArrayList<>();
+
+ if (lower.contains("affinity"))
+ terms.add("affinity");
+ if (lower.contains("topology"))
+ terms.add("topology");
+ if (lower.contains("exchange"))
+ terms.add("exchange");
+ if (lower.contains("partition"))
+ terms.add("partition");
+ if (lower.contains("cache"))
+ terms.add("cache");
+
+ if (nearestFrame != null &&
!Strings.isNullOrEmpty(nearestFrame.method))
+ terms.add("near " + nearestFrame.method);
+
+ return terms.isEmpty() ? null :
terms.stream().distinct().collect(Collectors.joining("/"));
}
/**
@@ -401,11 +652,11 @@ public class TestFailuresAiPromptBuilder {
* @param fullName Full test name.
*/
private void appendRelevantLogChecks(StringBuilder res, MultBuildRunCtx
suite, @Nullable String fullName) {
- List<String> snippets = new ArrayList<>();
+ List<List<String>> relevantBlocks = new ArrayList<>();
+ List<List<String>> otherBlocks = new ArrayList<>();
suite.getLogsCheckResults()
.flatMap(map -> map.entrySet().stream())
- .filter(entry -> isSameTestLog(entry.getKey(), fullName))
.filter(entry -> entry.getValue() != null)
.forEach(entry -> {
ITestLogCheckResult checkRes = entry.getValue();
@@ -413,30 +664,431 @@ public class TestFailuresAiPromptBuilder {
if (!checkRes.hasWarns())
return;
- snippets.add("Log grep for " + entry.getKey() + " (log size "
- + checkRes.getLogSizeBytes() + " bytes):");
+ List<String> block = logCheckBlock(entry);
- snippets.addAll(checkRes.getWarns());
+ if (isSameTestLog(entry.getKey(), fullName))
+ relevantBlocks.add(block);
+ else
+ otherBlocks.add(block);
});
- if (snippets.isEmpty()) {
- res.append("\n## Log Context\n");
- res.append("Cached build log analysis has no warning snippets for
this test. ");
- res.append("Raw 200-500 line windows around test start/end are not
available in the current bot cache.\n");
+ if (relevantBlocks.isEmpty() && otherBlocks.isEmpty()) {
+ appendSection(res, Section.LOG_CONTEXT);
+ appendLogAnalysisNote(res, suite);
+
+ return;
+ }
+
+ List<List<String>> allBlocks = new ArrayList<>(relevantBlocks);
+ allBlocks.addAll(otherBlocks);
+
+ appendSection(res, Section.LOG_CONTEXT);
+ if (logBlocksChars(allBlocks) <= LOG_CONTEXT_CHAR_BUDGET) {
+ res.append("Cached build-log scanner messages. Full cached message
set is included because it is small enough:\n");
+ appendLogBlocks(res, allBlocks);
return;
}
- res.append("\n## Log Context\n");
- res.append("Cached log grep from processed build log:\n");
+ if (!relevantBlocks.isEmpty()) {
+ res.append("Cached log grep for the failing test:\n");
+ appendLogBlocks(res, relevantBlocks);
+
+ if (!otherBlocks.isEmpty()) {
+ res.append("\nAdditional cached build-log scanner messages
near the end of the processed log context");
+ res.append(" (truncated to keep the prompt compact):\n");
+ appendLogBlocks(res, tailLogBlocks(otherBlocks));
+ }
+
+ return;
+ }
+
+ res.append("Cached build-log scanner messages are too large to include
fully. ");
+ res.append("Showing messages near the end of the processed log
context:\n");
+ appendLogBlocks(res, tailLogBlocks(allBlocks));
+ }
+
+ /**
+ * @param res Result builder.
+ * @param blocks Log check blocks.
+ */
+ private void appendLogBlocks(StringBuilder res, List<List<String>> blocks)
{
res.append("```text\n");
- for (String snippet : snippets)
- res.append(snippet).append('\n');
+ for (List<String> block : blocks) {
+ for (String line : block)
+ res.append(line).append('\n');
+
+ res.append('\n');
+ }
res.append("```\n");
}
+ /**
+ * @param entry Log check entry.
+ * @return Prompt lines for the entry.
+ */
+ private List<String> logCheckBlock(Map.Entry<String, ITestLogCheckResult>
entry) {
+ ITestLogCheckResult checkRes = entry.getValue();
+ List<String> block = new ArrayList<>();
+
+ block.add("Log grep for " + entry.getKey() + " (log size " +
checkRes.getLogSizeBytes() + " bytes):");
+ block.addAll(checkRes.getWarns());
+
+ return block;
+ }
+
+ /**
+ * @param blocks Log check blocks.
+ * @return Approximate char count.
+ */
+ private int logBlocksChars(List<List<String>> blocks) {
+ return blocks.stream()
+ .flatMap(List::stream)
+ .mapToInt(line -> line.length() + 1)
+ .sum();
+ }
+
+ /**
+ * @param blocks Log check blocks.
+ * @return Tail blocks fitting the tail budget.
+ */
+ private List<List<String>> tailLogBlocks(List<List<String>> blocks) {
+ List<List<String>> res = new ArrayList<>();
+ int chars = 0;
+
+ for (int i = blocks.size() - 1; i >= 0; i--) {
+ List<String> block = blocks.get(i);
+ int blockChars = logBlocksChars(Collections.singletonList(block));
+
+ if (!res.isEmpty() && chars + blockChars >
LOG_CONTEXT_TAIL_CHAR_BUDGET)
+ break;
+
+ res.add(0, block);
+ chars += blockChars;
+ }
+
+ return res;
+ }
+
+ /**
+ * @param res Result builder.
+ * @param suite Suite context.
+ * @param fullName Full TeamCity test name.
+ */
+ private void appendSuggestedLocalSearches(StringBuilder res,
MultBuildRunCtx suite, @Nullable String fullName,
+ List<ITest> failedInvocations) {
+ Set<String> commands = new LinkedHashSet<>();
+ String testCase = testCasePart(fullName);
+ String className = simpleClassName(testCase);
+ String methodName = methodName(testCase);
+ List<String> details = failedInvocations.stream()
+ .map(ITest::getDetailsText)
+ .filter(detail -> !Strings.isNullOrEmpty(detail))
+ .map(this::cleanDetails)
+ .collect(Collectors.toList());
+ StackFrame nearestFrame = details.stream()
+ .map(detail -> nearestProjectFrame(detail, className))
+ .filter(Objects::nonNull)
+ .findFirst()
+ .orElse(null);
+
+ String firstSearchRegex = joinRegex(methodName, nearestFrame == null ?
null : nearestFrame.method);
+
+ if (!Strings.isNullOrEmpty(firstSearchRegex))
+ addSearch(commands, firstSearchRegex, "modules");
+
+ if (!Strings.isNullOrEmpty(className))
+ addSearch(commands, escapeRgTerm(className), "modules");
+
+ List<String> warns = suite.getLogsCheckResults()
+ .flatMap(map -> map.entrySet().stream())
+ .filter(entry -> entry.getValue() != null)
+ .flatMap(entry -> entry.getValue().getWarns().stream())
+ .collect(Collectors.toList());
+ List<String> searchSources = new ArrayList<>(warns);
+ searchSources.addAll(details);
+
+ String logRegex = logSearchRegex(searchSources);
+
+ if (!Strings.isNullOrEmpty(logRegex))
+ addSearch(commands, logRegex, "modules");
+
+ stackSearchTerms(details).forEach(term -> addSearch(commands,
escapeRgTerm(term), searchPathForStackTerm(term)));
+
+ if (!Strings.isNullOrEmpty(className) && warns.stream().anyMatch(warn
-> warn.contains("AssertionError")))
+ addSearch(commands, escapeRgTerm(className) + "|AssertionError",
"modules");
+
+ if (commands.isEmpty())
+ return;
+
+ appendSection(res, Section.SUGGESTED_LOCAL_SEARCHES);
+
+ commands.stream().limit(SUGGESTED_SEARCHES_LIMIT).forEach(cmd ->
res.append("- ").append(cmd).append('\n'));
+ }
+
+ /**
+ * @param commands Commands.
+ * @param regex Search regex.
+ * @param path Search path.
+ */
+ private void addSearch(Set<String> commands, String regex, String path) {
+ commands.add("rg -n \"" + regex.replace("\"", "\\\"") + "\" " + path);
+ }
+
+ /**
+ * @param fullName Full TeamCity test name.
+ * @return Local search regex for test class/method.
+ */
+ @Nullable private String testSearchRegex(@Nullable String fullName) {
+ String testCase = testCasePart(fullName);
+ String className = simpleClassName(testCase);
+ String methodName = methodName(testCase);
+
+ if (!Strings.isNullOrEmpty(methodName) &&
!Strings.isNullOrEmpty(className))
+ return escapeRgTerm(methodName) + "|" + escapeRgTerm(className);
+
+ if (!Strings.isNullOrEmpty(className))
+ return escapeRgTerm(className);
+
+ return null;
+ }
+
+ /**
+ * @param warns Log scanner messages from the current compacted API.
+ * @return Search regex for notable log messages.
+ */
+ @Nullable String logSearchRegex(List<String> warns) {
+ String joined = String.join("\n", warns);
+ List<String> terms = new ArrayList<>();
+
+ terms.addAll(throwableSearchTerms(warns));
+
+ addIfPresent(terms, joined, "SYSTEM_WORKER_TERMINATION");
+ addIfPresent(terms, joined, "Critical system error detected");
+ addIfPresent(terms, joined, "AssertionError");
+ addIfPresent(terms, joined, "OutOfMemoryError");
+ addIfPresent(terms, joined, "Process exited with code");
+
+ return
terms.stream().distinct().map(this::escapeRgTerm).collect(Collectors.joining("|"));
+ }
+
+ /**
+ * @param terms Plain terms.
+ * @return Regex.
+ */
+ @Nullable private String joinRegex(@Nullable String... terms) {
+ String regex = java.util.Arrays.stream(terms)
+ .filter(term -> !Strings.isNullOrEmpty(term))
+ .distinct()
+ .map(this::escapeRgTerm)
+ .collect(Collectors.joining("|"));
+
+ return Strings.isNullOrEmpty(regex) ? null : regex;
+ }
+
+ /**
+ * @param warns Log scanner messages from the current compacted API.
+ * @return Throwable class names and messages worth searching in the
checkout.
+ */
+ private List<String> throwableSearchTerms(List<String> warns) {
+ Set<String> terms = new LinkedHashSet<>();
+
+ for (String warn : warns) {
+ if (Strings.isNullOrEmpty(warn))
+ continue;
+
+ for (String line : warn.split("\\r?\\n")) {
+ Matcher matcher = THROWABLE_SIGNAL.matcher(line);
+
+ while (matcher.find()) {
+ String throwableCls =
simpleThrowableName(matcher.group(1));
+ String msg = normalizeSearchMessage(matcher.group(2));
+
+ if (!Strings.isNullOrEmpty(throwableCls))
+ terms.add(throwableCls);
+ if (!Strings.isNullOrEmpty(msg))
+ terms.add(msg);
+ }
+ }
+ }
+
+ return new ArrayList<>(terms);
+ }
+
+ /**
+ * @param details Cleaned TeamCity details.
+ * @return Stack/search terms likely useful in local checkout.
+ */
+ private List<String> stackSearchTerms(List<String> details) {
+ Set<String> terms = new LinkedHashSet<>();
+
+ for (String detail : details) {
+ if (Strings.isNullOrEmpty(detail))
+ continue;
+
+ for (String line : detail.split("\\r?\\n")) {
+ Matcher matcher = STACK_FRAME.matcher(line);
+
+ if (!matcher.find())
+ continue;
+
+ String owner = matcher.group(1);
+ String method = matcher.group(2);
+
+ if (isInterestingStackMethod(owner, method))
+ terms.add(method);
+ }
+ }
+
+ return terms.stream().limit(3).collect(Collectors.toList());
+ }
+
+ /**
+ * @param owner Class owner.
+ * @param method Method.
+ * @return {@code true} if method is useful for search.
+ */
+ private boolean isInterestingStackMethod(String owner, String method) {
+ String lower = owner.toLowerCase() + method.toLowerCase();
+
+ return lower.contains("affinity")
+ || lower.contains("exchange")
+ || lower.contains("topology")
+ || lower.contains("partition")
+ || lower.contains("cache")
+ || method.startsWith("await");
+ }
+
+ /**
+ * @param term Search term.
+ * @return Search path.
+ */
+ private String searchPathForStackTerm(String term) {
+ if (term.startsWith("await"))
+ return "modules/core/src/test modules/core/src/main";
+
+ return "modules/core/src/main modules/core/src/test";
+ }
+
+ /**
+ * @param cls Throwable class name.
+ * @return Simple throwable class name.
+ */
+ @Nullable private String simpleThrowableName(@Nullable String cls) {
+ if (Strings.isNullOrEmpty(cls))
+ return null;
+
+ int idx = cls.lastIndexOf('.');
+
+ return idx >= 0 ? cls.substring(idx + 1) : cls;
+ }
+
+ /**
+ * @param msg Exception/assertion message.
+ * @return Searchable message.
+ */
+ @Nullable private String normalizeSearchMessage(@Nullable String msg) {
+ if (Strings.isNullOrEmpty(msg))
+ return null;
+
+ String normalized = INLINE_STACK_TAIL.matcher(msg).replaceFirst("")
+ .replaceAll("\\s+", " ")
+ .trim();
+
+ return normalized.length() < 5 ? null : normalized;
+ }
+
+ /**
+ * @param terms Terms.
+ * @param text Text.
+ * @param term Term.
+ */
+ private void addIfPresent(List<String> terms, String text, String term) {
+ if (text.contains(term))
+ terms.add(term);
+ }
+
+ /**
+ * @param fullTestName Test name with package/class/method.
+ * @return Simple class name.
+ */
+ @Nullable private String simpleClassName(@Nullable String fullTestName) {
+ if (Strings.isNullOrEmpty(fullTestName))
+ return null;
+
+ int methodSep = fullTestName.lastIndexOf('.');
+
+ if (methodSep < 0)
+ return fullTestName;
+
+ int classSep = fullTestName.lastIndexOf('.', methodSep - 1);
+
+ return fullTestName.substring(classSep + 1, methodSep);
+ }
+
+ /**
+ * @param fullTestName Test name with package/class/method.
+ * @return Method name.
+ */
+ @Nullable private String methodName(@Nullable String fullTestName) {
+ if (Strings.isNullOrEmpty(fullTestName))
+ return null;
+
+ int methodSep = fullTestName.lastIndexOf('.');
+
+ if (methodSep < 0 || methodSep + 1 >= fullTestName.length())
+ return null;
+
+ return fullTestName.substring(methodSep + 1);
+ }
+
+ /**
+ * @param term Plain search term.
+ * @return Regex-safe search term.
+ */
+ private String escapeRgTerm(String term) {
+ StringBuilder res = new StringBuilder();
+
+ for (int i = 0; i < term.length(); i++) {
+ char ch = term.charAt(i);
+
+ if ("\\.^$|?*+()[]{}".indexOf(ch) >= 0)
+ res.append('\\');
+
+ res.append(ch);
+ }
+
+ return res.toString();
+ }
+
+ /**
+ * @param res Result builder.
+ * @param suite Suite context.
+ */
+ private void appendLogAnalysisNote(StringBuilder res, MultBuildRunCtx
suite) {
+ long pending = suite.pendingLogChecksCount();
+
+ if (pending > 0) {
+ res.append("Build log analysis was requested for this prompt, but
");
+ res.append(pending).append(" log task(s) did not finish before the
prompt timeout. ");
+ res.append("The prompt may miss log grep context; retry later to
use cached results.\n");
+
+ return;
+ }
+
+ if (suite.logChecksStartedCount() > 0) {
+ res.append("Build log analysis completed or failed without
matching scanner messages for this scope. ");
+ res.append("Raw 200-500 line windows around test start/end are not
available in the current bot cache.\n");
+
+ return;
+ }
+
+ res.append("Build log analysis was not available for this prompt. ");
+ res.append("Raw 200-500 line windows around test start/end are not
available in the current bot cache.\n");
+ }
+
/**
* @param logTestName Test name from log scanner.
* @param fullName Full TeamCity test name.
@@ -552,6 +1204,135 @@ public class TestFailuresAiPromptBuilder {
.replace(" ------- Stderr: ------- ", "\n\n------- Stderr:
-------\n");
}
+ /**
+ * @param details Cleaned TeamCity details.
+ * @return Short stdout/stderr tails for prompt context.
+ */
+ String detailsTailsForPrompt(String details) {
+ String[] lines = details.split("\\r?\\n");
+ StringBuilder res = new StringBuilder();
+
+ for (int i = 0; i < lines.length; i++) {
+ if (!isDetailsSectionHeader(lines[i]))
+ continue;
+
+ int end = i + 1;
+
+ while (end < lines.length && !isDetailsSectionHeader(lines[end]))
+ end++;
+
+ appendSectionTail(res, lines, i, end);
+ }
+
+ return res.toString().trim();
+ }
+
+ /**
+ * @param details Cleaned TeamCity details.
+ * @param duration Test duration in millis.
+ * @return Compact stdout/stderr interpretation.
+ */
+ String relevantStdoutStderrSummary(String details, @Nullable Integer
duration) {
+ List<String> bullets = new ArrayList<>();
+
+ if (duration != null)
+ bullets.add("Test started and failed within ~" + duration + "
ms.");
+
+ String location = firstJavaSourceLocation(details);
+
+ if (!Strings.isNullOrEmpty(location)) {
+ if (details.contains("Assertion"))
+ bullets.add("Assertion at " + location + ".");
+ else
+ bullets.add("Failure stack points to " + location + ".");
+ }
+
+ if (igniteNodeStartedAndStopped(details))
+ bullets.add("Ignite node started and stopped normally.");
+
+ if (!hasTimeoutOrCrashSignal(details))
+ bullets.add("No timeout or crash signal found in included
details.");
+
+ return bullets.stream().map(bullet -> "- " + bullet +
'\n').collect(Collectors.joining());
+ }
+
+ /**
+ * @param details Details.
+ * @return First Java source location.
+ */
+ @Nullable private String firstJavaSourceLocation(String details) {
+ Matcher matcher = JAVA_SOURCE_LOCATION.matcher(details);
+
+ return matcher.find() ? matcher.group(1) + ":" + matcher.group(2) :
null;
+ }
+
+ /**
+ * @param details Details.
+ * @return {@code true} if stdout/stderr contains a normal Ignite
lifecycle signal.
+ */
+ private boolean igniteNodeStartedAndStopped(String details) {
+ String lower = details.toLowerCase();
+
+ boolean started = lower.contains("ignite node started")
+ || lower.contains("topology snapshot")
+ || lower.contains(">>> started cache");
+
+ boolean stopped = lower.contains("ignite node stopped")
+ || lower.contains("stopping grid")
+ || lower.contains("grid stopped")
+ || lower.contains("ignite instance stopped");
+
+ return started && stopped;
+ }
+
+ /**
+ * @param details Details.
+ * @return {@code true} if details contains timeout/crash/process-failure
signal.
+ */
+ private boolean hasTimeoutOrCrashSignal(String details) {
+ String lower = details.toLowerCase();
+
+ return lower.contains("timeout")
+ || lower.contains("timed out")
+ || lower.contains("outofmemoryerror")
+ || lower.contains("hs_err")
+ || lower.contains("jvm crash")
+ || lower.contains("process exited")
+ || lower.contains("exit code");
+ }
+
+ /**
+ * @param line Details line.
+ * @return {@code true} if line starts a stdout/stderr details section.
+ */
+ private boolean isDetailsSectionHeader(String line) {
+ return "------- Stdout: -------".equals(line) || "------- Stderr:
-------".equals(line);
+ }
+
+ /**
+ * @param res Result.
+ * @param lines Details lines.
+ * @param headerIdx Header line index.
+ * @param end Exclusive section end.
+ */
+ private void appendSectionTail(StringBuilder res, String[] lines, int
headerIdx, int end) {
+ int start = Math.max(headerIdx + 1, end - DETAILS_SECTION_TAIL_LINES);
+
+ if (start >= end)
+ return;
+
+ if (res.length() > 0)
+ res.append('\n');
+
+ res.append(lines[headerIdx]).append('\n');
+
+ if (start > headerIdx + 1)
+ res.append("... skipped ").append(start - headerIdx - 1).append("
earlier lines ...\n");
+
+ for (int i = start; i < end; i++)
+ res.append(lines[i]).append('\n');
+ }
+
/**
* @param details Details from TeamCity.
*/
@@ -618,15 +1399,17 @@ public class TestFailuresAiPromptBuilder {
/**
* @param res Result builder.
*/
- private void appendInvestigationInstructions(StringBuilder res) {
- res.append("\n## Investigation Instructions\n");
+ private void appendInvestigationInstructions(StringBuilder res, @Nullable
String masterCauseInstruction) {
+ appendSection(res, Section.INVESTIGATION_INSTRUCTIONS);
res.append("- Start from the First real failure signal and nearest
project frame.\n");
res.append("- Search for the failing class, method, assertion text,
and error message in the checkout.\n");
res.append("- Compare test history and suite history separately before
calling it flaky.\n");
+ if (!Strings.isNullOrEmpty(masterCauseInstruction))
+ res.append("- ").append(masterCauseInstruction).append('\n');
res.append("- Classify the cause as exactly one of: caused by current
change; pre-existing flaky test; ");
res.append("environmental/infra; product bug exposed by test;
inconclusive.\n");
- res.append("\n## Required Final Answer\n");
+ appendSection(res, Section.REQUIRED_FINAL_ANSWER);
res.append("- Likely root cause.\n");
res.append("- Confidence level.\n");
res.append("- Files/classes/methods to inspect.\n");
@@ -689,6 +1472,40 @@ public class TestFailuresAiPromptBuilder {
* @param hist Run history.
*/
@Nullable private String breakBoundary(IRunHistory hist) {
+ BuildBoundary boundary = findBreakBoundary(hist);
+
+ if (boundary == null)
+ return null;
+
+ return "last OK build " + boundary.lastOk.buildId()
+ + ", first later failure build " +
boundary.firstBadAfterOk.buildId()
+ + ", change state at first failure " +
boundary.firstBadAfterOk.changesState();
+ }
+
+ /**
+ * @param branch Branch.
+ * @param hist Run history.
+ * @return Master branch cause interpretation instruction.
+ */
+ @Nullable private String masterChangeCauseInstruction(@Nullable String
branch, @Nullable IRunHistory hist) {
+ if (hist == null || !"master".equals(normalizeBranch(branch)))
+ return null;
+
+ BuildBoundary boundary = findBreakBoundary(hist);
+
+ if (boundary == null)
+ return null;
+
+ return "For master builds, interpret \"caused by current change\" as
caused by a recent master change " +
+ "between last OK build " + boundary.lastOk.buildId() + " and first
failing build " +
+ boundary.firstBadAfterOk.buildId() + ".";
+ }
+
+ /**
+ * @param hist Run history.
+ * @return Break boundary.
+ */
+ @Nullable private BuildBoundary findBreakBoundary(IRunHistory hist) {
List<Invocation> invocations = hist.getInvocations()
.filter(inv -> inv.status() != InvocationData.MISSING)
.filter(inv -> !Invocation.isMutedOrIgnored(inv.status()))
@@ -710,9 +1527,7 @@ public class TestFailuresAiPromptBuilder {
if (lastOk == null || firstBadAfterOk == null)
return null;
- return "last OK build " + lastOk.buildId()
- + ", first later failure build " + firstBadAfterOk.buildId()
- + ", change state at first failure " +
firstBadAfterOk.changesState();
+ return new BuildBoundary(lastOk, firstBadAfterOk);
}
/**
@@ -766,4 +1581,77 @@ public class TestFailuresAiPromptBuilder {
return String.valueOf(status);
}
+
+ /** Break boundary. */
+ private static class BuildBoundary {
+ /** Last successful run before break. */
+ private final Invocation lastOk;
+
+ /** First failing run after success. */
+ private final Invocation firstBadAfterOk;
+
+ /**
+ * @param lastOk Last successful run before break.
+ * @param firstBadAfterOk First failing run after success.
+ */
+ private BuildBoundary(Invocation lastOk, Invocation firstBadAfterOk) {
+ this.lastOk = lastOk;
+ this.firstBadAfterOk = firstBadAfterOk;
+ }
+ }
+
+ /** Throwable search signal. */
+ private static class ThrowableSignal {
+ /** Throwable class. */
+ private final String cls;
+
+ /** Throwable message. */
+ private final String msg;
+
+ /**
+ * @param cls Throwable class.
+ * @param msg Throwable message.
+ */
+ private ThrowableSignal(@Nullable String cls, @Nullable String msg) {
+ this.cls = Strings.nullToEmpty(cls);
+ this.msg = msg;
+ }
+ }
+
+ /** Stack frame signal. */
+ private static class StackFrame {
+ /** Owner prefix ending with dot. */
+ private final String owner;
+
+ /** Method. */
+ private final String method;
+
+ /** File. */
+ private final String file;
+
+ /** Line. */
+ private final String line;
+
+ /**
+ * @param owner Owner.
+ * @param method Method.
+ * @param file File.
+ * @param line Line.
+ */
+ private StackFrame(String owner, String method, String file, String
line) {
+ this.owner = owner;
+ this.method = method;
+ this.file = file;
+ this.line = line;
+ }
+
+ /** @return Short location. */
+ private String shortLocation() {
+ String ownerNoDot = owner.endsWith(".") ? owner.substring(0,
owner.length() - 1) : owner;
+ int clsSep = ownerNoDot.lastIndexOf('.');
+ String cls = clsSep >= 0 ? ownerNoDot.substring(clsSep + 1) :
ownerNoDot;
+
+ return cls + "." + method + ":" + line;
+ }
+ }
}
diff --git
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/FullChainRunCtx.java
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/FullChainRunCtx.java
index e9fb23ae..4b95a503 100644
---
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/FullChainRunCtx.java
+++
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/FullChainRunCtx.java
@@ -21,6 +21,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import java.util.stream.Stream;
import org.apache.ignite.tcservice.model.result.Build;
@@ -108,6 +109,27 @@ public class FullChainRunCtx {
this.buildCfgsResults.addAll(suites);
}
+ /** @return Count of build log analyses requested for this chain context.
*/
+ public long logChecksStartedCount() {
+ return
suites().mapToLong(MultBuildRunCtx::logChecksStartedCount).sum();
+ }
+
+ /** @return Count of build log analyses still running for this chain
context. */
+ public long pendingLogChecksCount() {
+ return
suites().mapToLong(MultBuildRunCtx::pendingLogChecksCount).sum();
+ }
+
+ /**
+ * Waits for build log analyses until timeout expires.
+ *
+ * @param timeoutMs Timeout in milliseconds.
+ */
+ public void awaitLogChecks(long timeoutMs) {
+ long deadlineNanos = System.nanoTime() +
TimeUnit.MILLISECONDS.toNanos(timeoutMs);
+
+ suites().forEach(suite -> suite.awaitLogChecksUntil(deadlineNanos));
+ }
+
public boolean isFakeStub() {
return fakeStub;
}
diff --git
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/MultBuildRunCtx.java
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/MultBuildRunCtx.java
index 46c1d34e..590468bb 100644
---
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/MultBuildRunCtx.java
+++
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/MultBuildRunCtx.java
@@ -31,6 +31,7 @@ import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Predicate;
import java.util.stream.Collectors;
@@ -110,6 +111,35 @@ public class MultBuildRunCtx implements ISuiteResults {
return
buildsStream().map(SingleBuildRunCtx::getTestLogCheckResult).filter(Objects::nonNull);
}
+ /** @return Count of build log analyses requested for this suite. */
+ public long logChecksStartedCount() {
+ return
buildsStream().filter(SingleBuildRunCtx::hasLogCheckStarted).count();
+ }
+
+ /** @return Count of build log analyses still running for this suite. */
+ public long pendingLogChecksCount() {
+ return
buildsStream().filter(SingleBuildRunCtx::hasPendingLogCheck).count();
+ }
+
+ /**
+ * Waits for suite build log analyses until the shared deadline.
+ *
+ * @param deadlineNanos Absolute {@link System#nanoTime()} deadline.
+ */
+ public void awaitLogChecksUntil(long deadlineNanos) {
+ for (SingleBuildRunCtx build : builds) {
+ if (!build.hasPendingLogCheck())
+ continue;
+
+ long remainingMs = TimeUnit.NANOSECONDS.toMillis(deadlineNanos -
System.nanoTime());
+
+ if (remainingMs <= 0)
+ return;
+
+ build.awaitLogCheck(remainingMs);
+ }
+ }
+
/** {@inheritDoc} */
@Override public String suiteId() {
return firstBuild().map(SingleBuildRunCtx::suiteId).orElse(null);
diff --git
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/SingleBuildRunCtx.java
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/SingleBuildRunCtx.java
index d0634103..79a12eeb 100644
---
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/SingleBuildRunCtx.java
+++
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/SingleBuildRunCtx.java
@@ -29,6 +29,8 @@ import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import javax.annotation.Nonnull;
@@ -134,6 +136,44 @@ public class SingleBuildRunCtx implements ISuiteResults {
this.logCheckResFut = logCheckResFut;
}
+ /** @return {@code True} if build log analysis was requested for this
build. */
+ public boolean hasLogCheckStarted() {
+ return logCheckResFut != null;
+ }
+
+ /** @return {@code True} if build log analysis is still running. */
+ public boolean hasPendingLogCheck() {
+ return logCheckResFut != null && !logCheckResFut.isDone() &&
!logCheckResFut.isCancelled();
+ }
+
+ /**
+ * Waits for build log analysis to finish.
+ *
+ * @param timeoutMs Timeout in milliseconds.
+ * @return {@code True} if no log analysis was requested or it finished
before the timeout.
+ */
+ public boolean awaitLogCheck(long timeoutMs) {
+ if (logCheckResFut == null || logCheckResFut.isDone() ||
logCheckResFut.isCancelled())
+ return true;
+
+ try {
+ logCheckResFut.get(Math.max(timeoutMs, 1), TimeUnit.MILLISECONDS);
+
+ return true;
+ }
+ catch (TimeoutException e) {
+ return false;
+ }
+ catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+
+ return false;
+ }
+ catch (Exception e) {
+ return true;
+ }
+ }
+
@Nullable public String getCriticalFailLastStartedTest() {
ILogCheckResult logCheckRes = getLogCheckIfFinished();
if (logCheckRes == null)
diff --git
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/pr/PrChainsProcessor.java
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/pr/PrChainsProcessor.java
index 4b524dea..dd574b9a 100644
---
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/pr/PrChainsProcessor.java
+++
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/pr/PrChainsProcessor.java
@@ -71,6 +71,12 @@ import org.apache.ignite.tcservice.ITeamcity;
* Process pull request/untracked branch chain at particular server.
*/
public class PrChainsProcessor {
+ /** Max time to wait for fresh AI prompt build context. */
+ private static final long AI_PROMPT_CONTEXT_WAIT_MS =
TimeUnit.MINUTES.toMillis(1);
+
+ /** Max time to wait for AI prompt build log processing. */
+ private static final long AI_PROMPT_LOG_WAIT_MS =
TimeUnit.MINUTES.toMillis(1);
+
private static class Action {
public static final String HISTORY = "History";
public static final String LATEST = "Latest";
@@ -454,6 +460,7 @@ public class PrChainsProcessor {
* @param maxDetailsChars Max chars to include for every test details
block. Non-positive means default cap.
* @param testName Optional full test name filter.
* @param promptSuiteId Optional suite id filter.
+ * @param waitForTc Wait for fresh TeamCity context and build log
processing.
* @return AI prompt with PR failure context.
*/
@AutoProfiling
@@ -467,7 +474,8 @@ public class PrChainsProcessor {
@Nullable String tcBaseBranchParm,
int maxDetailsChars,
@Nullable String testName,
- @Nullable String promptSuiteId) {
+ @Nullable String promptSuiteId,
+ boolean waitForTc) {
long reqId = aiPromptMonitor.start("pr", branchForTc, srvCodeOrAlias,
suiteId, testName);
try {
@@ -494,7 +502,10 @@ public class PrChainsProcessor {
aiPromptMonitor.stage(reqId, "loading chain context: " +
srvCodeOrAlias + "/" + suiteId);
FullChainRunCtx ctx = loadAiPromptContextBestEffort(reqId,
tcIgnited, hist, rebuild,
- buildResMergeCnt == 1, baseBranchForTc, srvCodeOrAlias + "/" +
suiteId);
+ buildResMergeCnt == 1, baseBranchForTc, srvCodeOrAlias + "/" +
suiteId, waitForTc);
+
+ if (waitForTc)
+ waitForAiPromptLogs(reqId, ctx, srvCodeOrAlias + "/" +
suiteId);
aiPromptMonitor.stage(reqId, "building prompt: " + srvCodeOrAlias
+ "/" + suiteId);
@@ -521,6 +532,7 @@ public class PrChainsProcessor {
* @param includeScheduledInfo Include scheduled info.
* @param baseBranchForTc Base branch.
* @param stageSuffix Stage suffix.
+ * @param waitForTc Wait for fresh TeamCity context and build log
processing.
*/
private FullChainRunCtx loadAiPromptContextBestEffort(
long reqId,
@@ -529,29 +541,46 @@ public class PrChainsProcessor {
LatestRebuildMode rebuild,
boolean includeScheduledInfo,
String baseBranchForTc,
- String stageSuffix) {
+ String stageSuffix,
+ boolean waitForTc) {
+ if (!waitForTc) {
+ aiPromptMonitor.stage(reqId, "using cached context without waiting
for TeamCity: " + stageSuffix);
+
+ return buildChainProcessor.loadFullChainContext(
+ tcIgnited,
+ hist,
+ LatestRebuildMode.NONE,
+ ProcessLogsMode.CACHED_ONLY,
+ false,
+ baseBranchForTc,
+ SyncMode.NONE,
+ null, null);
+ }
+
Future<FullChainRunCtx> live = null;
try {
- aiPromptMonitor.stage(reqId, "trying fresh context for up to 1s: "
+ stageSuffix);
+ aiPromptMonitor.stage(reqId, "loading fresh build chain from
TeamCity for up to "
+ + TimeUnit.MILLISECONDS.toSeconds(AI_PROMPT_CONTEXT_WAIT_MS) +
"s: " + stageSuffix);
live = tcUpdatePool.getService().submit(() ->
buildChainProcessor.loadFullChainContext(
tcIgnited,
hist,
rebuild,
- ProcessLogsMode.CACHED_ONLY,
+ ProcessLogsMode.ALL,
includeScheduledInfo,
baseBranchForTc,
SyncMode.RELOAD_QUEUED,
null, null));
- return live.get(1, TimeUnit.SECONDS);
+ return live.get(AI_PROMPT_CONTEXT_WAIT_MS, TimeUnit.MILLISECONDS);
}
catch (TimeoutException e) {
if (live != null)
live.cancel(true);
- aiPromptMonitor.stage(reqId, "fresh context timed out, using stale
cache: " + stageSuffix);
+ aiPromptMonitor.stage(reqId, "fresh TeamCity reload timed out,
loading best-effort cached context: "
+ + stageSuffix);
}
catch (InterruptedException e) {
if (live != null)
@@ -564,17 +593,43 @@ public class PrChainsProcessor {
throw new IllegalStateException("Interrupted while loading fresh
TeamCity context: " + stageSuffix, e);
}
catch (Exception e) {
- aiPromptMonitor.stage(reqId, "fresh context failed, using stale
cache: " + stageSuffix + " - " + e.getMessage());
+ aiPromptMonitor.stage(reqId, "fresh TeamCity reload failed,
loading best-effort cached context: "
+ + stageSuffix + " - " + e.getMessage());
}
return buildChainProcessor.loadFullChainContext(
tcIgnited,
hist,
- rebuild,
+ LatestRebuildMode.NONE,
ProcessLogsMode.CACHED_ONLY,
false,
baseBranchForTc,
SyncMode.NONE,
null, null);
}
+
+ /**
+ * @param reqId AI prompt request id.
+ * @param ctx Chain context.
+ * @param stageSuffix Stage suffix.
+ */
+ private void waitForAiPromptLogs(long reqId, FullChainRunCtx ctx, String
stageSuffix) {
+ long started = ctx.logChecksStartedCount();
+
+ if (started == 0)
+ return;
+
+ aiPromptMonitor.stage(reqId, "processing build logs: " + started + "
task(s), waiting up to "
+ + TimeUnit.MILLISECONDS.toSeconds(AI_PROMPT_LOG_WAIT_MS) + "s: " +
stageSuffix);
+
+ ctx.awaitLogChecks(AI_PROMPT_LOG_WAIT_MS);
+
+ long pending = ctx.pendingLogChecksCount();
+
+ if (pending > 0)
+ aiPromptMonitor.stage(reqId, "build log processing timeout: " +
pending
+ + " task(s) still running: " + stageSuffix);
+ else
+ aiPromptMonitor.stage(reqId, "build log processing finished: " +
stageSuffix);
+ }
}
diff --git
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/tracked/TrackedBranchChainsProcessor.java
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/tracked/TrackedBranchChainsProcessor.java
index db5633d9..35963682 100644
---
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/tracked/TrackedBranchChainsProcessor.java
+++
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/tracked/TrackedBranchChainsProcessor.java
@@ -76,6 +76,12 @@ public class TrackedBranchChainsProcessor implements
IDetailedStatusForTrackedBr
private static final long SLOW_TRACKED_BRANCH_WARN_MS =
Long.getLong("tcbot.tracked.slowOperationWarnMs", 1000L);
+ /** Max time to wait for fresh AI prompt build context. */
+ private static final long AI_PROMPT_CONTEXT_WAIT_MS =
TimeUnit.MINUTES.toMillis(1);
+
+ /** Max time to wait for AI prompt build log processing. */
+ private static final long AI_PROMPT_LOG_WAIT_MS =
TimeUnit.MINUTES.toMillis(1);
+
/** TC ignited server provider. */
@Inject private ITeamcityIgnitedProvider tcIgnitedProv;
@@ -109,6 +115,7 @@ public class TrackedBranchChainsProcessor implements
IDetailedStatusForTrackedBr
* @param maxDetailsChars Max chars to include for every test details
block. Non-positive means default cap.
* @param testName Full test name to include.
* @param suiteId Suite id to include.
+ * @param waitForTc Wait for fresh TeamCity context and build log
processing.
*/
@Nonnull public String getTrackedBranchFailuresAiPrompt(
@Nullable String branch,
@@ -119,7 +126,8 @@ public class TrackedBranchChainsProcessor implements
IDetailedStatusForTrackedBr
@Nullable SortOption sortOption,
@Nullable Integer maxDetailsChars,
@Nullable String testName,
- @Nullable String suiteId) {
+ @Nullable String suiteId,
+ boolean waitForTc) {
long reqId = aiPromptMonitor.start("trackedBranch", branch, null,
null, testName);
StringBuilder res = new StringBuilder();
@@ -154,7 +162,10 @@ public class TrackedBranchChainsProcessor implements
IDetailedStatusForTrackedBr
FullChainRunCtx ctx = loadAiPromptContextBestEffort(reqId,
tcIgnited, chains, rebuild,
buildResMergeCnt == 1, baseBranchTc, syncMode,
sortOption, requireParamVal,
- srvCodeOrAlias + "/" + suiteIdMandatory);
+ srvCodeOrAlias + "/" + suiteIdMandatory, waitForTc);
+
+ if (waitForTc)
+ waitForAiPromptLogs(reqId, ctx, srvCodeOrAlias + "/" +
suiteIdMandatory);
if (res.length() > 0)
res.append("\n\n");
@@ -187,6 +198,7 @@ public class TrackedBranchChainsProcessor implements
IDetailedStatusForTrackedBr
* @param sortOption Sort option.
* @param requireParamVal Required parameter values.
* @param stageSuffix Stage suffix.
+ * @param waitForTc Wait for fresh TeamCity context and build log
processing.
*/
private FullChainRunCtx loadAiPromptContextBestEffort(
long reqId,
@@ -198,17 +210,35 @@ public class TrackedBranchChainsProcessor implements
IDetailedStatusForTrackedBr
SyncMode liveSyncMode,
@Nullable SortOption sortOption,
@Nullable Map<Integer, Integer> requireParamVal,
- String stageSuffix) {
+ String stageSuffix,
+ boolean waitForTc) {
+ if (!waitForTc) {
+ aiPromptMonitor.stage(reqId, "using cached context without waiting
for TeamCity: " + stageSuffix);
+
+ return chainProc.loadFullChainContext(
+ tcIgnited,
+ chains,
+ LatestRebuildMode.NONE,
+ ProcessLogsMode.CACHED_ONLY,
+ false,
+ baseBranchTc,
+ SyncMode.NONE,
+ sortOption,
+ requireParamVal
+ );
+ }
+
Future<FullChainRunCtx> live = null;
try {
- aiPromptMonitor.stage(reqId, "trying fresh context for up to 1s: "
+ stageSuffix);
+ aiPromptMonitor.stage(reqId, "loading fresh build chain from
TeamCity for up to "
+ + TimeUnit.MILLISECONDS.toSeconds(AI_PROMPT_CONTEXT_WAIT_MS) +
"s: " + stageSuffix);
live = tcUpdatePool.getService().submit(() ->
chainProc.loadFullChainContext(
tcIgnited,
chains,
rebuild,
- ProcessLogsMode.CACHED_ONLY,
+ ProcessLogsMode.ALL,
includeScheduledInfo,
baseBranchTc,
liveSyncMode,
@@ -216,13 +246,14 @@ public class TrackedBranchChainsProcessor implements
IDetailedStatusForTrackedBr
requireParamVal
));
- return live.get(1, TimeUnit.SECONDS);
+ return live.get(AI_PROMPT_CONTEXT_WAIT_MS, TimeUnit.MILLISECONDS);
}
catch (TimeoutException e) {
if (live != null)
live.cancel(true);
- aiPromptMonitor.stage(reqId, "fresh context timed out, using stale
cache: " + stageSuffix);
+ aiPromptMonitor.stage(reqId, "fresh TeamCity reload timed out,
loading best-effort cached context: "
+ + stageSuffix);
}
catch (InterruptedException e) {
if (live != null)
@@ -235,13 +266,14 @@ public class TrackedBranchChainsProcessor implements
IDetailedStatusForTrackedBr
throw new IllegalStateException("Interrupted while loading fresh
TeamCity context: " + stageSuffix, e);
}
catch (Exception e) {
- aiPromptMonitor.stage(reqId, "fresh context failed, using stale
cache: " + stageSuffix + " - " + e.getMessage());
+ aiPromptMonitor.stage(reqId, "fresh TeamCity reload failed,
loading best-effort cached context: "
+ + stageSuffix + " - " + e.getMessage());
}
return chainProc.loadFullChainContext(
tcIgnited,
chains,
- rebuild,
+ LatestRebuildMode.NONE,
ProcessLogsMode.CACHED_ONLY,
false,
baseBranchTc,
@@ -251,6 +283,31 @@ public class TrackedBranchChainsProcessor implements
IDetailedStatusForTrackedBr
);
}
+ /**
+ * @param reqId AI prompt request id.
+ * @param ctx Chain context.
+ * @param stageSuffix Stage suffix.
+ */
+ private void waitForAiPromptLogs(long reqId, FullChainRunCtx ctx, String
stageSuffix) {
+ long started = ctx.logChecksStartedCount();
+
+ if (started == 0)
+ return;
+
+ aiPromptMonitor.stage(reqId, "processing build logs: " + started + "
task(s), waiting up to "
+ + TimeUnit.MILLISECONDS.toSeconds(AI_PROMPT_LOG_WAIT_MS) + "s: " +
stageSuffix);
+
+ ctx.awaitLogChecks(AI_PROMPT_LOG_WAIT_MS);
+
+ long pending = ctx.pendingLogChecksCount();
+
+ if (pending > 0)
+ aiPromptMonitor.stage(reqId, "build log processing timeout: " +
pending
+ + " task(s) still running: " + stageSuffix);
+ else
+ aiPromptMonitor.stage(reqId, "build log processing finished: " +
stageSuffix);
+ }
+
/** {@inheritDoc} */
@AutoProfiling
@Nonnull
diff --git
a/tcbot-engine/src/test/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilderTest.java
b/tcbot-engine/src/test/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilderTest.java
index 33793fa5..95b14871 100644
---
a/tcbot-engine/src/test/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilderTest.java
+++
b/tcbot-engine/src/test/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilderTest.java
@@ -17,9 +17,12 @@
package org.apache.ignite.tcbot.engine.build;
+import java.util.Arrays;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
public class TestFailuresAiPromptBuilderTest {
@Test
@@ -34,4 +37,100 @@ public class TestFailuresAiPromptBuilderTest {
TestFailuresAiPromptBuilder.restMaxDetailsChars(TestFailuresAiPromptBuilder.DFLT_MAX_DETAILS_CHARS
+ 1));
assertEquals(1024,
TestFailuresAiPromptBuilder.restMaxDetailsChars(1024));
}
+
+ @Test
+ public void logSearchRegexIncludesExceptionAndAssertionMessages() {
+ TestFailuresAiPromptBuilder builder = new
TestFailuresAiPromptBuilder(null);
+
+ String regex = builder.logSearchRegex(Arrays.asList(
+ "java.lang.IllegalStateException: Node left topology unexpectedly
at " +
+ "org.apache.ignite.internal.Test.test(Test.java:42)",
+ "Test failed. junit.framework.AssertionFailedError: expected cache
size to be 42 at " +
+ "junit.framework.Assert.fail(Assert.java:57)",
+ "java.lang.AssertionError: Partition did not rebalance",
+ "Caused by: org.apache.ignite.IgniteCheckedException",
+ "java.lang.OutOfMemoryError",
+ "ava.lang.IllegalStateException: Getting affinity for too old
topology version that is already out of " +
+ "history (try to increase 'IGNITE_AFFINITY_HISTORY_SIZE'
system property)"
+ ));
+
+ assertTrue(regex.contains("IllegalStateException"));
+ assertTrue(regex.contains("Node left topology unexpectedly"));
+ assertTrue(regex.contains("AssertionFailedError"));
+ assertTrue(regex.contains("expected cache size to be 42"));
+ assertTrue(regex.contains("Partition did not rebalance"));
+ assertTrue(regex.contains("IgniteCheckedException"));
+ assertTrue(regex.contains("OutOfMemoryError"));
+ assertTrue(regex.contains("AssertionError"));
+ assertTrue(regex.contains("Getting affinity for too old topology
version"));
+ assertTrue(regex.contains("IGNITE_AFFINITY_HISTORY_SIZE"));
+ assertFalse(regex.contains("Test\\.java:42"));
+ }
+
+ @Test
+ public void fastSummaryExtractsThrowableAndNearestProjectFrame() {
+ TestFailuresAiPromptBuilder builder = new
TestFailuresAiPromptBuilder(null);
+ String details = "java.lang.IllegalStateException: Getting affinity
for too old topology version " +
+ "that is already out of history\n" +
+ " at
org.apache.ignite.internal.processors.cache.GridCacheAffinityManager.cachedAffinity"
+
+ "(GridCacheAffinityManager.java:188)\n" +
+ " at
org.apache.ignite.internal.processors.cache.CacheEventWithTxLabelTest.prepareCache"
+
+ "(CacheEventWithTxLabelTest.java:402)\n";
+
+ String summary = builder.fastSummary("Cache 4", "master",
+ "Cache 4:
org.apache.ignite.internal.processors.cache.CacheEventWithTxLabelTest.testPassTxLabelInCashEventForAllCases",
+ 1, details, 341);
+
+ assertTrue(summary.contains("Single test failure in Cache 4 on
master."));
+ assertTrue(summary.contains("within ~341 ms"));
+ assertTrue(summary.contains("Exception: IllegalStateException: Getting
affinity for too old topology version"));
+ assertTrue(summary.contains("Nearest project frame:
CacheEventWithTxLabelTest.prepareCache:402."));
+ assertTrue(summary.contains("Likely area: affinity/topology/cache/near
prepareCache."));
+ }
+
+ @Test
+ public void detailsTailsForPromptKeepsOnlyStdoutAndStderrTails() {
+ TestFailuresAiPromptBuilder builder = new
TestFailuresAiPromptBuilder(null);
+ StringBuilder details = new StringBuilder();
+
+ details.append("junit.framework.AssertionFailedError: expected:<11>
but was:<12>\n");
+ details.append(" at org.apache.ignite.Test.test(Test.java:42)\n");
+ details.append("------- Stdout: -------\n");
+
+ for (int i = 0; i < 30; i++)
+ details.append("stdout line ").append(i).append('\n');
+
+ details.append("------- Stderr: -------\n");
+
+ for (int i = 0; i < 3; i++)
+ details.append("stderr line ").append(i).append('\n');
+
+ String tails = builder.detailsTailsForPrompt(details.toString());
+
+ assertFalse(tails.contains("expected:<11> but was:<12>"));
+ assertFalse(tails.contains("stdout line 0"));
+ assertTrue(tails.contains("... skipped 5 earlier lines ..."));
+ assertTrue(tails.contains("stdout line 5"));
+ assertTrue(tails.contains("stdout line 29"));
+ assertTrue(tails.contains("stderr line 0"));
+ assertTrue(tails.contains("stderr line 2"));
+ }
+
+ @Test
+ public void relevantStdoutStderrSummaryKeepsHighValueSignals() {
+ TestFailuresAiPromptBuilder builder = new
TestFailuresAiPromptBuilder(null);
+ String details = "junit.framework.AssertionFailedError: expected:<11>
but was:<12>\n" +
+ " at
org.apache.ignite.internal.WalDeletionArchiveAbstractTest.test(WalDeletionArchiveAbstractTest.java:212)\n"
+
+ "------- Stdout: -------\n" +
+ "Topology snapshot [ver=1, locNode=abc]\n" +
+ "Grid stopped.\n";
+
+ String summary = builder.relevantStdoutStderrSummary(details, 341);
+
+ assertTrue(summary.contains("Test started and failed within ~341
ms."));
+ assertTrue(summary.contains("Assertion at
WalDeletionArchiveAbstractTest.java:212."));
+ assertTrue(summary.contains("Ignite node started and stopped
normally."));
+ assertTrue(summary.contains("No timeout or crash signal found in
included details."));
+ assertFalse(summary.contains("expected:<11> but was:<12>"));
+ }
}