logLines = logExtractor.getFailedStepLog();
+ this.urlString = logExtractor.getUrl();
if (StringUtils.isBlank(logPattern)) {
// Return last few lines if no pattern specified
@@ -83,7 +87,7 @@ private String extractErrorLogs(Run, ?> run, String logPattern, int maxLines)
* Explains error text directly without extracting from logs.
* Used for console output error explanation.
*/
- public ErrorExplanationAction explainErrorText(String errorText, @NonNull Run, ?> run) throws IOException, ExplanationException {
+ public ErrorExplanationAction explainErrorText(String errorText, String url, @NonNull Run, ?> run) throws IOException, ExplanationException {
String jobInfo ="[" + run.getParent().getFullName() + " #" + run.getNumber() + "]";
GlobalConfigurationImpl config = GlobalConfigurationImpl.get();
@@ -95,7 +99,7 @@ public ErrorExplanationAction explainErrorText(String errorText, @NonNull Run
LOGGER.fine(jobInfo + " AI error explanation succeeded.");
LOGGER.finer("Explanation length: " + explanation.length());
this.providerName = provider.getProviderName();
- ErrorExplanationAction action = new ErrorExplanationAction(explanation, errorText, provider.getProviderName());
+ ErrorExplanationAction action = new ErrorExplanationAction(explanation, url, errorText, provider.getProviderName());
run.addOrReplaceAction(action);
run.save();
diff --git a/src/main/java/io/jenkins/plugins/explain_error/ErrorExplanationAction.java b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplanationAction.java
index 90e1c0c..4088afe 100644
--- a/src/main/java/io/jenkins/plugins/explain_error/ErrorExplanationAction.java
+++ b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplanationAction.java
@@ -9,16 +9,18 @@
public class ErrorExplanationAction implements RunAction2 {
private final String explanation;
+ private final String urlString;
private final transient String originalErrorLogs;
private final long timestamp;
private String providerName = "Unknown";
private transient Run, ?> run;
- public ErrorExplanationAction(String explanation, String originalErrorLogs, String providerName) {
+ public ErrorExplanationAction(String explanation, String urlString, String originalErrorLogs, String providerName) {
this.explanation = explanation;
this.originalErrorLogs = originalErrorLogs;
this.timestamp = System.currentTimeMillis();
this.providerName = providerName;
+ this.urlString = urlString;
}
public Object readResolve() {
@@ -63,6 +65,10 @@ public String getProviderName() {
return providerName;
}
+ public String getUrlString() {
+ return urlString;
+ }
+
@Override
public void onAttached(Run, ?> r) {
this.run = r;
diff --git a/src/main/java/io/jenkins/plugins/explain_error/PipelineLogExtractor.java b/src/main/java/io/jenkins/plugins/explain_error/PipelineLogExtractor.java
new file mode 100644
index 0000000..7bed19c
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/explain_error/PipelineLogExtractor.java
@@ -0,0 +1,165 @@
+package io.jenkins.plugins.explain_error;
+
+import org.jenkinsci.plugins.workflow.job.WorkflowRun;
+
+import org.jenkinsci.plugins.workflow.flow.FlowExecution;
+import org.jenkinsci.plugins.workflow.graph.FlowNode;
+import org.jenkinsci.plugins.workflow.graph.FlowGraphWalker;
+import org.jenkinsci.plugins.workflow.actions.ErrorAction;
+import org.jenkinsci.plugins.workflow.actions.LogAction;
+import hudson.console.AnnotatedLargeText;
+import hudson.console.ConsoleNote;
+import hudson.model.Run;
+import jenkins.model.Jenkins;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.logging.Logger;
+import java.util.List;
+
+/**
+ * Utility for extracting log lines related to a failing build or pipeline step
+ * and computing a URL that points back to the error source.
+ *
+ * For {@link org.jenkinsci.plugins.workflow.job.WorkflowRun} (Pipeline) builds,
+ * this class walks the flow graph to locate the node that originally threw the
+ * error, reads a limited number of log lines from that step, and records a
+ * node-specific URL that can be used to navigate to the failure location.
+ * When no failing step log can be found, or when the build is not a pipeline,
+ * it falls back to the standard build console log.
+ *
+ * If the optional {@code pipeline-graph-view} plugin is installed, the
+ * generated URL is compatible with its overview page so that consumers can
+ * deep-link directly into the failing node from error explanations.
+ */
+public class PipelineLogExtractor {
+
+ private static final Logger LOGGER = Logger.getLogger(PipelineLogExtractor.class.getName());
+ public static final String URL_NAME = "pipeline-overview";
+ private boolean isGraphViewPluginAvailable = false;
+ private transient String url;
+ private transient Run, ?> run;
+ private int maxLines;
+
+
+
+ /**
+ * Reads the provided log text and returns at most the last {@code maxLines} lines.
+ *
+ * The entire log is streamed into memory, Jenkins {@link ConsoleNote} annotations are stripped,
+ * and a sliding window is maintained over the lines: when the number of buffered lines reaches
+ * {@code maxLines}, the oldest line is removed before adding the next one. This ensures that
+ * only the most recent {@code maxLines} lines are retained.
+ *
+ * Line terminators ({@code \n} and {@code \r}) are removed from each returned line. If no log
+ * content is available or an error occurs while reading, an empty list is returned.
+ *
+ * @param logText the annotated log text associated with a {@link FlowNode}
+ * @param maxLines the maximum number of trailing log lines to return
+ * @return a list containing up to the last {@code maxLines} lines of the log, or an empty list
+ * if the log is empty or an error occurs
+ */
+ private List readLimitedLog(AnnotatedLargeText extends FlowNode> logText,
+ int maxLines) {
+ StringWriter writer = new StringWriter();
+ try {
+ long offset = logText.writeLogTo(0, writer);
+ if (offset <= 0) {
+ return Collections.emptyList();
+ }
+ String cleanLog = ConsoleNote.removeNotes(writer.toString());
+ BufferedReader reader = new BufferedReader(new StringReader(cleanLog));
+ LinkedList queue = new LinkedList<>();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (queue.size() >= maxLines) {
+ queue.removeFirst();
+ }
+
+ queue.add(line);
+ }
+ return new ArrayList<>(queue);
+ } catch (IOException e) {
+ LOGGER.severe("Unable to serialize the flow node log: " + e.getMessage());
+ }
+ return Collections.emptyList();
+ }
+
+ /**
+ * Extracts the log output of the specific step that caused the pipeline failure.
+ *
+ * @return A non-null list of log lines for the failed step, or the overall build log if
+ * no failed step with a log is found.
+ * @throws IOException if there is an error reading the build logs.
+ */
+ public List getFailedStepLog() throws IOException {
+
+ if (this.run instanceof WorkflowRun) {
+ FlowExecution execution = ((WorkflowRun) this.run).getExecution();
+
+ FlowGraphWalker walker = new FlowGraphWalker(execution);
+ for (FlowNode node : walker) {
+ ErrorAction errorAction = node.getAction(ErrorAction.class);
+ if (errorAction != null) {
+ FlowNode nodeThatThrewException = ErrorAction.findOrigin(errorAction.getError(), execution);
+ if (nodeThatThrewException == null) {
+ continue;
+ }
+ LogAction logAction = nodeThatThrewException.getAction(LogAction.class);
+ if (logAction != null) {
+ AnnotatedLargeText extends FlowNode> logText = logAction.getLogText();
+ List result = readLimitedLog(logText, this.maxLines);
+ if (result == null || result.isEmpty())
+ {
+ continue;
+ }
+ setUrl(nodeThatThrewException.getId());
+ return result;
+ }
+ }
+ }
+ }
+ /* Reference to pipeline overview or console output */
+ setUrl("0");
+ return run.getLog(maxLines);
+ }
+
+ private void setUrl(String node)
+ {
+ String rootUrl = Jenkins.get().getRootUrl();
+ if (isGraphViewPluginAvailable) {
+ url = rootUrl + run.getUrl() + URL_NAME + "?selected-node=" + node;
+ } else {
+ url = rootUrl + run.getUrl() + "console";
+ }
+ }
+
+ /**
+ * Returns the URL associated with the extracted log.
+ *
+ * When {@link #getFailedStepLog()} finds a failed pipeline step with an attached log and the
+ * {@code pipeline-graph-view} plugin is available, this will point to the pipeline overview page with the
+ * failing node preselected. Otherwise, it falls back to the build's console output URL.
+ *
+ *
+ * @return the Jenkins URL for either the pipeline overview of the failing step or the build console output,
+ * or {@code null} if {@link #getFailedStepLog()} has not been invoked successfully.
+ */
+ public String getUrl() {
+ return this.url;
+ }
+
+ public PipelineLogExtractor(Run, ?> run, int maxLines)
+ {
+ this.run = run;
+ this.maxLines = maxLines;
+ if (Jenkins.get().getPlugin("pipeline-graph-view") != null) {
+ isGraphViewPluginAvailable = true;
+ }
+ }
+}
diff --git a/src/main/resources/io/jenkins/plugins/explain_error/ConsolePageDecorator/footer.jelly b/src/main/resources/io/jenkins/plugins/explain_error/ConsolePageDecorator/footer.jelly
index c9f7c63..0f32fcf 100644
--- a/src/main/resources/io/jenkins/plugins/explain_error/ConsolePageDecorator/footer.jelly
+++ b/src/main/resources/io/jenkins/plugins/explain_error/ConsolePageDecorator/footer.jelly
@@ -1,5 +1,5 @@
-
+
@@ -24,6 +24,12 @@
${hasExplanation ? existingExplanation.explanation : ''}
+
diff --git a/src/main/resources/io/jenkins/plugins/explain_error/ErrorExplanationAction/index.jelly b/src/main/resources/io/jenkins/plugins/explain_error/ErrorExplanationAction/index.jelly
index 225280e..1ebda88 100644
--- a/src/main/resources/io/jenkins/plugins/explain_error/ErrorExplanationAction/index.jelly
+++ b/src/main/resources/io/jenkins/plugins/explain_error/ErrorExplanationAction/index.jelly
@@ -7,6 +7,12 @@
${it.explanation}
+
diff --git a/src/main/webapp/js/explain-error-footer.js b/src/main/webapp/js/explain-error-footer.js
index 01bc698..1ceeb1c 100644
--- a/src/main/webapp/js/explain-error-footer.js
+++ b/src/main/webapp/js/explain-error-footer.js
@@ -187,7 +187,7 @@ function sendExplainRequest(forceNew = false) {
.then(json => {
try {
if (json.status == "success") {
- showErrorExplanation(json.message, json.providerName);
+ showErrorExplanation(json.message, json.providerName, json.url);
}
else {
if (json.status == "warning") {
@@ -207,16 +207,19 @@ function sendExplainRequest(forceNew = false) {
});
}
-function showErrorExplanation(message, providerName) {
+function showErrorExplanation(message, providerName, url) {
const container = document.getElementById('explain-error-container');
const spinner = document.getElementById('explain-error-spinner');
const content = document.getElementById('explain-error-content');
+ const urlString = document.getElementById('explain-error-url');
const cardTitle = document.querySelector('.jenkins-card__title');
cardTitle.firstChild.textContent = `AI Error Explanation (${providerName})`;
container.classList.remove('jenkins-hidden');
spinner.classList.add('jenkins-hidden');
content.textContent = message;
content.classList.remove('jenkins-hidden');
+ urlString.classList.remove('jenkins-hidden');
+ urlString.href = url;
}
function showSpinner() {
diff --git a/src/test/java/io/jenkins/plugins/explain_error/ConsolePageDecoratorTest.java b/src/test/java/io/jenkins/plugins/explain_error/ConsolePageDecoratorTest.java
index 9dc13c1..a05777c 100644
--- a/src/test/java/io/jenkins/plugins/explain_error/ConsolePageDecoratorTest.java
+++ b/src/test/java/io/jenkins/plugins/explain_error/ConsolePageDecoratorTest.java
@@ -171,7 +171,8 @@ void testContainerIsInjectedWhenEnabled() throws Exception {
void testContainerIsInjectedWithExistingExplanationWhenDisabled() throws Exception {
FreeStyleProject project = rule.createFreeStyleProject("test");
FreeStyleBuild build = rule.buildAndAssertSuccess(project);
- build.addAction(new ErrorExplanationAction("This is a test explanation of the error", "ERROR: Build failed\nFinished: FAILURE", "Ollama" ));
+ build.addAction(new ErrorExplanationAction("This is a test explanation of the error", "",
+ "ERROR: Build failed\nFinished: FAILURE", "Ollama" ));
build.save();
config.setEnableExplanation(false);
diff --git a/src/test/java/io/jenkins/plugins/explain_error/ErrorExplainerTest.java b/src/test/java/io/jenkins/plugins/explain_error/ErrorExplainerTest.java
index 401f0d4..bb5e7f2 100644
--- a/src/test/java/io/jenkins/plugins/explain_error/ErrorExplainerTest.java
+++ b/src/test/java/io/jenkins/plugins/explain_error/ErrorExplainerTest.java
@@ -69,27 +69,27 @@ void testErrorExplainerTextMethods(JenkinsRule jenkins) throws Exception {
// Test with valid error text (will fail with API but should not throw exception)
assertDoesNotThrow(() -> {
- ErrorExplanationAction action = errorExplainer.explainErrorText("Build failed", build);
+ ErrorExplanationAction action = errorExplainer.explainErrorText("Build failed", "", build);
assertEquals("Summary: Request was successful\n", action.getExplanation());
});
// Test with null input
ExplanationException e = assertThrows(ExplanationException.class, () -> {
- errorExplainer.explainErrorText(null, build);
+ errorExplainer.explainErrorText(null, "", build);
// Should return error message about no error text provided
});
assertEquals("No error logs provided for explanation.", e.getMessage());
// Test with empty input
e = assertThrows(ExplanationException.class, () -> {
- errorExplainer.explainErrorText("", build);
+ errorExplainer.explainErrorText("", "", build);
// Should return error message about no error text provided
});
assertEquals("No error logs provided for explanation.", e.getMessage());
// Test with whitespace only input
e = assertThrows(ExplanationException.class, () -> {
- errorExplainer.explainErrorText(" ", build);
+ errorExplainer.explainErrorText(" ", "", build);
// Should return error message about no error text provided
});
assertEquals("No error logs provided for explanation.", e.getMessage());
@@ -97,7 +97,7 @@ void testErrorExplainerTextMethods(JenkinsRule jenkins) throws Exception {
// Test with invalid config input
e = assertThrows(ExplanationException.class, () -> {
provider.setApiKey(null);
- errorExplainer.explainErrorText("Build Failed", build);
+ errorExplainer.explainErrorText("Build Failed", "", build);
});
assertEquals("The provider is not properly configured.", e.getMessage());
@@ -105,7 +105,7 @@ void testErrorExplainerTextMethods(JenkinsRule jenkins) throws Exception {
e = assertThrows(ExplanationException.class, () -> {
provider.setApiKey(Secret.fromString("test-key"));
provider.setThrowError(true);
- errorExplainer.explainErrorText("Build failed", build);
+ errorExplainer.explainErrorText("Build failed", "", build);
});
assertEquals("API request failed: Request failed.", e.getMessage());
}
diff --git a/src/test/java/io/jenkins/plugins/explain_error/ErrorExplanationActionTest.java b/src/test/java/io/jenkins/plugins/explain_error/ErrorExplanationActionTest.java
index 3c91f3f..4351559 100644
--- a/src/test/java/io/jenkins/plugins/explain_error/ErrorExplanationActionTest.java
+++ b/src/test/java/io/jenkins/plugins/explain_error/ErrorExplanationActionTest.java
@@ -20,7 +20,7 @@ class ErrorExplanationActionTest {
void setUp() {
testExplanation = "This is a test explanation of the error";
testErrorLogs = "ERROR: Build failed\nFAILED: Compilation error";
- action = new ErrorExplanationAction(testExplanation, testErrorLogs, "Ollama");
+ action = new ErrorExplanationAction(testExplanation, "", testErrorLogs, "Ollama");
}
@Test
@@ -89,21 +89,21 @@ void testRunAction2Interface(JenkinsRule jenkins) throws Exception {
@Test
void testWithNullExplanation() {
- ErrorExplanationAction nullAction = new ErrorExplanationAction(null, testErrorLogs, "Ollama");
+ ErrorExplanationAction nullAction = new ErrorExplanationAction(null, "", testErrorLogs, "Ollama");
assertNull(nullAction.getExplanation());
assertEquals(testErrorLogs, nullAction.getOriginalErrorLogs());
}
@Test
void testWithNullErrorLogs() {
- ErrorExplanationAction nullAction = new ErrorExplanationAction(testExplanation, null, "Ollama");
+ ErrorExplanationAction nullAction = new ErrorExplanationAction(testExplanation, "", null, "Ollama");
assertEquals(testExplanation, nullAction.getExplanation());
assertNull(nullAction.getOriginalErrorLogs());
}
@Test
void testWithEmptyStrings() {
- ErrorExplanationAction emptyAction = new ErrorExplanationAction("", "", "Ollama");
+ ErrorExplanationAction emptyAction = new ErrorExplanationAction("", "", "", "Ollama");
assertEquals("", emptyAction.getExplanation());
assertEquals("", emptyAction.getOriginalErrorLogs());
}
@@ -115,7 +115,7 @@ void testWithLongExplanation() {
longExplanation.append("This is line ").append(i).append(" of a very long explanation.\n");
}
- ErrorExplanationAction longAction = new ErrorExplanationAction(longExplanation.toString(), testErrorLogs, "Ollama");
+ ErrorExplanationAction longAction = new ErrorExplanationAction(longExplanation.toString(), "", testErrorLogs, "Ollama");
assertEquals(longExplanation.toString(), longAction.getExplanation());
}
@@ -124,7 +124,7 @@ void testWithSpecialCharacters() {
String specialExplanation = "Error with special chars: <>&\"'\nUnicode: ñáéíóú 中文 العربية";
String specialErrorLogs = "ERROR: File 'test@#$%^&*().txt' not found";
- ErrorExplanationAction specialAction = new ErrorExplanationAction(specialExplanation, specialErrorLogs, "Ollama");
+ ErrorExplanationAction specialAction = new ErrorExplanationAction(specialExplanation, "", specialErrorLogs, "Ollama");
assertEquals(specialExplanation, specialAction.getExplanation());
assertEquals(specialErrorLogs, specialAction.getOriginalErrorLogs());
}
@@ -134,7 +134,7 @@ void testTimestampConsistency() throws InterruptedException {
long beforeCreation = System.currentTimeMillis();
Thread.sleep(10); // Small delay to ensure timestamp difference
- ErrorExplanationAction timedAction = new ErrorExplanationAction("test", "test", "Ollama");
+ ErrorExplanationAction timedAction = new ErrorExplanationAction("test", "", "test", "Ollama");
Thread.sleep(10); // Small delay to ensure timestamp difference
long afterCreation = System.currentTimeMillis();
@@ -147,27 +147,27 @@ void testTimestampConsistency() throws InterruptedException {
@Test
void testHasValidExplanation() {
// Test with valid explanation
- ErrorExplanationAction validAction = new ErrorExplanationAction("Valid explanation", "Error logs", "Ollama");
+ ErrorExplanationAction validAction = new ErrorExplanationAction("Valid explanation", "", "Error logs", "Ollama");
assertTrue(validAction.hasValidExplanation());
// Test with null explanation
- ErrorExplanationAction nullAction = new ErrorExplanationAction(null, "Error logs", "Ollama");
+ ErrorExplanationAction nullAction = new ErrorExplanationAction(null, "", "Error logs", "Ollama");
assertFalse(nullAction.hasValidExplanation());
// Test with empty explanation
- ErrorExplanationAction emptyAction = new ErrorExplanationAction("", "Error logs", "Ollama");
+ ErrorExplanationAction emptyAction = new ErrorExplanationAction("", "", "Error logs", "Ollama");
assertFalse(emptyAction.hasValidExplanation());
// Test with whitespace-only explanation
- ErrorExplanationAction whitespaceAction = new ErrorExplanationAction(" \n \t ", "Error logs", "Ollama");
+ ErrorExplanationAction whitespaceAction = new ErrorExplanationAction(" \n \t ", "", "Error logs", "Ollama");
assertFalse(whitespaceAction.hasValidExplanation());
// Test with explanation containing only spaces
- ErrorExplanationAction spacesAction = new ErrorExplanationAction(" ", "Error logs", "Ollama");
+ ErrorExplanationAction spacesAction = new ErrorExplanationAction(" ", "", "Error logs", "Ollama");
assertFalse(spacesAction.hasValidExplanation());
// Test with valid explanation containing whitespace
- ErrorExplanationAction validWithWhitespaceAction = new ErrorExplanationAction(" Valid explanation ", "Error logs", "Ollama");
+ ErrorExplanationAction validWithWhitespaceAction = new ErrorExplanationAction(" Valid explanation ", "", "Error logs", "Ollama");
assertTrue(validWithWhitespaceAction.hasValidExplanation());
}
}
diff --git a/src/test/java/io/jenkins/plugins/explain_error/provider/TestProvider.java b/src/test/java/io/jenkins/plugins/explain_error/provider/TestProvider.java
index b388df9..6641ae2 100644
--- a/src/test/java/io/jenkins/plugins/explain_error/provider/TestProvider.java
+++ b/src/test/java/io/jenkins/plugins/explain_error/provider/TestProvider.java
@@ -5,8 +5,6 @@
import hudson.util.Secret;
import io.jenkins.plugins.explain_error.JenkinsLogAnalysis;
-import java.util.List;
-
import org.jenkinsci.Symbol;
import org.kohsuke.stapler.DataBoundConstructor;