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 5b044ab5 Ignite 28635 add AI prompt generation for TeamCity failures
(#209)
5b044ab5 is described below
commit 5b044ab52406002a6585d84a79254245126257ae
Author: ignitetcbot <[email protected]>
AuthorDate: Fri May 8 19:32:47 2026 +0300
Ignite 28635 add AI prompt generation for TeamCity failures (#209)
Add TeamCity failure context extraction, stale-cache fallback and
monitoring for AI prompt generation.
Codex co-authored-by: Dmitriy Pavlov [email protected]
---
.../ignite/ci/web/StaticResourceServlet.java | 16 +-
.../rest/build/GetSingleBuildTestFailuresRest.java | 13 +
.../ci/web/rest/monitoring/MonitoringService.java | 11 +-
.../ignite/ci/web/rest/pr/GetPrTestFailures.java | 40 ++
.../rest/tracked/GetTrackedBranchTestResults.java | 25 +
ignite-tc-helper-web/src/main/webapp/build.html | 4 +-
ignite-tc-helper-web/src/main/webapp/current.html | 47 ++
.../src/main/webapp/js/testfails-2.2.js | 33 +
.../src/main/webapp/monitoring.html | 57 ++
ignite-tc-helper-web/src/main/webapp/pr.html | 49 +-
.../ignite/ci/web/StaticResourceServletTest.java | 115 +++
.../monitoring/MonitoringServiceSecurityTest.java | 3 +
.../tcbot/engine/build/AiPromptRequestMonitor.java | 233 +++++++
.../engine/build/SingleBuildResultsService.java | 141 +++-
.../engine/build/TestFailuresAiPromptBuilder.java | 769 +++++++++++++++++++++
.../tcbot/engine/chain/BuildChainProcessor.java | 27 +-
.../ignite/tcbot/engine/chain/ProcessLogsMode.java | 3 +
.../tcbot/engine/chain/SingleBuildRunCtx.java | 4 +
.../ignite/tcbot/engine/pr/PrChainsProcessor.java | 147 ++++
.../tracked/TrackedBranchChainsProcessor.java | 163 +++++
.../build/TestFailuresAiPromptBuilderTest.java | 37 +
.../ignited/buildtype/ParametersCompacted.java | 3 +
.../ignite/tcignited/build/TestCompactedV2.java | 23 +-
.../tcignited/buildlog/BuildLogProcessor.java | 16 +
.../tcignited/buildlog/IBuildLogProcessor.java | 2 +
25 files changed, 1946 insertions(+), 35 deletions(-)
diff --git
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/StaticResourceServlet.java
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/StaticResourceServlet.java
index 5a373881..8e038ea5 100644
---
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/StaticResourceServlet.java
+++
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/StaticResourceServlet.java
@@ -84,9 +84,7 @@ public class StaticResourceServlet extends HttpServlet {
if (path.endsWith("/"))
path += "index.html";
- String resPath = STATIC_ROOT + path;
-
- try (InputStream in =
Thread.currentThread().getContextClassLoader().getResourceAsStream(resPath)) {
+ try (InputStream in = openResource(path)) {
if (in == null) {
resp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
@@ -103,4 +101,16 @@ public class StaticResourceServlet extends HttpServlet {
in.transferTo(resp.getOutputStream());
}
}
+
+ /**
+ * @param path Resource path relative to webapp root.
+ */
+ private InputStream openResource(String path) {
+ InputStream in = getServletContext().getResourceAsStream("/" + path);
+
+ if (in != null)
+ return in;
+
+ return
Thread.currentThread().getContextClassLoader().getResourceAsStream(STATIC_ROOT
+ path);
+ }
}
diff --git
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/build/GetSingleBuildTestFailuresRest.java
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/build/GetSingleBuildTestFailuresRest.java
index abd9ed7a..6dcb9ff3 100644
---
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/build/GetSingleBuildTestFailuresRest.java
+++
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/build/GetSingleBuildTestFailuresRest.java
@@ -85,6 +85,19 @@ public class GetSingleBuildTestFailuresRest {
return getBuildTestFails(srvCodeOrAlias, buildId,
checkAllLogs).toString();
}
+ @GET
+ @Path("failures/aiPrompt")
+ @Produces(MediaType.TEXT_PLAIN)
+ public String getTestFailsAiPrompt(
+ @QueryParam("serverId") String srvCodeOrAlias,
+ @QueryParam("buildId") Integer buildId,
+ @Nullable @QueryParam("maxDetailsChars") Integer maxDetailsChars)
throws ServiceUnauthorizedException {
+ return CtxListener.getInjector(ctx)
+ .getInstance(SingleBuildResultsService.class)
+ .getSingleBuildFailuresAiPrompt(srvCodeOrAlias, buildId,
maxDetailsChars, SyncMode.RELOAD_QUEUED,
+ ITcBotUserCreds.get(req));
+ }
+
@GET
@Path("failuresNoSync")
public DsSummaryUi getBuildTestFailsNoSync(
diff --git
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
index 65dc2a0a..f2b5706d 100644
---
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
+++
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
@@ -34,9 +34,10 @@ import org.apache.ignite.cache.CacheMetrics;
import org.apache.ignite.cache.affinity.Affinity;
import org.apache.ignite.ci.web.CtxListener;
import org.apache.ignite.ci.web.model.SimpleResult;
+import org.apache.ignite.tcbot.common.conf.TcBotWorkDir;
import org.apache.ignite.tcbot.common.interceptor.AutoProfilingInterceptor;
import org.apache.ignite.tcbot.common.interceptor.MonitoredTaskInterceptor;
-import org.apache.ignite.tcbot.common.conf.TcBotWorkDir;
+import org.apache.ignite.tcbot.engine.build.AiPromptRequestMonitor;
import org.apache.ignite.tcbot.engine.conf.INotificationChannel;
import org.apache.ignite.tcbot.engine.conf.ITcBotConfig;
import org.apache.ignite.tcbot.engine.conf.NotificationsConfig;
@@ -500,4 +501,12 @@ public class MonitoringService {
return new SimpleResult("Ok");
}
+
+ @GET
+ @Path("aiPrompts")
+ public List<AiPromptRequestMonitor.Request> getAiPromptRequests() {
+ AiPromptRequestMonitor monitor =
CtxListener.getInjector(ctx).getInstance(AiPromptRequestMonitor.class);
+
+ return monitor.getRequests();
+ }
}
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 d46c82d7..c0347023 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
@@ -35,6 +35,7 @@ import org.apache.ignite.ci.web.CtxListener;
import org.apache.ignite.githubignited.IGitHubConnIgnited;
import org.apache.ignite.githubignited.IGitHubConnIgnitedProvider;
import org.apache.ignite.githubservice.IGitHubConnection;
+import org.apache.ignite.tcbot.engine.build.TestFailuresAiPromptBuilder;
import org.apache.ignite.tcbot.engine.pr.PrChainsProcessor;
import org.apache.ignite.tcbot.engine.ui.DsSummaryUi;
import org.apache.ignite.tcbot.engine.ui.UpdateInfo;
@@ -120,6 +121,45 @@ public class GetPrTestFailures {
return getPrFailsWithSyncMode(srvId, suiteId, branchForTc, act, cnt,
baseBranchForTc, checkAllLogs, SyncMode.RELOAD_QUEUED);
}
+ /**
+ * @param srvId Server id.
+ * @param suiteId Suite id.
+ * @param branchForTc Branch name in TC identification.
+ * @param act Action.
+ * @param cnt Count.
+ * @param baseBranchForTc Base branch name in TC identification.
+ * @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.
+ */
+ @GET
+ @Path("results/aiPrompt")
+ @Produces(MediaType.TEXT_PLAIN)
+ @NotNull public String getPrFailuresAiPrompt(
+ @Nullable @QueryParam("serverId") String srvId,
+ @Nonnull @QueryParam("suiteId") String suiteId,
+ @Nonnull @QueryParam("branchForTc") String branchForTc,
+ @Nonnull @QueryParam("action") String act,
+ @Nullable @QueryParam("count") Integer cnt,
+ @Nullable @QueryParam("baseBranchForTc") String baseBranchForTc,
+ @Nullable @QueryParam("maxDetailsChars") Integer maxDetailsChars,
+ @Nullable @QueryParam("testName") String testName,
+ @Nullable @QueryParam("promptSuiteId") String promptSuiteId) {
+ final Injector injector = CtxListener.getInjector(ctx);
+
+ return
injector.getInstance(PrChainsProcessor.class).getPrFailuresAiPrompt(
+ ITcBotUserCreds.get(req),
+ srvId,
+ suiteId,
+ branchForTc,
+ act,
+ cnt,
+ baseBranchForTc,
+ TestFailuresAiPromptBuilder.restMaxDetailsChars(maxDetailsChars),
+ testName,
+ promptSuiteId);
+ }
+
@POST
@Path("notifyGit")
public String getNotifyGit(
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 b0b969a3..d39f3740 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
@@ -106,6 +106,31 @@ public class GetTrackedBranchTestResults {
displayMode, sortOption, mergeCnt, showTestLongerThan, showMuted,
showIgnored).toString();
}
+ @GET
+ @Path("results/aiPrompt")
+ @Produces(MediaType.TEXT_PLAIN)
+ public String getTestFailsAiPrompt(@Nullable @QueryParam("branch") String
branchOrNull,
+ @Nullable @QueryParam("tagForHistSelected") String tagForHistSelected,
+ @Nullable @QueryParam("sortOption") String sortOption,
+ @Nullable @QueryParam("count") Integer mergeCnt,
+ @Nullable @QueryParam("maxDetailsChars") Integer maxDetailsChars,
+ @Nullable @QueryParam("testName") String testName,
+ @Nullable @QueryParam("promptSuiteId") String promptSuiteId) {
+ int actualMergeBuilds = (mergeCnt == null || mergeCnt < 1) ? 1 :
mergeCnt;
+
+ return CtxListener.getInjector(ctx)
+ .getInstance(TrackedBranchChainsProcessor.class)
+ .getTrackedBranchFailuresAiPrompt(branchOrNull,
+ actualMergeBuilds,
+ ITcBotUserCreds.get(req),
+ SyncMode.RELOAD_QUEUED,
+ tagForHistSelected,
+ SortOption.parseStringValue(sortOption),
+ maxDetailsChars,
+ testName,
+ promptSuiteId);
+ }
+
@GET
@Path("resultsNoSync")
public DsSummaryUi getTestFailsResultsNoSync(
diff --git a/ignite-tc-helper-web/src/main/webapp/build.html
b/ignite-tc-helper-web/src/main/webapp/build.html
index 6d26a445..50e8b435 100644
--- a/ignite-tc-helper-web/src/main/webapp/build.html
+++ b/ignite-tc-helper-web/src/main/webapp/build.html
@@ -128,8 +128,10 @@ function loadPartialData() {
function showData(result) {
var txtUrl = "rest/build/failures/txt" + parmsForRest();
+ var aiPromptUrl = "rest/build/failures/aiPrompt" + parmsForRest();
$("#divFailures").html(showChainOnServersResults(result)
+ + " <a href='"+ aiPromptUrl + "' title='Download AI prompt with
TeamCity failure context'>AI prompt</a>"
// + " <a href='"+ txtUrl + "'>txt</a>"
);
}
@@ -143,4 +145,4 @@ function showData(result) {
<div id="version"></div>
<div style="visibility:hidden;"><div id="triggerConfirm" title="Trigger
Confirmation"></div><div id="triggerDialog" title="Trigger Result"></div></div>
</body>
-</html>
\ No newline at end of file
+</html>
diff --git a/ignite-tc-helper-web/src/main/webapp/current.html
b/ignite-tc-helper-web/src/main/webapp/current.html
index decd43c6..674b2b3e 100644
--- a/ignite-tc-helper-web/src/main/webapp/current.html
+++ b/ignite-tc-helper-web/src/main/webapp/current.html
@@ -347,6 +347,53 @@ function showData(result) {
// + " <a href='"+ txtUrl + "'>txt</a>");
}
+function aiPromptUrlForTest(testName) {
+ return "rest/tracked/results/aiPrompt" + parmsForRest() + "&testName=" +
encodeURIComponent(testName);
+}
+
+function aiPromptUrlForSuite(suiteId) {
+ return "rest/tracked/results/aiPrompt" + parmsForRest() +
"&promptSuiteId=" + encodeURIComponent(suiteId);
+}
+
+function openAiPromptForTest(testName) {
+ openAiPrompt(aiPromptUrlForTest(testName));
+}
+
+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/testfails-2.2.js
b/ignite-tc-helper-web/src/main/webapp/js/testfails-2.2.js
index 8d7b6042..d7882eea 100644
--- a/ignite-tc-helper-web/src/main/webapp/js/testfails-2.2.js
+++ b/ignite-tc-helper-web/src/main/webapp/js/testfails-2.2.js
@@ -717,6 +717,13 @@ function showSuiteData(suite, settings, prNum) {
res += "</a> ]";
+ if (isDefinedAndFilled(suite.suiteId) && isSuiteProblematic(suite) &&
typeof openAiPromptForSuite === "function") {
+ res += " <a href='javascript:void(0);' ";
+ res += "onClick='openAiPromptForSuite(decodeURIComponent(";
+ res += JSON.stringify(encodeURIComponent(suite.suiteId)) + "))' ";
+ res += "title='Open AI prompt with TeamCity context for this
suite'>[AI Prompt]</a>";
+ }
+
if(isDefinedAndFilled(suite.tags)) {
for (let i = 0; i < suite.tags.length; i++) {
const tag = suite.tags[i];
@@ -818,6 +825,25 @@ function showSuiteData(suite, settings, prNum) {
return res;
}
+function isSuiteProblematic(suite) {
+ if (isDefinedAndFilled(suite.success) && suite.success === true)
+ return false;
+
+ if (isDefinedAndFilled(suite.failedTests) && suite.failedTests > 0)
+ return true;
+
+ if (isDefinedAndFilled(suite.result) && suite.result !== "")
+ return true;
+
+ if (isDefinedAndFilled(suite.hasCriticalProblem) &&
suite.hasCriticalProblem)
+ return true;
+
+ if (isDefinedAndFilled(suite.blockerComment) && suite.blockerComment !==
"")
+ return true;
+
+ return false;
+}
+
function failureRateToColor(failureRate) {
var redSaturation = 255;
var greenSaturation = 0;
@@ -958,6 +984,13 @@ function showTestFailData(testFail, isFailureShown,
settings) {
else
res += testFail.name;
+ if (isDefinedAndFilled(testFail.name) && typeof openAiPromptForTest ===
"function") {
+ res += " <a href='javascript:void(0);' ";
+ res += "onClick='openAiPromptForTest(decodeURIComponent(";
+ res += JSON.stringify(encodeURIComponent(testFail.name)) + "))' ";
+ res += "title='Open AI prompt with TeamCity context for this test'>[AI
Prompt]</a>";
+ }
+
var histContent = "";
//see class TestHistory
diff --git a/ignite-tc-helper-web/src/main/webapp/monitoring.html
b/ignite-tc-helper-web/src/main/webapp/monitoring.html
index d9932882..6aa73048 100644
--- a/ignite-tc-helper-web/src/main/webapp/monitoring.html
+++ b/ignite-tc-helper-web/src/main/webapp/monitoring.html
@@ -22,6 +22,7 @@
$( document ).tooltip();
loadData();
+ setInterval(loadAiPromptsData, 5000);
});
function loadPofilingData() {
@@ -84,6 +85,20 @@
},
error: showErrInLoadStatus
});
+
+ loadAiPromptsData();
+ }
+
+ function loadAiPromptsData() {
+ $.ajax({
+ url: "rest/monitoring/aiPrompts",
+ success: function(result) {
+ $("#loadStatus").html("");
+
+ showAiPrompts(result);
+ },
+ error: showErrInLoadStatus
+ });
}
/**
@@ -202,6 +217,43 @@
$("#caches").html(res);
}
+ function showAiPrompts(result) {
+ var res = "<table class='stat'>" ;
+ res += "<tr>";
+ res += "<th>Id</th>";
+ res += "<th>Kind</th>";
+ res += "<th>Branch</th>";
+ res += "<th>Server</th>";
+ res += "<th>Suite</th>";
+ res += "<th>Test</th>";
+ res += "<th>Stage</th>";
+ res += "<th>Start</th>";
+ res += "<th>End</th>";
+ res += "<th>Duration</th>";
+ res += "<th>Running</th>";
+ res += "<th>Result</th>";
+ res += "</tr>";
+ for (var i = 0; i < result.length; i++) {
+ var req = result[i];
+ res += "<tr>";
+ res += "<td>" + escapeHtml(req.id) + "</td>";
+ res += "<td>" + escapeHtml(req.kind) + "</td>";
+ res += "<td>" + escapeHtml(req.branch) + "</td>";
+ res += "<td>" + escapeHtml(req.server) + "</td>";
+ res += "<td>" + escapeHtml(req.suite) + "</td>";
+ res += "<td>" + escapeHtml(req.test) + "</td>";
+ res += "<td>" + escapeHtml(req.stage) + "</td>";
+ res += "<td>" + escapeHtml(req.start) + "</td>";
+ res += "<td>" + escapeHtml(req.end) + "</td>";
+ res += "<td>" + escapeHtml(req.duration) + "</td>";
+ res += "<td>" + escapeHtml(req.running) + "</td>";
+ res += "<td>" + escapeHtml(req.result) + "</td>";
+ res += "</tr>";
+ }
+ res += "</table>";
+ $("#aiPrompts").html(res);
+ }
+
function resetProfiling() {
$.ajax({
url: "rest/monitoring/resetProfiling",
@@ -271,6 +323,11 @@ Tasks Monitoring Data:
<div id="tasks" style="font-family: monospace"></div>
<br>
+<hr>
+<b>AI Prompt Requests:</b>
+<div id="aiPrompts" style="font-family: monospace"></div>
+<br>
+
<hr>
<b>REST Request Timings:</b> <button onclick="resetRequests()">Reset</button>
<div id="requests" style="font-family: monospace"></div>
diff --git a/ignite-tc-helper-web/src/main/webapp/pr.html
b/ignite-tc-helper-web/src/main/webapp/pr.html
index 11b52c44..5f0f97c2 100644
--- a/ignite-tc-helper-web/src/main/webapp/pr.html
+++ b/ignite-tc-helper-web/src/main/webapp/pr.html
@@ -128,6 +128,53 @@ function parmsForRest() {
return curReqParms;
}
+function aiPromptUrlForTest(testName) {
+ return "rest/pr/results/aiPrompt" + parmsForRest() + "&testName=" +
encodeURIComponent(testName);
+}
+
+function aiPromptUrlForSuite(suiteId) {
+ return "rest/pr/results/aiPrompt" + parmsForRest() + "&promptSuiteId=" +
encodeURIComponent(suiteId);
+}
+
+function openAiPromptForTest(testName) {
+ openAiPrompt(aiPromptUrlForTest(testName));
+}
+
+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();
@@ -235,4 +282,4 @@ function showData(result) {
<div id="version"></div>
<div style="visibility:hidden"><div id="triggerConfirm" title="Trigger
Confirmation"></div><div id="triggerDialog" title="Trigger Result"></div></div>
</body>
-</html>
\ No newline at end of file
+</html>
diff --git
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/StaticResourceServletTest.java
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/StaticResourceServletTest.java
new file mode 100644
index 00000000..a04131c6
--- /dev/null
+++
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/StaticResourceServletTest.java
@@ -0,0 +1,115 @@
+/*
+ * 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.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.WriteListener;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class StaticResourceServletTest {
+ @Test
+ public void servesRootFromWebappResourceBase() throws Exception {
+ ServletContext ctx = mock(ServletContext.class);
+ byte[] html = "<html>ok</html>".getBytes(StandardCharsets.UTF_8);
+
+ when(ctx.getResourceAsStream("/index.html")).thenReturn(new
ByteArrayInputStream(html));
+ when(ctx.getMimeType("index.html")).thenReturn("text/html");
+
+ TestStaticResourceServlet servlet = new TestStaticResourceServlet(ctx);
+
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(req.getContextPath()).thenReturn("");
+ when(req.getRequestURI()).thenReturn("/");
+
+ ByteArrayOutputStream body = new ByteArrayOutputStream();
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+ when(resp.getOutputStream()).thenReturn(new
ByteArrayServletOutputStream(body));
+
+ servlet.get(req, resp);
+
+ assertEquals("<html>ok</html>",
body.toString(StandardCharsets.UTF_8.name()));
+
+ verify(resp).setContentType("text/html");
+ verify(resp, never()).sendError(HttpServletResponse.SC_NOT_FOUND);
+ }
+
+ private static class TestStaticResourceServlet extends
StaticResourceServlet {
+ /** Context. */
+ private final ServletContext ctx;
+
+ /**
+ * @param ctx Context.
+ */
+ private TestStaticResourceServlet(ServletContext ctx) {
+ this.ctx = ctx;
+ }
+
+ /** {@inheritDoc} */
+ @Override public ServletContext getServletContext() {
+ return ctx;
+ }
+
+ /**
+ * @param req Request.
+ * @param resp Response.
+ */
+ private void get(HttpServletRequest req, HttpServletResponse resp)
throws Exception {
+ doGet(req, resp);
+ }
+ }
+
+ private static class ByteArrayServletOutputStream extends
ServletOutputStream {
+ /** Delegate. */
+ private final ByteArrayOutputStream delegate;
+
+ /**
+ * @param delegate Delegate.
+ */
+ private ByteArrayServletOutputStream(ByteArrayOutputStream delegate) {
+ this.delegate = delegate;
+ }
+
+ /** {@inheritDoc} */
+ @Override public boolean isReady() {
+ return true;
+ }
+
+ /** {@inheritDoc} */
+ @Override public void setWriteListener(WriteListener listener) {
+ // No async IO in this test.
+ }
+
+ /** {@inheritDoc} */
+ @Override public void write(int b) throws IOException {
+ delegate.write(b);
+ }
+ }
+}
diff --git
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringServiceSecurityTest.java
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringServiceSecurityTest.java
index 6eae4b1e..160e7299 100644
---
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringServiceSecurityTest.java
+++
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringServiceSecurityTest.java
@@ -35,6 +35,7 @@ public class MonitoringServiceSecurityTest {
assertAuthRequired(MonitoringService.class.getMethod("getRequestStats"));
assertAuthRequired(MonitoringService.class.getMethod("getRecentRequests"));
assertAuthRequired(MonitoringService.class.getMethod("resetRequestStats"));
+
assertAuthRequired(MonitoringService.class.getMethod("getAiPromptRequests"));
}
@Test
@@ -44,6 +45,8 @@ public class MonitoringServiceSecurityTest {
assertTrue(html.contains("escapeHtml(inv.method)"));
assertTrue(html.contains("escapeHtml(inv.path)"));
assertTrue(html.contains("escapeHtml(inv.lastRequest)"));
+ assertTrue(html.contains("escapeHtml(req.branch)"));
+ assertTrue(html.contains("escapeHtml(req.result)"));
assertTrue(html.contains("String(str == null ? \"\" : str)"));
}
diff --git
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/AiPromptRequestMonitor.java
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/AiPromptRequestMonitor.java
new file mode 100644
index 00000000..97b2fb3c
--- /dev/null
+++
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/AiPromptRequestMonitor.java
@@ -0,0 +1,233 @@
+/*
+ * 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.tcbot.engine.build;
+
+import com.google.common.base.Strings;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import javax.inject.Singleton;
+import org.apache.ignite.tcbot.common.util.TimeUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Lightweight request monitor for AI prompt generation.
+ */
+@Singleton
+public class AiPromptRequestMonitor {
+ /** Logger. */
+ private static final Logger logger =
LoggerFactory.getLogger(AiPromptRequestMonitor.class);
+
+ /** Request sequence. */
+ private final AtomicLong seq = new AtomicLong();
+
+ /** Requests. */
+ private final ConcurrentMap<Long, Request> requests = new
ConcurrentHashMap<>();
+
+ /**
+ * @param kind Prompt kind.
+ * @param branch Branch.
+ * @param server Server.
+ * @param suite Suite.
+ * @param test Test.
+ */
+ public long start(String kind, @Nullable String branch, @Nullable String
server, @Nullable String suite,
+ @Nullable String test) {
+ long id = seq.incrementAndGet();
+ Request req = new Request(id, kind, branch, server, suite, test);
+
+ requests.put(id, req);
+
+ logger.info("AI prompt request started: {}", req);
+
+ trim();
+
+ return id;
+ }
+
+ /**
+ * @param id Request id.
+ * @param stage Stage.
+ */
+ public void stage(long id, String stage) {
+ Request req = requests.get(id);
+
+ if (req == null)
+ return;
+
+ req.stage = stage;
+
+ logger.info("AI prompt request stage: id={}, stage={}", id, stage);
+ }
+
+ /**
+ * @param id Request id.
+ * @param result Result.
+ */
+ public void finish(long id, String result) {
+ Request req = requests.get(id);
+
+ if (req == null)
+ return;
+
+ req.finished = System.currentTimeMillis();
+ req.stage = "finished";
+ req.result = result;
+
+ logger.info("AI prompt request finished: {}", req);
+ }
+
+ /**
+ * @param id Request id.
+ * @param e Error.
+ */
+ public void fail(long id, Throwable e) {
+ Request req = requests.get(id);
+
+ if (req == null)
+ return;
+
+ req.finished = System.currentTimeMillis();
+ req.stage = "failed";
+ req.result = e.getClass().getSimpleName() + ": " + e.getMessage();
+
+ logger.warn("AI prompt request failed: {}", req, e);
+ }
+
+ /**
+ * @return Snapshot.
+ */
+ public List<Request> getRequests() {
+ return requests.values().stream()
+ .sorted(Comparator.comparingLong(Request::id).reversed())
+ .collect(Collectors.toList());
+ }
+
+ /** */
+ private void trim() {
+ if (requests.size() <= 200)
+ return;
+
+ requests.values().stream()
+ .sorted(Comparator.comparingLong(Request::id))
+ .limit(requests.size() - 200)
+ .map(Request::id)
+ .forEach(requests::remove);
+ }
+
+ /** Request record. */
+ @SuppressWarnings({"WeakerAccess", "PublicField"})
+ public static class Request {
+ /** Id. */
+ public final long id;
+
+ /** Kind. */
+ public final String kind;
+
+ /** Branch. */
+ public final String branch;
+
+ /** Server. */
+ public final String server;
+
+ /** Suite. */
+ public final String suite;
+
+ /** Test. */
+ public final String test;
+
+ /** Started timestamp. */
+ public final long started;
+
+ /** Finished timestamp. */
+ public volatile long finished;
+
+ /** Stage. */
+ public volatile String stage = "starting";
+
+ /** Result. */
+ public volatile String result = "";
+
+ /**
+ * @param id Id.
+ * @param kind Kind.
+ * @param branch Branch.
+ * @param server Server.
+ * @param suite Suite.
+ * @param test Test.
+ */
+ private Request(long id, String kind, @Nullable String branch,
@Nullable String server,
+ @Nullable String suite, @Nullable String test) {
+ this.id = id;
+ this.kind = kind;
+ this.branch = Strings.nullToEmpty(branch);
+ this.server = Strings.nullToEmpty(server);
+ this.suite = Strings.nullToEmpty(suite);
+ this.test = Strings.nullToEmpty(test);
+ started = System.currentTimeMillis();
+ }
+
+ /** @return Id. */
+ public long id() {
+ return id;
+ }
+
+ /** @return Started printable. */
+ public String getStart() {
+ return TimeUtil.timestampToDateTimePrintable(started);
+ }
+
+ /** @return End printable. */
+ public String getEnd() {
+ return TimeUtil.timestampToDateTimePrintable(finished);
+ }
+
+ /** @return Duration printable. */
+ public String getDuration() {
+ long end = finished == 0 ? System.currentTimeMillis() : finished;
+
+ return TimeUtil.millisToDurationPrintable(end - started);
+ }
+
+ /** @return Running. */
+ public boolean isRunning() {
+ return finished == 0;
+ }
+
+ /** {@inheritDoc} */
+ @Override public String toString() {
+ return "Request{" +
+ "id=" + id +
+ ", kind='" + kind + '\'' +
+ ", branch='" + branch + '\'' +
+ ", server='" + server + '\'' +
+ ", suite='" + suite + '\'' +
+ ", test='" + test + '\'' +
+ ", stage='" + stage + '\'' +
+ ", started='" + getStart() + '\'' +
+ ", duration='" + getDuration() + '\'' +
+ ", result='" + result + '\'' +
+ '}';
+ }
+ }
+}
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 f0ac5557..eab55dc0 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
@@ -21,6 +21,9 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.inject.Inject;
@@ -28,6 +31,7 @@ import
org.apache.ignite.tcbot.engine.chain.BuildChainProcessor;
import org.apache.ignite.tcbot.engine.chain.FullChainRunCtx;
import org.apache.ignite.tcbot.engine.chain.LatestRebuildMode;
import org.apache.ignite.tcbot.engine.chain.ProcessLogsMode;
+import org.apache.ignite.tcbot.engine.pool.TcUpdatePool;
import org.apache.ignite.tcbot.engine.ui.DsChainUi;
import org.apache.ignite.tcbot.engine.ui.DsSummaryUi;
import org.apache.ignite.tcbot.persistence.IStringCompactor;
@@ -48,11 +52,74 @@ public class SingleBuildResultsService {
@Inject BranchEquivalence branchEquivalence;
@Inject IStringCompactor compactor;
@Inject UpdateCountersStorage updateCounters;
+ @Inject AiPromptRequestMonitor aiPromptMonitor;
+ @Inject TcUpdatePool tcUpdatePool;
@Nonnull public DsSummaryUi getSingleBuildResults(String srvCodeOrAlias,
Integer buildId,
@Nullable Boolean checkAllLogs, SyncMode syncMode, ICredentialsProv
prov) {
DsSummaryUi res = new DsSummaryUi();
+ FullChainRunCtx ctx = loadSingleBuildContext(srvCodeOrAlias, buildId,
checkAllLogs, syncMode, prov);
+
+ ITeamcityIgnited tcIgnited = tcIgnitedProv.server(srvCodeOrAlias,
prov);
+
+ String failRateBranch = ITeamcity.DEFAULT;
+
+ DsChainUi chainStatus = new DsChainUi(srvCodeOrAlias,
tcIgnited.serverCode(), ctx.branchName());
+
+ chainStatus.initFromContext(tcIgnited, ctx, failRateBranch, compactor,
false, null, null, -1, null, false, false);
+
+ res.addChainOnServer(chainStatus);
+
+ res.initCounters(getBranchCntrs(srvCodeOrAlias, buildId, prov));
+ return res;
+ }
+
+ /**
+ * @param srvCodeOrAlias Server id or alias.
+ * @param buildId Build id.
+ * @param maxDetailsChars Max chars to include for every test details
block. Non-positive means default cap.
+ * @param syncMode Synchronization mode.
+ * @param prov Credentials provider.
+ */
+ @Nonnull public String getSingleBuildFailuresAiPrompt(String
srvCodeOrAlias, Integer buildId,
+ @Nullable Integer maxDetailsChars, SyncMode syncMode, ICredentialsProv
prov) {
+ long reqId = aiPromptMonitor.start("singleBuild", null,
srvCodeOrAlias, String.valueOf(buildId), null);
+
+ try {
+ aiPromptMonitor.stage(reqId, "loading build context");
+
+ FullChainRunCtx ctx = loadSingleBuildContextBestEffort(reqId,
srvCodeOrAlias, buildId, syncMode, prov);
+
+ ITeamcityIgnited tcIgnited = tcIgnitedProv.server(srvCodeOrAlias,
prov);
+
+ int maxDetails =
TestFailuresAiPromptBuilder.restMaxDetailsChars(maxDetailsChars);
+
+ aiPromptMonitor.stage(reqId, "building prompt");
+
+ String res = new TestFailuresAiPromptBuilder(compactor)
+ .buildPrompt(tcIgnited, ctx, ITeamcity.DEFAULT, maxDetails);
+
+ aiPromptMonitor.finish(reqId, "chars=" + res.length());
+
+ return res;
+ }
+ catch (RuntimeException e) {
+ aiPromptMonitor.fail(reqId, e);
+
+ throw e;
+ }
+ }
+
+ /**
+ * @param srvCodeOrAlias Server id or alias.
+ * @param buildId Build id.
+ * @param checkAllLogs Check all logs.
+ * @param syncMode Synchronization mode.
+ * @param prov Credentials provider.
+ */
+ private FullChainRunCtx loadSingleBuildContext(String srvCodeOrAlias,
Integer buildId,
+ @Nullable Boolean checkAllLogs, SyncMode syncMode, ICredentialsProv
prov) {
tcIgnitedProv.checkAccess(srvCodeOrAlias, prov);
ITeamcityIgnited tcIgnited = tcIgnitedProv.server(srvCodeOrAlias,
prov);
@@ -72,16 +139,78 @@ public class SingleBuildResultsService {
null,
null);
- DsChainUi chainStatus = new DsChainUi(srvCodeOrAlias,
tcIgnited.serverCode(), ctx.branchName());
+ return ctx;
+ }
- chainStatus.initFromContext(tcIgnited, ctx, failRateBranch, compactor,
false, null, null, -1, null, false, false);
+ /**
+ * @param reqId Monitor request id.
+ * @param srvCodeOrAlias Server id or alias.
+ * @param buildId Build id.
+ * @param liveSyncMode Live sync mode.
+ * @param prov Credentials provider.
+ */
+ private FullChainRunCtx loadSingleBuildContextBestEffort(long reqId,
String srvCodeOrAlias, Integer buildId,
+ SyncMode liveSyncMode, ICredentialsProv prov) {
+ Future<FullChainRunCtx> live = null;
+
+ try {
+ aiPromptMonitor.stage(reqId, "trying fresh context for up to 1s");
+
+ live = tcUpdatePool.getService().submit(() ->
+ loadSingleBuildContext(srvCodeOrAlias, buildId, null,
liveSyncMode, prov, ProcessLogsMode.CACHED_ONLY));
+
+ return live.get(1, TimeUnit.SECONDS);
+ }
+ catch (TimeoutException e) {
+ if (live != null)
+ live.cancel(true);
+
+ aiPromptMonitor.stage(reqId, "fresh context timed out, using stale
cache");
+ }
+ catch (InterruptedException e) {
+ if (live != null)
+ live.cancel(true);
+
+ Thread.currentThread().interrupt();
+
+ aiPromptMonitor.stage(reqId, "fresh context interrupted");
+
+ throw new IllegalStateException("Interrupted while loading fresh
TeamCity context", e);
+ }
+ catch (Exception e) {
+ aiPromptMonitor.stage(reqId, "fresh context failed, using stale
cache: " + e.getMessage());
+ }
+
+ return loadSingleBuildContext(srvCodeOrAlias, buildId, null,
SyncMode.NONE, prov, ProcessLogsMode.CACHED_ONLY);
+ }
- res.addChainOnServer(chainStatus);
+ /**
+ * @param srvCodeOrAlias Server id or alias.
+ * @param buildId Build id.
+ * @param checkAllLogs Check all logs.
+ * @param syncMode Synchronization mode.
+ * @param prov Credentials provider.
+ * @param procLogs Process logs mode override.
+ */
+ private FullChainRunCtx loadSingleBuildContext(String srvCodeOrAlias,
Integer buildId,
+ @Nullable Boolean checkAllLogs, SyncMode syncMode, ICredentialsProv
prov, ProcessLogsMode procLogs) {
+ tcIgnitedProv.checkAccess(srvCodeOrAlias, prov);
- res.initCounters(getBranchCntrs(srvCodeOrAlias, buildId, prov));
- return res;
- }
+ ITeamcityIgnited tcIgnited = tcIgnitedProv.server(srvCodeOrAlias,
prov);
+ String failRateBranch = ITeamcity.DEFAULT;
+
+ return buildChainProcessor.loadFullChainContext(
+ tcIgnited,
+ Collections.singletonList(buildId),
+ LatestRebuildMode.NONE,
+ procLogs,
+ false,
+ failRateBranch,
+ syncMode,
+ null,
+ null);
+ }
public Map<Integer, Integer> getBranchCntrs(String srvCodeOrAlias,
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
new file mode 100644
index 00000000..f262ca7e
--- /dev/null
+++
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilder.java
@@ -0,0 +1,769 @@
+/*
+ * 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.tcbot.engine.build;
+
+import com.google.common.base.Strings;
+import java.util.Comparator;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import org.apache.ignite.ci.teamcity.ignited.buildtype.ParametersCompacted;
+import org.apache.ignite.ci.teamcity.ignited.runhist.Invocation;
+import org.apache.ignite.tcbot.engine.chain.FullChainRunCtx;
+import org.apache.ignite.tcbot.engine.chain.MultBuildRunCtx;
+import org.apache.ignite.tcbot.engine.chain.SingleBuildRunCtx;
+import org.apache.ignite.tcbot.engine.chain.TestCompactedMult;
+import org.apache.ignite.tcbot.engine.ui.ShortTestFailureUi;
+import org.apache.ignite.tcbot.persistence.IStringCompactor;
+import org.apache.ignite.tcignited.ITeamcityIgnited;
+import org.apache.ignite.tcignited.build.ITest;
+import org.apache.ignite.tcignited.buildlog.ITestLogCheckResult;
+import org.apache.ignite.tcignited.history.InvocationData;
+import org.apache.ignite.tcignited.history.IRunHistory;
+import org.apache.ignite.tcservice.model.result.problems.ProblemOccurrence;
+
+import static
org.apache.ignite.tcbot.common.util.TimeUtil.millisToDurationPrintable;
+import static
org.apache.ignite.tcignited.buildref.BranchEquivalence.normalizeBranch;
+
+/**
+ * Builds an AI prompt with TeamCity failure context.
+ */
+public class TestFailuresAiPromptBuilder {
+ /** Default limit for a single TeamCity failure details block. */
+ public static final int DFLT_MAX_DETAILS_CHARS = 40000;
+
+ /** String compactor. */
+ private final IStringCompactor compactor;
+
+ /**
+ * @param compactor String compactor.
+ */
+ public TestFailuresAiPromptBuilder(IStringCompactor compactor) {
+ this.compactor = compactor;
+ }
+
+ /**
+ * @param maxDetailsChars Requested REST limit.
+ * @return Safe REST limit for a single TeamCity failure details block.
+ */
+ public static int restMaxDetailsChars(@Nullable Integer maxDetailsChars) {
+ if (maxDetailsChars == null || maxDetailsChars <= 0)
+ return DFLT_MAX_DETAILS_CHARS;
+
+ return Math.min(maxDetailsChars, DFLT_MAX_DETAILS_CHARS);
+ }
+
+ /**
+ * @param tcIgnited TeamCity facade.
+ * @param ctx Full chain context.
+ * @param baseBranchTc Base branch for failure-rate history.
+ * @param maxDetailsChars Max chars to include for every test details
block. Non-positive means no limit.
+ */
+ @Nonnull public String buildPrompt(ITeamcityIgnited tcIgnited,
FullChainRunCtx ctx,
+ @Nullable String baseBranchTc, int maxDetailsChars) {
+ return buildPrompt(tcIgnited, ctx, baseBranchTc, maxDetailsChars,
null, null);
+ }
+
+ /**
+ * @param tcIgnited TeamCity facade.
+ * @param ctx Full chain context.
+ * @param baseBranchTc Base branch for failure-rate history.
+ * @param maxDetailsChars Max chars to include for every test details
block. Non-positive means no limit.
+ * @param testNameFilter Optional full test name filter.
+ */
+ @Nonnull public String buildPrompt(ITeamcityIgnited tcIgnited,
FullChainRunCtx ctx,
+ @Nullable String baseBranchTc, int maxDetailsChars, @Nullable String
testNameFilter) {
+ return buildPrompt(tcIgnited, ctx, baseBranchTc, maxDetailsChars,
testNameFilter, null);
+ }
+
+ /**
+ * @param tcIgnited TeamCity facade.
+ * @param ctx Full chain context.
+ * @param baseBranchTc Base branch for failure-rate history.
+ * @param maxDetailsChars Max chars to include for every test details
block. Non-positive means no limit.
+ * @param testNameFilter Optional full test name filter.
+ * @param suiteIdFilter Optional suite id filter.
+ */
+ @Nonnull public String buildPrompt(ITeamcityIgnited tcIgnited,
FullChainRunCtx ctx,
+ @Nullable String baseBranchTc, int maxDetailsChars, @Nullable String
testNameFilter,
+ @Nullable String suiteIdFilter) {
+ StringBuilder res = new StringBuilder();
+
+ String normalizedBaseBranch = normalizeBranch(baseBranchTc);
+ Integer baseBranchId =
compactor.getStringIdIfPresent(normalizedBaseBranch);
+
+ appendHeader(res, tcIgnited, ctx, normalizedBaseBranch);
+
+ AtomicInteger suiteCnt = new AtomicInteger();
+ AtomicInteger testCnt = new AtomicInteger();
+
+ List<MultBuildRunCtx> failedSuites = ctx.suites()
+ .filter(suite -> !suite.isComposite())
+ .filter(MultBuildRunCtx::isFailed)
+ .filter(suite -> suiteIdFilter == null ||
suiteIdFilter.equals(suite.suiteId()))
+ .filter(suite -> testNameFilter == null
+ || suite.getFailedTests().stream().anyMatch(test ->
testNameFilter.equals(test.getName())))
+ .collect(Collectors.toList());
+
+ if (failedSuites.isEmpty())
+ res.append("No failed suites were found in the TeamCity
context.\n");
+
+ failedSuites.forEach(suite -> {
+ suiteCnt.incrementAndGet();
+ appendSuite(res, tcIgnited, suite, baseBranchId);
+
+ List<TestCompactedMult> failedTests = suite.getFailedTests()
+ .stream()
+ .filter(test -> testNameFilter == null ||
testNameFilter.equals(test.getName()))
+ .collect(Collectors.toList());
+
+ if (failedTests.isEmpty())
+ appendSuiteFailure(res, suite);
+ else {
+ for (TestCompactedMult test : failedTests) {
+ testCnt.incrementAndGet();
+ appendTest(res, tcIgnited, suite, test, baseBranchId,
maxDetailsChars);
+ }
+ }
+ });
+
+ res.append("## Included Scope\n");
+ res.append("Failed suites included:
").append(suiteCnt.get()).append('\n');
+ res.append("Failed tests included:
").append(testCnt.get()).append('\n');
+
+ return res.toString();
+ }
+
+ /**
+ * @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) {
+ 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");
+
+ res.append("## Run Context\n");
+ appendLine(res, "TeamCity server", tcIgnited.serverCode());
+ appendLine(res, "Chain", ctx.suiteName());
+ appendLine(res, "Suite id", ctx.suiteId());
+ appendLine(res, "Branch", ctx.branchName());
+ appendLine(res, "Base branch for history", normalizedBaseBranch);
+ appendLine(res, "Entry build id",
String.valueOf(ctx.getSuiteBuildId()));
+ appendLine(res, "Entry build", tcIgnited.host() +
"viewLog.html?buildId=" + ctx.getSuiteBuildId());
+ appendLine(res, "Duration", ctx.getDurationPrintable(suite -> true));
+ res.append('\n');
+
+ }
+
+ /**
+ * @param res Result builder.
+ * @param tcIgnited TeamCity facade.
+ * @param suite Suite context.
+ * @param baseBranchId Base branch compacted id.
+ */
+ private void appendSuite(StringBuilder res, ITeamcityIgnited tcIgnited,
MultBuildRunCtx suite,
+ @Nullable Integer baseBranchId) {
+ IRunHistory baseHist = suite.history(tcIgnited, baseBranchId, null);
+
+ res.append("## Run Context\n");
+ res.append("Suite:
").append(nullToUnknown(suite.suiteName())).append('\n');
+ appendLine(res, "Suite id", suite.suiteId());
+ appendLine(res, "Build id", String.valueOf(suite.getBuildId()));
+ appendLine(res, "Build", tcIgnited.host() + "viewLog.html?buildId=" +
suite.getBuildId());
+ appendLine(res, "Branch", suite.branchName());
+ appendLine(res, "Result", suite.getResult());
+ appendLine(res, "Failed tests", String.valueOf(suite.failedTests()));
+ appendLine(res, "Duration",
millisToDurationPrintable(suite.buildDuration()));
+ appendLine(res, "Execution parameters", executionParameters(suite));
+ appendLine(res, "Last change users",
compactCsv(suite.lastChangeUsers().collect(Collectors.toList())));
+
+ if (baseHist != null) {
+ appendLine(res, "Base branch suite failure rate",
baseHist.getFailPercentPrintable() + "%");
+ appendLine(res, "Base branch suite critical failure rate",
+ baseHist.getCriticalFailPercentPrintable() + "%");
+ }
+
+ String blockerComment = suite.getPossibleBlockerComment(compactor,
baseHist, tcIgnited.config());
+ appendLine(res, "Possible blocker note", blockerComment);
+
+ res.append('\n');
+ }
+
+ /**
+ * @param res Result builder.
+ * @param suite Suite context.
+ */
+ private void appendProblems(StringBuilder res, MultBuildRunCtx suite) {
+ List<ProblemOccurrence> problems = suite.allProblemsInAllBuilds()
+ .map(problem -> problem.toProblemOccurrence(compactor,
suite.getBuildId()))
+ .collect(Collectors.toList());
+
+ if (problems.isEmpty())
+ return;
+
+ res.append("TeamCity build problems:\n");
+
+ for (ProblemOccurrence problem : problems) {
+ res.append("- type=").append(nullToUnknown(problem.type));
+ res.append(", identity=").append(nullToUnknown(problem.identity));
+
+ if (problem.buildRef != null && problem.buildRef.getId() != null)
+ res.append(",
actualBuildId=").append(problem.buildRef.getId());
+
+ res.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()
+ .flatMap(map -> map.entrySet().stream())
+ .filter(entry -> entry.getValue() != null &&
entry.getValue().hasWarns())
+ .collect(Collectors.toList());
+
+ if (warnings.isEmpty())
+ return;
+
+ res.append("Build log scanner warnings:\n");
+
+ for (Map.Entry<String, ITestLogCheckResult> entry : warnings) {
+ ITestLogCheckResult checkRes = entry.getValue();
+
+ res.append("- ").append(nullToUnknown(entry.getKey()));
+ res.append(" (log size
").append(checkRes.getLogSizeBytes()).append(" bytes)\n");
+
+ for (String warn : checkRes.getWarns())
+ res.append(" ").append(warn).append('\n');
+ }
+ }
+
+ /**
+ * @param res Result builder.
+ * @param tcIgnited TeamCity facade.
+ * @param suite Suite context.
+ * @param test Test context.
+ * @param baseBranchId Base branch compacted id.
+ * @param maxDetailsChars Max details chars.
+ */
+ private void appendTest(StringBuilder res, ITeamcityIgnited tcIgnited,
MultBuildRunCtx suite,
+ TestCompactedMult test, @Nullable Integer baseBranchId, int
maxDetailsChars) {
+ String fullName = test.getName();
+ IRunHistory testHist = test.history(tcIgnited, baseBranchId);
+
+ res.append("## Failure Signal\n");
+ appendLine(res, "Full test name", fullName);
+ appendLine(res, "Suite before colon", testSuitePart(fullName));
+ appendLine(res, "Test after colon", testCasePart(fullName));
+ appendProblems(res, suite);
+
+ AtomicInteger invocationIdx = new AtomicInteger();
+
+ test.getInvocationsStream()
+ .filter(Objects::nonNull)
+ .filter(invocation -> invocation.isFailedButNotMuted(compactor))
+ .forEach(invocation -> appendInvocation(res, tcIgnited, suite,
invocation, fullName,
+ invocationIdx.incrementAndGet(), maxDetailsChars));
+
+ appendRelevantLogChecks(res, suite, fullName);
+
+ 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");
+
+ res.append("\n## Test Context\n");
+ appendLine(res, "Full test name", fullName);
+ appendLine(res, "Suite before colon", testSuitePart(fullName));
+ appendLine(res, "Test after colon", testCasePart(fullName));
+ appendLine(res, "Short suite",
ShortTestFailureUi.extractSuite(Strings.nullToEmpty(testSuitePart(fullName))));
+ appendLine(res, "Short test", extractShortTest(fullName));
+ appendLine(res, "Failures in loaded context",
String.valueOf(test.failuresCount()));
+ 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));
+ }
+
+ appendInvestigationInstructions(res);
+
+ res.append('\n');
+ }
+
+ /**
+ * @param res Result builder.
+ * @param suite Suite context.
+ */
+ private void appendSuiteFailure(StringBuilder res, MultBuildRunCtx suite) {
+ res.append("## Failure Signal\n");
+ 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);
+
+ 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");
+
+ res.append("\n## Test Context\n");
+ 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);
+
+ res.append('\n');
+ }
+
+ /**
+ * @param res Result builder.
+ * @param tcIgnited TeamCity facade.
+ * @param suite Suite context.
+ * @param invocation Single test invocation.
+ * @param idx Invocation index.
+ * @param maxDetailsChars Max details chars.
+ */
+ private void appendInvocation(StringBuilder res, ITeamcityIgnited
tcIgnited, MultBuildRunCtx suite,
+ ITest invocation, @Nullable String fullName, int idx, int
maxDetailsChars) {
+ int actualBuildId = invocation.getActualBuildId() > 0 ?
invocation.getActualBuildId() : suite.getBuildId();
+
+ res.append("Invocation ").append(idx).append(":\n");
+ appendLine(res, "Full test name", fullName);
+ appendLine(res, "Status",
compactor.getStringFromId(invocation.status()));
+ Integer duration = invocation.getDuration();
+
+ appendLine(res, "Duration", millisToDurationPrintable(duration == null
? null : duration.longValue()));
+ appendLine(res, "Actual build id", String.valueOf(actualBuildId));
+ appendLine(res, "Build URL", tcIgnited.host() +
"viewLog.html?buildId=" + actualBuildId);
+ appendLine(res, "TeamCity test id",
String.valueOf(invocation.getTestId()));
+ appendLine(res, "TeamCity test REST", testOccurrenceRestUrl(tcIgnited,
invocation, actualBuildId));
+ appendLine(res, "Muted", String.valueOf(invocation.isMutedTest()));
+ appendLine(res, "Ignored", String.valueOf(invocation.isIgnoredTest()));
+ appendLine(res, "Currently muted",
String.valueOf(invocation.getCurrentlyMuted()));
+ appendLine(res, "Currently investigated",
String.valueOf(invocation.getCurrInvestigatedFlag()));
+
+ String details = invocation.getDetailsText();
+
+ if (Strings.isNullOrEmpty(details))
+ appendEmptyDetailsDiagnosis(res, suite);
+ else {
+ String cleanedDetails = cleanDetails(details);
+
+ res.append("First real failure signal:\n");
+ res.append("```text\n");
+ 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");
+ }
+ }
+
+ /**
+ * @param res Result builder.
+ * @param suite Suite context.
+ * @param fullName Full test name.
+ */
+ private void appendRelevantLogChecks(StringBuilder res, MultBuildRunCtx
suite, @Nullable String fullName) {
+ List<String> snippets = 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();
+
+ if (!checkRes.hasWarns())
+ return;
+
+ snippets.add("Log grep for " + entry.getKey() + " (log size "
+ + checkRes.getLogSizeBytes() + " bytes):");
+
+ snippets.addAll(checkRes.getWarns());
+ });
+
+ 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");
+
+ return;
+ }
+
+ res.append("\n## Log Context\n");
+ res.append("Cached log grep from processed build log:\n");
+ res.append("```text\n");
+
+ for (String snippet : snippets)
+ res.append(snippet).append('\n');
+
+ res.append("```\n");
+ }
+
+ /**
+ * @param logTestName Test name from log scanner.
+ * @param fullName Full TeamCity test name.
+ */
+ private boolean isSameTestLog(@Nullable String logTestName, @Nullable
String fullName) {
+ if (Strings.isNullOrEmpty(logTestName) ||
Strings.isNullOrEmpty(fullName))
+ return false;
+
+ if (Objects.equals(logTestName, fullName))
+ return true;
+
+ String shortTest = extractShortTest(fullName);
+
+ return !Strings.isNullOrEmpty(shortTest) &&
logTestName.contains(shortTest);
+ }
+
+ /**
+ * @param fullName Full test name.
+ */
+ @Nullable private String extractShortTest(@Nullable String fullName) {
+ if (fullName == null)
+ return null;
+
+ String[] split = fullName.split("\\:");
+
+ if (split.length < 2)
+ return null;
+
+ return ShortTestFailureUi.extractTest(split[1]);
+ }
+
+ /**
+ * @param fullName Full test name.
+ */
+ @Nullable private String testSuitePart(@Nullable String fullName) {
+ if (fullName == null)
+ return null;
+
+ int colon = fullName.indexOf(':');
+
+ if (colon < 0)
+ return fullName;
+
+ return fullName.substring(0, colon).trim();
+ }
+
+ /**
+ * @param fullName Full test name.
+ */
+ @Nullable private String testCasePart(@Nullable String fullName) {
+ if (fullName == null)
+ return null;
+
+ int colon = fullName.indexOf(':');
+
+ if (colon < 0 || colon + 1 >= fullName.length())
+ return null;
+
+ return fullName.substring(colon + 1).trim();
+ }
+
+ /**
+ * @param tcIgnited TeamCity facade.
+ * @param invocation Invocation.
+ * @param actualBuildId Actual build id.
+ */
+ @Nullable private String testOccurrenceRestUrl(ITeamcityIgnited tcIgnited,
ITest invocation, int actualBuildId) {
+ if (invocation.idInBuild() < 0 || actualBuildId <= 0)
+ return null;
+
+ return tcIgnited.host() + "app/rest/latest/testOccurrences/id:" +
invocation.idInBuild()
+ + ",build:(id:" + actualBuildId + ")";
+ }
+
+ /**
+ * @param res Result builder.
+ * @param name Field name.
+ * @param val Field value.
+ */
+ private void appendLine(StringBuilder res, String name, @Nullable String
val) {
+ if (Strings.isNullOrEmpty(val))
+ return;
+
+ res.append("- ").append(name).append(": ").append(val).append('\n');
+ }
+
+ /**
+ * @param val Value.
+ */
+ private String nullToUnknown(@Nullable String val) {
+ return Strings.isNullOrEmpty(val) ? "<unknown>" : val;
+ }
+
+ /**
+ * @param text Text.
+ * @param maxChars Limit.
+ */
+ private String limit(String text, int maxChars) {
+ if (maxChars <= 0 || text.length() <= maxChars)
+ return text;
+
+ return text.substring(0, maxChars)
+ + "\n\n... truncated " + (text.length() - maxChars) + " chars. "
+ + "Increase maxDetailsChars up to the server cap if more details
are needed.";
+ }
+
+ /**
+ * @param details Details from TeamCity.
+ */
+ private String cleanDetails(String details) {
+ return details.trim()
+ .replace(" ------- Stdout: ------- ", "\n\n------- Stdout:
-------\n")
+ .replace(" ------- Stderr: ------- ", "\n\n------- Stderr:
-------\n");
+ }
+
+ /**
+ * @param details Details from TeamCity.
+ */
+ private String firstFailureSignal(String details) {
+ String normalized = details
+ .replace(" Caused by: ", "\nCaused by: ")
+ .replace(" at ", "\n at ");
+
+ String[] lines = normalized.split("\\r?\\n");
+ int start = -1;
+
+ for (int i = 0; i < lines.length; i++) {
+ String line = lines[i];
+
+ if (line.contains("Exception")
+ || line.contains("Assertion")
+ || line.contains("Error")
+ || line.contains("Caused by:")
+ || line.contains("Test failed")
+ || line.contains("Failed")) {
+ start = i;
+
+ break;
+ }
+ }
+
+ if (start < 0)
+ return limit(normalized, 6000);
+
+ StringBuilder res = new StringBuilder();
+
+ for (int i = start; i < lines.length && i < start + 80; i++)
+ res.append(lines[i]).append('\n');
+
+ return res.toString().trim();
+ }
+
+ /**
+ * @param res Result builder.
+ * @param suite Suite context.
+ */
+ private void appendEmptyDetailsDiagnosis(StringBuilder res,
MultBuildRunCtx suite) {
+ res.append("TeamCity failure details: <empty>\n");
+ res.append("Likely empty-details category: ");
+
+ if (suite.hasTimeoutProblem())
+ res.append("timeout or test process killed before reporting a
stack trace");
+ else if (suite.hasJvmCrashProblem())
+ res.append("JVM crash");
+ else if (suite.hasOomeProblem())
+ res.append("OOM / process killed");
+ else if (suite.hasExitCodeProblem())
+ res.append("test process exited with non-zero code");
+ else if (suite.hasProblemNonByFailedTest())
+ res.append("build problem without a test stack");
+ else if (suite.failedTests() == 0)
+ res.append("orphaned test/reporting issue");
+ else
+ res.append("reporting issue or TeamCity did not persist details");
+
+ res.append(".\n");
+ }
+
+ /**
+ * @param res Result builder.
+ */
+ private void appendInvestigationInstructions(StringBuilder res) {
+ res.append("\n## Investigation Instructions\n");
+ 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");
+ 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");
+ res.append("- Likely root cause.\n");
+ res.append("- Confidence level.\n");
+ res.append("- Files/classes/methods to inspect.\n");
+ res.append("- Minimal fix or mitigation.\n");
+ res.append("- Whether retry is justified.\n");
+ res.append("- Extra artifact/log that would confirm the diagnosis.\n");
+ }
+
+ /**
+ * @param vals Values.
+ */
+ private String compactCsv(List<String> vals) {
+ if (vals == null || vals.isEmpty())
+ return null;
+
+ return vals.stream()
+ .filter(v -> !Strings.isNullOrEmpty(v))
+ .distinct()
+ .limit(10)
+ .collect(Collectors.joining(", "));
+ }
+
+ /**
+ * @param suite Suite context.
+ */
+ @Nullable private String executionParameters(MultBuildRunCtx suite) {
+ List<String> params = new ArrayList<>();
+
+ suite.buildsStream()
+ .map(SingleBuildRunCtx::buildParameters)
+ .filter(Objects::nonNull)
+ .forEach(buildParams -> collectInterestingParameters(buildParams,
params));
+
+ if (params.isEmpty())
+ return null;
+
+ return
params.stream().distinct().limit(20).collect(Collectors.joining("; "));
+ }
+
+ /**
+ * @param buildParams Build parameters.
+ * @param params Destination.
+ */
+ private void collectInterestingParameters(ParametersCompacted buildParams,
List<String> params) {
+ buildParams.forEach(compactor, (key, val) -> {
+ String lower = key == null ? "" : key.toLowerCase();
+
+ if (lower.contains("os")
+ || lower.contains("agent")
+ || lower.contains("java")
+ || lower.contains("jdk")
+ || lower.contains("jvm")
+ || lower.contains("runner")
+ || lower.contains("env."))
+ params.add(key + "=" + val);
+ });
+ }
+
+ /**
+ * @param hist Run history.
+ */
+ @Nullable private String breakBoundary(IRunHistory hist) {
+ List<Invocation> invocations = hist.getInvocations()
+ .filter(inv -> inv.status() != InvocationData.MISSING)
+ .filter(inv -> !Invocation.isMutedOrIgnored(inv.status()))
+ .sorted(Comparator.comparing(Invocation::buildId))
+ .collect(Collectors.toList());
+
+ Invocation lastOk = null;
+ Invocation firstBadAfterOk = null;
+
+ for (Invocation inv : invocations) {
+ if (inv.status() == InvocationData.OK) {
+ lastOk = inv;
+ firstBadAfterOk = null;
+ }
+ else if (isFailure(inv) && lastOk != null && firstBadAfterOk ==
null)
+ firstBadAfterOk = inv;
+ }
+
+ 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();
+ }
+
+ /**
+ * @param hist Run history.
+ * @param limit Limit.
+ */
+ @Nullable private String recentHistory(IRunHistory hist, int limit) {
+ List<Invocation> recent = hist.getInvocations()
+ .filter(inv -> inv.status() != InvocationData.MISSING)
+ .sorted(Comparator.comparing(Invocation::buildId).reversed())
+ .limit(limit)
+ .sorted(Comparator.comparing(Invocation::buildId))
+ .collect(Collectors.toList());
+
+ if (recent.isEmpty())
+ return null;
+
+ return recent.stream()
+ .map(inv -> inv.buildId() + ":" + statusName(inv.status()) + ":" +
inv.changesState())
+ .collect(Collectors.joining(", "));
+ }
+
+ /**
+ * @param inv Invocation.
+ */
+ private boolean isFailure(Invocation inv) {
+ return inv.status() == InvocationData.FAILURE || inv.status() ==
InvocationData.CRITICAL_FAILURE;
+ }
+
+ /**
+ * @param status Status.
+ */
+ private String statusName(byte status) {
+ if (status == InvocationData.OK)
+ return "OK";
+
+ if (status == InvocationData.FAILURE)
+ return "FAIL";
+
+ if (status == InvocationData.CRITICAL_FAILURE)
+ return "CRITICAL_FAIL";
+
+ if (status == InvocationData.MISSING)
+ return "MISSING";
+
+ if (status == InvocationData.IGNORED)
+ return "IGNORED";
+
+ if (Invocation.isMutedOrIgnored(status))
+ return "MUTED_OR_IGNORED";
+
+ return String.valueOf(status);
+ }
+}
diff --git
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/BuildChainProcessor.java
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/BuildChainProcessor.java
index 8e04a280..02a99fb4 100644
---
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/BuildChainProcessor.java
+++
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/BuildChainProcessor.java
@@ -201,7 +201,8 @@ public class BuildChainProcessor {
final MultBuildRunCtx ctx = new MultBuildRunCtx(ref, compactor);
- buildsForSuite.forEach(buildCompacted ->
ctx.addBuild(loadChanges(buildCompacted, tcIgn)));
+ buildsForSuite.forEach(buildCompacted ->
ctx.addBuild(loadChanges(buildCompacted, tcIgn,
+ mode == SyncMode.NONE)));
//ask for history for the suite in parallel
tcUpdatePool.getService().submit(() -> {
@@ -310,9 +311,22 @@ public class BuildChainProcessor {
*/
public SingleBuildRunCtx loadChanges(@Nonnull FatBuildCompacted
buildCompacted,
ITeamcityIgnited tcIgnited) {
+ return loadChanges(buildCompacted, tcIgnited, false);
+ }
+
+ /**
+ * @param buildCompacted Build ref from history with references to tests.
+ * @param tcIgnited TC connection.
+ * @param cachedOnly Use only data already present in the fat build cache.
+ * @return Full context.
+ */
+ public SingleBuildRunCtx loadChanges(@Nonnull FatBuildCompacted
buildCompacted,
+ ITeamcityIgnited tcIgnited,
+ boolean cachedOnly) {
SingleBuildRunCtx ctx = new SingleBuildRunCtx(buildCompacted,
compactor);
- ctx.setChanges(tcIgnited.getAllChanges(buildCompacted.changes()));
+ if (!cachedOnly)
+ ctx.setChanges(tcIgnited.getAllChanges(buildCompacted.changes()));
ctx.addTags(SingleBuildRunCtx.getBuildTagsFromParameters(tcIgnited.config(),
compactor, buildCompacted));
@@ -493,7 +507,14 @@ public class BuildChainProcessor {
ProcessLogsMode procLog) {
for (SingleBuildRunCtx ctx : outCtx.getBuilds()) {
boolean incompleteFailure = ctx.hasSuiteIncompleteFailure();
- if ((procLog == ProcessLogsMode.SUITE_NOT_COMPLETE &&
incompleteFailure)
+ if (procLog == ProcessLogsMode.CACHED_ONLY) {
+ ILogCheckResult logCheckRes =
buildLogProcessor.getCachedBuildLogAnalysis(teamcity.serverCode(),
+ ctx.buildId());
+
+ if (logCheckRes != null)
+
ctx.setLogCheckResFut(CompletableFuture.completedFuture(logCheckRes));
+ }
+ else if ((procLog == ProcessLogsMode.SUITE_NOT_COMPLETE &&
incompleteFailure)
|| procLog == ProcessLogsMode.ALL)
ctx.setLogCheckResFut(
CompletableFuture.supplyAsync(
diff --git
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/ProcessLogsMode.java
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/ProcessLogsMode.java
index 5203ba00..3ad75f0c 100644
---
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/ProcessLogsMode.java
+++
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/ProcessLogsMode.java
@@ -26,6 +26,9 @@ public enum ProcessLogsMode {
/** Parse logs if suite not completed. */
SUITE_NOT_COMPLETE,
+ /** Use cached log analysis only, never download logs from TeamCity. */
+ CACHED_ONLY,
+
/** Always parse logs. */
ALL
}
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 b1a85065..d0634103 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
@@ -267,6 +267,10 @@ public class SingleBuildRunCtx implements ISuiteResults {
return tags;
}
+ @Nullable public ParametersCompacted buildParameters() {
+ return buildCompacted.parameters();
+ }
+
public static Set<String> getBuildTagsFromParameters(ITcServerConfig tcCfg,
IStringCompactor compactor, FatBuildCompacted fatBuildCompacted) {
ParametersCompacted parameters = fatBuildCompacted.parameters();
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 5f197e0c..f47b9898 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
@@ -24,6 +24,9 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
@@ -41,10 +44,13 @@ import org.apache.ignite.tcbot.engine.chain.FullChainRunCtx;
import org.apache.ignite.tcbot.engine.chain.LatestRebuildMode;
import org.apache.ignite.tcbot.engine.chain.MultBuildRunCtx;
import org.apache.ignite.tcbot.engine.chain.ProcessLogsMode;
+import org.apache.ignite.tcbot.engine.build.AiPromptRequestMonitor;
+import org.apache.ignite.tcbot.engine.build.TestFailuresAiPromptBuilder;
import org.apache.ignite.tcbot.engine.conf.ITcBotConfig;
import org.apache.ignite.tcbot.engine.conf.ITrackedBranch;
import org.apache.ignite.tcbot.engine.conf.ITrackedChain;
import org.apache.ignite.tcbot.engine.newtests.NewTestsStorage;
+import org.apache.ignite.tcbot.engine.pool.TcUpdatePool;
import org.apache.ignite.tcbot.engine.ui.DsChainUi;
import org.apache.ignite.tcbot.engine.ui.DsSummaryUi;
import org.apache.ignite.tcbot.engine.ui.ShortSuiteUi;
@@ -96,6 +102,12 @@ public class PrChainsProcessor {
@Inject private NewTestsStorage newTestsStorage;
+ /** AI prompt request monitor. */
+ @Inject private AiPromptRequestMonitor aiPromptMonitor;
+
+ /** TC update pool for best-effort AI prompt refreshes. */
+ @Inject private TcUpdatePool tcUpdatePool;
+
/**
* @param creds Credentials.
* @param srvCodeOrAlias Server code or alias.
@@ -432,4 +444,139 @@ public class PrChainsProcessor {
return countersStorage.getCounters(allRelatedBranchCodes);
}
+
+ /**
+ * @param creds Credentials.
+ * @param srvCodeOrAlias Server code or alias.
+ * @param suiteId Suite id.
+ * @param branchForTc Branch name in TC identification.
+ * @param act Action.
+ * @param cnt Count.
+ * @param tcBaseBranchParm Base branch name in TC identification.
+ * @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.
+ * @return AI prompt with PR failure context.
+ */
+ @AutoProfiling
+ public String getPrFailuresAiPrompt(
+ ICredentialsProv creds,
+ String srvCodeOrAlias,
+ String suiteId,
+ String branchForTc,
+ String act,
+ Integer cnt,
+ @Nullable String tcBaseBranchParm,
+ int maxDetailsChars,
+ @Nullable String testName,
+ @Nullable String promptSuiteId) {
+ long reqId = aiPromptMonitor.start("pr", branchForTc, srvCodeOrAlias,
suiteId, testName);
+
+ try {
+ ITeamcityIgnited tcIgnited =
tcIgnitedProvider.server(srvCodeOrAlias, creds);
+
+ LatestRebuildMode rebuild;
+ if (Action.HISTORY.equals(act))
+ rebuild = LatestRebuildMode.ALL;
+ else if (Action.CHAIN.equals(act))
+ rebuild = LatestRebuildMode.NONE;
+ else
+ rebuild = LatestRebuildMode.LATEST;
+
+ int buildResMergeCnt = rebuild == LatestRebuildMode.ALL ? cnt ==
null ? 10 : cnt : 1;
+
+ aiPromptMonitor.stage(reqId, "loading history: " + srvCodeOrAlias
+ "/" + suiteId);
+
+ List<Integer> hist = tcIgnited.getLastNBuildsFromHistory(suiteId,
branchForTc, buildResMergeCnt);
+
+ String baseBranchForTc = Strings.isNullOrEmpty(tcBaseBranchParm)
+ ? dfltBaseTcBranch(srvCodeOrAlias)
+ : tcBaseBranchParm;
+
+ aiPromptMonitor.stage(reqId, "loading chain context: " +
srvCodeOrAlias + "/" + suiteId);
+
+ FullChainRunCtx ctx = loadAiPromptContextBestEffort(reqId,
tcIgnited, hist, rebuild,
+ buildResMergeCnt == 1, baseBranchForTc, srvCodeOrAlias + "/" +
suiteId);
+
+ aiPromptMonitor.stage(reqId, "building prompt: " + srvCodeOrAlias
+ "/" + suiteId);
+
+ String res = new TestFailuresAiPromptBuilder(compactor)
+ .buildPrompt(tcIgnited, ctx, baseBranchForTc,
+
TestFailuresAiPromptBuilder.restMaxDetailsChars(maxDetailsChars), testName,
promptSuiteId);
+
+ aiPromptMonitor.finish(reqId, "chars=" + res.length());
+
+ return res;
+ }
+ catch (RuntimeException e) {
+ aiPromptMonitor.fail(reqId, e);
+
+ throw e;
+ }
+ }
+
+ /**
+ * @param reqId Monitor request id.
+ * @param tcIgnited TeamCity facade.
+ * @param hist Entry builds.
+ * @param rebuild Rebuild mode.
+ * @param includeScheduledInfo Include scheduled info.
+ * @param baseBranchForTc Base branch.
+ * @param stageSuffix Stage suffix.
+ */
+ private FullChainRunCtx loadAiPromptContextBestEffort(
+ long reqId,
+ ITeamcityIgnited tcIgnited,
+ List<Integer> hist,
+ LatestRebuildMode rebuild,
+ boolean includeScheduledInfo,
+ String baseBranchForTc,
+ String stageSuffix) {
+ Future<FullChainRunCtx> live = null;
+
+ try {
+ aiPromptMonitor.stage(reqId, "trying fresh context for up to 1s: "
+ stageSuffix);
+
+ live = tcUpdatePool.getService().submit(() ->
buildChainProcessor.loadFullChainContext(
+ tcIgnited,
+ hist,
+ rebuild,
+ ProcessLogsMode.CACHED_ONLY,
+ includeScheduledInfo,
+ baseBranchForTc,
+ SyncMode.RELOAD_QUEUED,
+ null, null));
+
+ return live.get(1, TimeUnit.SECONDS);
+ }
+ catch (TimeoutException e) {
+ if (live != null)
+ live.cancel(true);
+
+ aiPromptMonitor.stage(reqId, "fresh context timed out, using stale
cache: " + stageSuffix);
+ }
+ catch (InterruptedException e) {
+ if (live != null)
+ live.cancel(true);
+
+ Thread.currentThread().interrupt();
+
+ aiPromptMonitor.stage(reqId, "fresh context interrupted: " +
stageSuffix);
+
+ 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());
+ }
+
+ return buildChainProcessor.loadFullChainContext(
+ tcIgnited,
+ hist,
+ rebuild,
+ ProcessLogsMode.CACHED_ONLY,
+ false,
+ baseBranchForTc,
+ SyncMode.NONE,
+ null, null);
+ }
}
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 63c89686..db5633d9 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
@@ -25,7 +25,9 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
@@ -41,9 +43,12 @@ import org.apache.ignite.tcbot.engine.chain.FullChainRunCtx;
import org.apache.ignite.tcbot.engine.chain.LatestRebuildMode;
import org.apache.ignite.tcbot.engine.chain.ProcessLogsMode;
import org.apache.ignite.tcbot.engine.chain.SortOption;
+import org.apache.ignite.tcbot.engine.build.AiPromptRequestMonitor;
+import org.apache.ignite.tcbot.engine.build.TestFailuresAiPromptBuilder;
import org.apache.ignite.tcbot.engine.conf.ITcBotConfig;
import org.apache.ignite.tcbot.engine.conf.ITrackedBranch;
import org.apache.ignite.tcbot.engine.conf.ITrackedChain;
+import org.apache.ignite.tcbot.engine.pool.TcUpdatePool;
import org.apache.ignite.tcbot.engine.ui.DsChainUi;
import org.apache.ignite.tcbot.engine.ui.DsSummaryUi;
import org.apache.ignite.tcbot.engine.ui.GuardBranchStatusUi;
@@ -88,6 +93,164 @@ public class TrackedBranchChainsProcessor implements
IDetailedStatusForTrackedBr
/** Update Counters for branch-related changes storage. */
@Inject private UpdateCountersStorage countersStorage;
+ /** AI prompt monitor. */
+ @Inject private AiPromptRequestMonitor aiPromptMonitor;
+
+ /** TC update pool for best-effort AI prompt refreshes. */
+ @Inject private TcUpdatePool tcUpdatePool;
+
+ /**
+ * @param branch Branch.
+ * @param buildResMergeCnt Build results merge count.
+ * @param creds Credentials.
+ * @param syncMode Sync mode.
+ * @param tagForHistSelected Selected tag for filtering history.
+ * @param sortOption Sort mode.
+ * @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.
+ */
+ @Nonnull public String getTrackedBranchFailuresAiPrompt(
+ @Nullable String branch,
+ int buildResMergeCnt,
+ ICredentialsProv creds,
+ SyncMode syncMode,
+ @Nullable String tagForHistSelected,
+ @Nullable SortOption sortOption,
+ @Nullable Integer maxDetailsChars,
+ @Nullable String testName,
+ @Nullable String suiteId) {
+ long reqId = aiPromptMonitor.start("trackedBranch", branch, null,
null, testName);
+ StringBuilder res = new StringBuilder();
+
+ try {
+ final String branchNn = isNullOrEmpty(branch) ?
ITcServerConfig.DEFAULT_TRACKED_BRANCH_NAME : branch;
+ final ITrackedBranch tracked =
tcBotCfg.getTrackedBranches().getBranchMandatory(branchNn);
+ final int maxDetails =
TestFailuresAiPromptBuilder.restMaxDetailsChars(maxDetailsChars);
+
+ tracked.chainsStream()
+ .filter(chainTracked ->
tcIgnitedProv.hasAccess(chainTracked.serverCode(), creds))
+ .forEach(chainTracked -> {
+ String srvCodeOrAlias = chainTracked.serverCode();
+ String branchForTc = chainTracked.tcBranch();
+ String baseBranchTc =
chainTracked.tcBaseBranch().orElse(branchForTc);
+ String suiteIdMandatory = chainTracked.tcSuiteId();
+
+ aiPromptMonitor.stage(reqId, "loading history: " +
srvCodeOrAlias + "/" + suiteIdMandatory);
+
+ ITeamcityIgnited tcIgnited =
tcIgnitedProv.server(srvCodeOrAlias, creds);
+
+ Map<Integer, Integer> requireParamVal = new HashMap<>();
+
+ if (!Strings.isNullOrEmpty(tagForHistSelected))
+
requireParamVal.putAll(reverseTagToParametersRequired(tagForHistSelected,
srvCodeOrAlias));
+
+ List<Integer> chains =
tcIgnited.getLastNBuildsFromHistory(suiteIdMandatory, branchForTc,
+ Math.max(buildResMergeCnt, 1));
+
+ LatestRebuildMode rebuild = buildResMergeCnt > 1 ?
LatestRebuildMode.ALL : LatestRebuildMode.LATEST;
+
+ aiPromptMonitor.stage(reqId, "loading chain context: " +
srvCodeOrAlias + "/" + suiteIdMandatory);
+
+ FullChainRunCtx ctx = loadAiPromptContextBestEffort(reqId,
tcIgnited, chains, rebuild,
+ buildResMergeCnt == 1, baseBranchTc, syncMode,
sortOption, requireParamVal,
+ srvCodeOrAlias + "/" + suiteIdMandatory);
+
+ if (res.length() > 0)
+ res.append("\n\n");
+
+ aiPromptMonitor.stage(reqId, "building prompt: " +
srvCodeOrAlias + "/" + suiteIdMandatory);
+
+ res.append(new TestFailuresAiPromptBuilder(compactor)
+ .buildPrompt(tcIgnited, ctx, baseBranchTc, maxDetails,
testName, suiteId));
+ });
+
+ aiPromptMonitor.finish(reqId, "chars=" + res.length());
+
+ return res.toString();
+ }
+ catch (RuntimeException e) {
+ aiPromptMonitor.fail(reqId, e);
+
+ throw e;
+ }
+ }
+
+ /**
+ * @param reqId Monitor request id.
+ * @param tcIgnited TeamCity facade.
+ * @param chains Entry builds.
+ * @param rebuild Rebuild mode.
+ * @param includeScheduledInfo Include scheduled info.
+ * @param baseBranchTc Base branch.
+ * @param liveSyncMode Live sync mode.
+ * @param sortOption Sort option.
+ * @param requireParamVal Required parameter values.
+ * @param stageSuffix Stage suffix.
+ */
+ private FullChainRunCtx loadAiPromptContextBestEffort(
+ long reqId,
+ ITeamcityIgnited tcIgnited,
+ List<Integer> chains,
+ LatestRebuildMode rebuild,
+ boolean includeScheduledInfo,
+ String baseBranchTc,
+ SyncMode liveSyncMode,
+ @Nullable SortOption sortOption,
+ @Nullable Map<Integer, Integer> requireParamVal,
+ String stageSuffix) {
+ Future<FullChainRunCtx> live = null;
+
+ try {
+ aiPromptMonitor.stage(reqId, "trying fresh context for up to 1s: "
+ stageSuffix);
+
+ live = tcUpdatePool.getService().submit(() ->
chainProc.loadFullChainContext(
+ tcIgnited,
+ chains,
+ rebuild,
+ ProcessLogsMode.CACHED_ONLY,
+ includeScheduledInfo,
+ baseBranchTc,
+ liveSyncMode,
+ sortOption,
+ requireParamVal
+ ));
+
+ return live.get(1, TimeUnit.SECONDS);
+ }
+ catch (TimeoutException e) {
+ if (live != null)
+ live.cancel(true);
+
+ aiPromptMonitor.stage(reqId, "fresh context timed out, using stale
cache: " + stageSuffix);
+ }
+ catch (InterruptedException e) {
+ if (live != null)
+ live.cancel(true);
+
+ Thread.currentThread().interrupt();
+
+ aiPromptMonitor.stage(reqId, "fresh context interrupted: " +
stageSuffix);
+
+ 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());
+ }
+
+ return chainProc.loadFullChainContext(
+ tcIgnited,
+ chains,
+ rebuild,
+ ProcessLogsMode.CACHED_ONLY,
+ false,
+ baseBranchTc,
+ SyncMode.NONE,
+ sortOption,
+ requireParamVal
+ );
+ }
+
/** {@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
new file mode 100644
index 00000000..33793fa5
--- /dev/null
+++
b/tcbot-engine/src/test/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilderTest.java
@@ -0,0 +1,37 @@
+/*
+ * 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.tcbot.engine.build;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class TestFailuresAiPromptBuilderTest {
+ @Test
+ public void restMaxDetailsCharsUsesHardCap() {
+ assertEquals(TestFailuresAiPromptBuilder.DFLT_MAX_DETAILS_CHARS,
+ TestFailuresAiPromptBuilder.restMaxDetailsChars(null));
+ assertEquals(TestFailuresAiPromptBuilder.DFLT_MAX_DETAILS_CHARS,
+ TestFailuresAiPromptBuilder.restMaxDetailsChars(0));
+ assertEquals(TestFailuresAiPromptBuilder.DFLT_MAX_DETAILS_CHARS,
+ TestFailuresAiPromptBuilder.restMaxDetailsChars(-1));
+ assertEquals(TestFailuresAiPromptBuilder.DFLT_MAX_DETAILS_CHARS,
+
TestFailuresAiPromptBuilder.restMaxDetailsChars(TestFailuresAiPromptBuilder.DFLT_MAX_DETAILS_CHARS
+ 1));
+ assertEquals(1024,
TestFailuresAiPromptBuilder.restMaxDetailsChars(1024));
+ }
+}
diff --git
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/ci/teamcity/ignited/buildtype/ParametersCompacted.java
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/ci/teamcity/ignited/buildtype/ParametersCompacted.java
index 65ac3a10..6783e638 100644
---
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/ci/teamcity/ignited/buildtype/ParametersCompacted.java
+++
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/ci/teamcity/ignited/buildtype/ParametersCompacted.java
@@ -154,6 +154,9 @@ public class ParametersCompacted {
}
public void forEach(BiConsumer<Integer, Integer> consumer) {
+ if (keys == null || values == null)
+ return;
+
int size = keys.size();
for (int i = 0; i < size; i++) {
diff --git
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/build/TestCompactedV2.java
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/build/TestCompactedV2.java
index cfd64f8b..f46e5704 100644
---
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/build/TestCompactedV2.java
+++
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/build/TestCompactedV2.java
@@ -260,28 +260,11 @@ public class TestCompactedV2 implements ITest {
public void setDetails(String details, @Nullable ILogProductSpecific
logSpecific) {
this.details = null;
- if (Strings.isNullOrEmpty(details) || logSpecific == null)
+ if (Strings.isNullOrEmpty(details))
return;
- StringBuilder sb = new StringBuilder();
-
- //todo check integration with JIRA
- for (String s : details.split("\n")) {
- if(s.isEmpty())
- continue;
-
- if (logSpecific.needWarn(s)
- || s.contains("http://issues.apache.org/jira/browse/")
- || s.contains("https://issues.apache.org/jira/browse/")) {
- sb.append(s);
- sb.append("\n");
- }
- }
-
- if (sb.length() > 0) {
- this.details = new StringFieldCompacted();
- this.details.setValue(sb.toString());
- }
+ this.details = new StringFieldCompacted();
+ this.details.setValue(details);
}
public Boolean getIgnoredFlag() {
diff --git
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/buildlog/BuildLogProcessor.java
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/buildlog/BuildLogProcessor.java
index 67bf01ed..513fcbac 100644
---
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/buildlog/BuildLogProcessor.java
+++
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/buildlog/BuildLogProcessor.java
@@ -94,6 +94,22 @@ class BuildLogProcessor implements IBuildLogProcessor {
}
}
+ @Nullable
+ @Override
+ public ILogCheckResult getCachedBuildLogAnalysis(String serverCode,
Integer buildId) {
+ if (buildId == null)
+ return null;
+
+ long cacheKey = BuildLogCheckResultDao.getCacheKey(serverCode,
buildId);
+
+ ILogCheckResult cached = logCheckResultCache.getIfPresent(cacheKey);
+
+ if (cached != null)
+ return cached;
+
+ return logCheckResultDao.get(serverCode, buildId);
+ }
+
@Nullable
@Override
public String getThreadDumpCached(String serverCode, Integer buildId) {
diff --git
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/buildlog/IBuildLogProcessor.java
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/buildlog/IBuildLogProcessor.java
index 6816004f..35d1539f 100644
---
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/buildlog/IBuildLogProcessor.java
+++
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/buildlog/IBuildLogProcessor.java
@@ -23,6 +23,8 @@ import javax.annotation.Nullable;
public interface IBuildLogProcessor {
public ILogCheckResult analyzeBuildLog(ITeamcityIgnited teamcity, int
buildId, boolean dumpLastTest);
+ @Nullable
+ public ILogCheckResult getCachedBuildLogAnalysis(String serverCode,
Integer buildId);
@Nullable
public String getThreadDumpCached(String serverCode, Integer buildId);