From e5a9e95cef896f4e84edda3c00aea78c0e31c837 Mon Sep 17 00:00:00 2001 From: Michael Trimarchi Date: Tue, 16 Dec 2025 09:49:43 +0100 Subject: [PATCH 1/4] Add log extraction from FlowNode instead the entire log In complex pipeline we can find the node that generate the error and just get that one back to the ErrorExplainer. Signed-off-by: Michael Trimarchi --- pom.xml | 16 ++-- .../ConsoleExplainErrorAction.java | 5 +- .../plugins/explain_error/ErrorExplainer.java | 3 +- .../explain_error/PipelineLogExtractor.java | 88 +++++++++++++++++++ 4 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 src/main/java/io/jenkins/plugins/explain_error/PipelineLogExtractor.java diff --git a/pom.xml b/pom.xml index 9ce46c5..f39b2ff 100644 --- a/pom.xml +++ b/pom.xml @@ -86,6 +86,16 @@ workflow-step-api + + org.jenkins-ci.plugins.workflow + workflow-api + + + + org.jenkins-ci.plugins.workflow + workflow-job + + io.jenkins.plugins @@ -99,12 +109,6 @@ test - - org.jenkins-ci.plugins.workflow - workflow-job - test - - org.jenkins-ci.plugins.workflow workflow-durable-task-step diff --git a/src/main/java/io/jenkins/plugins/explain_error/ConsoleExplainErrorAction.java b/src/main/java/io/jenkins/plugins/explain_error/ConsoleExplainErrorAction.java index 8c53156..f4b2249 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ConsoleExplainErrorAction.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ConsoleExplainErrorAction.java @@ -6,9 +6,12 @@ import jenkins.model.RunAction2; import java.io.IOException; import java.io.PrintWriter; +import java.util.List; import java.util.logging.Logger; import javax.servlet.ServletException; import net.sf.json.JSONObject; + +import org.jenkinsci.plugins.workflow.job.WorkflowRun; import org.kohsuke.stapler.StaplerRequest2; import org.kohsuke.stapler.StaplerResponse2; import org.kohsuke.stapler.interceptor.RequirePOST; @@ -86,7 +89,7 @@ public void doExplainConsoleError(StaplerRequest2 req, StaplerResponse2 rsp) thr } // Fetch the last N lines of the log - java.util.List logLines = run.getLog(maxLines); + List logLines = PipelineLogExtractor.getFailedStepLog(run, maxLines); String errorText = String.join("\n", logLines); ErrorExplainer explainer = new ErrorExplainer(); diff --git a/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java index 476fe11..370614f 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java @@ -11,6 +11,7 @@ import java.util.logging.Logger; import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; +import org.jenkinsci.plugins.workflow.job.WorkflowRun; /** * Service class responsible for explaining errors using AI. @@ -60,7 +61,7 @@ public void explainError(Run run, TaskListener listener, String logPattern } private String extractErrorLogs(Run run, String logPattern, int maxLines) throws IOException { - List logLines = run.getLog(maxLines); + List logLines = PipelineLogExtractor.getFailedStepLog(run, maxLines); if (StringUtils.isBlank(logPattern)) { // Return last few lines if no pattern specified 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..ad1c76b --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/PipelineLogExtractor.java @@ -0,0 +1,88 @@ +package io.jenkins.plugins.explain_error; + +import org.jenkinsci.plugins.workflow.job.WorkflowRun; + +import edu.umd.cs.findbugs.annotations.NonNull; + +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 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.List; + +public class PipelineLogExtractor { + + private static List readLimitedLog(AnnotatedLargeText 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(); + } + line = line.replace("\n", "").replace("\r", ""); + queue.add(line); + } + return new ArrayList<>(queue); + } catch (Exception e) { + e.printStackTrace(); + } + return Collections.emptyList(); + } + + /** + * Extracts the log output of the specific step that caused the pipeline failure. + * @param run The pipeline build + * @return The log text of the failed step, or null if no failed step with a log is found. + * @throws IOException + */ + public static List getFailedStepLog(@NonNull Run run, int maxLines) throws IOException { + + if (run instanceof WorkflowRun) { + FlowExecution execution = ((WorkflowRun) 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 logText = logAction.getLogText(); + List result = readLimitedLog(logText, maxLines); + if (result == null || result.isEmpty()) + { + continue; + } + return result; + } + } + } + } + + return run.getLog(maxLines); + } +} From 0f26ff70502bb3c2d557d7e61117e922e168cd62 Mon Sep 17 00:00:00 2001 From: Michael Trimarchi Date: Sat, 20 Dec 2025 12:53:37 +0100 Subject: [PATCH 2/4] src: Drop unused imports Signed-off-by: Michael Trimarchi --- .../io/jenkins/plugins/explain_error/ConsolePageDecorator.java | 1 - .../io/jenkins/plugins/explain_error/provider/TestProvider.java | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/explain_error/ConsolePageDecorator.java b/src/main/java/io/jenkins/plugins/explain_error/ConsolePageDecorator.java index 2fc45d2..abfac86 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ConsolePageDecorator.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ConsolePageDecorator.java @@ -5,7 +5,6 @@ import hudson.model.Run; import org.kohsuke.stapler.Ancestor; import org.kohsuke.stapler.Stapler; -import org.kohsuke.stapler.StaplerRequest2; /** * Page decorator to add "Explain Error" functionality to console output pages. 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; From 9194a122c2d8271f5688ad33a7e8d63a87d723c3 Mon Sep 17 00:00:00 2001 From: Michael Trimarchi Date: Sun, 21 Dec 2025 18:09:36 +0100 Subject: [PATCH 3/4] src: Add deep link to error log source in explanation UI This commit introduces a "View Full Report" button within the AI Error Explanation UI. This allows users to navigate directly from the AI summary to the specific location of the failure. Key changes: - Logic: Updated `PipelineLogExtractor` to generate a URL for the failed step. It attempts to construct a deep link to the `pipeline-graph-view` (e.g., `?selected-node=ID`) or falls back to the standard console output. - Dependencies: Added `io.jenkins.plugins:pipeline-graph-view` as an optional dependency to support node-specific linking. - UI: Added a "View Full Report" button in `footer.jelly` and `index.jelly` that links to the extracted URL. - JavaScript: Updated `explain-error-footer.js` to handle the URL payload in the JSON response. - Tests: Updated `ErrorExplainerTest`, `ErrorExplanationActionTest`, and `ConsolePageDecoratorTest` to match updated method signatures. Style and Icons are get from standard jenkins style and icon plugin, as suggested by Markus Winter Signed-off-by: Michael Trimarchi --- pom.xml | 11 +++ .../ConsoleExplainErrorAction.java | 10 ++- .../plugins/explain_error/ErrorExplainer.java | 13 ++-- .../explain_error/ErrorExplanationAction.java | 8 +- .../explain_error/PipelineLogExtractor.java | 75 ++++++++++++++++--- .../ConsolePageDecorator/footer.jelly | 8 +- .../ErrorExplanationAction/index.jelly | 6 ++ src/main/webapp/js/explain-error-footer.js | 7 +- .../ConsolePageDecoratorTest.java | 3 +- .../explain_error/ErrorExplainerTest.java | 12 +-- .../ErrorExplanationActionTest.java | 26 +++---- 11 files changed, 135 insertions(+), 44 deletions(-) diff --git a/pom.xml b/pom.xml index f39b2ff..b1b9749 100644 --- a/pom.xml +++ b/pom.xml @@ -102,6 +102,17 @@ commons-lang3-api + + io.jenkins.plugins + pipeline-graph-view + true + + + + io.jenkins.plugins + ionicons-api + + org.jenkins-ci.plugins.workflow diff --git a/src/main/java/io/jenkins/plugins/explain_error/ConsoleExplainErrorAction.java b/src/main/java/io/jenkins/plugins/explain_error/ConsoleExplainErrorAction.java index f4b2249..2898210 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ConsoleExplainErrorAction.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ConsoleExplainErrorAction.java @@ -11,7 +11,6 @@ import javax.servlet.ServletException; import net.sf.json.JSONObject; -import org.jenkinsci.plugins.workflow.job.WorkflowRun; import org.kohsuke.stapler.StaplerRequest2; import org.kohsuke.stapler.StaplerResponse2; import org.kohsuke.stapler.interceptor.RequirePOST; @@ -25,6 +24,7 @@ public class ConsoleExplainErrorAction implements RunAction2 { private static final Logger LOGGER = Logger.getLogger(ConsoleExplainErrorAction.class.getName()); private transient Run run; + private String urlString; public ConsoleExplainErrorAction(Run run) { this.run = run; @@ -89,12 +89,15 @@ public void doExplainConsoleError(StaplerRequest2 req, StaplerResponse2 rsp) thr } // Fetch the last N lines of the log - List logLines = PipelineLogExtractor.getFailedStepLog(run, maxLines); + PipelineLogExtractor logExtractor = new PipelineLogExtractor(run, maxLines); + List logLines = logExtractor.getFailedStepLog(); + this.urlString = logExtractor.getUrl(); + String errorText = String.join("\n", logLines); ErrorExplainer explainer = new ErrorExplainer(); try { - ErrorExplanationAction action = explainer.explainErrorText(errorText, run); + ErrorExplanationAction action = explainer.explainErrorText(errorText, urlString, run); writeJsonResponse(rsp, "success", action.getProviderName(), action.getExplanation()); } catch (ExplanationException ee) { writeJsonResponse(rsp, ee.getLevel(), explainer.getProviderName(), ee.getMessage()); @@ -148,6 +151,7 @@ private void writeJsonResponse(StaplerResponse2 rsp, String status, String provi json.put("status", status); json.put("providerName", providerName); json.put("message", message); + json.put("url", urlString); writer.write(json.toString()); writer.flush(); } diff --git a/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java index 370614f..7e8a8c5 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java @@ -11,7 +11,6 @@ import java.util.logging.Logger; import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; -import org.jenkinsci.plugins.workflow.job.WorkflowRun; /** * Service class responsible for explaining errors using AI. @@ -19,6 +18,8 @@ public class ErrorExplainer { private String providerName; + private String urlString; + private static final Logger LOGGER = Logger.getLogger(ErrorExplainer.class.getName()); public String getProviderName() { @@ -46,7 +47,7 @@ public void explainError(Run run, TaskListener listener, String logPattern LOGGER.fine(jobInfo + " AI error explanation succeeded."); // Store explanation in build action - ErrorExplanationAction action = new ErrorExplanationAction(explanation, errorLogs, provider.getProviderName()); + ErrorExplanationAction action = new ErrorExplanationAction(explanation, urlString, errorLogs, provider.getProviderName()); run.addOrReplaceAction(action); } catch (ExplanationException ee) { listener.getLogger().println(ee.getMessage()); @@ -61,7 +62,9 @@ public void explainError(Run run, TaskListener listener, String logPattern } private String extractErrorLogs(Run run, String logPattern, int maxLines) throws IOException { - List logLines = PipelineLogExtractor.getFailedStepLog(run, maxLines); + PipelineLogExtractor logExtractor = new PipelineLogExtractor(run, maxLines); + List logLines = logExtractor.getFailedStepLog(); + this.urlString = logExtractor.getUrl(); if (StringUtils.isBlank(logPattern)) { // Return last few lines if no pattern specified @@ -84,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(); @@ -96,7 +99,7 @@ public ErrorExplanationAction explainErrorText(String errorText, @NonNull 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 index ad1c76b..8a65821 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/PipelineLogExtractor.java +++ b/src/main/java/io/jenkins/plugins/explain_error/PipelineLogExtractor.java @@ -2,8 +2,6 @@ import org.jenkinsci.plugins.workflow.job.WorkflowRun; -import edu.umd.cs.findbugs.annotations.NonNull; - import org.jenkinsci.plugins.workflow.flow.FlowExecution; import org.jenkinsci.plugins.workflow.graph.FlowNode; import org.jenkinsci.plugins.workflow.graph.FlowGraphWalker; @@ -12,6 +10,7 @@ import hudson.console.AnnotatedLargeText; import hudson.console.ConsoleNote; import hudson.model.Run; +import jenkins.model.Jenkins; import java.io.BufferedReader; import java.io.IOException; @@ -20,11 +19,37 @@ import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; +import java.util.logging.Logger; import java.util.List; public class PipelineLogExtractor { - private static List readLimitedLog(AnnotatedLargeText logText, + 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 logText, int maxLines) { StringWriter writer = new StringWriter(); try { @@ -44,22 +69,23 @@ private static List readLimitedLog(AnnotatedLargeText(queue); - } catch (Exception e) { - e.printStackTrace(); + } 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. - * @param run The pipeline build - * @return The log text of the failed step, or null if no failed step with a log is found. + * + * @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 */ - public static List getFailedStepLog(@NonNull Run run, int maxLines) throws IOException { + public List getFailedStepLog() throws IOException { - if (run instanceof WorkflowRun) { - FlowExecution execution = ((WorkflowRun) run).getExecution(); + if (this.run instanceof WorkflowRun) { + FlowExecution execution = ((WorkflowRun) this.run).getExecution(); FlowGraphWalker walker = new FlowGraphWalker(execution); for (FlowNode node : walker) { @@ -72,17 +98,42 @@ public static List getFailedStepLog(@NonNull Run run, int maxLine LogAction logAction = nodeThatThrewException.getAction(LogAction.class); if (logAction != null) { AnnotatedLargeText logText = logAction.getLogText(); - List result = readLimitedLog(logText, maxLines); + 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"; + } + } + + 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()); } } From 285f2d49ad5973520f4b260b9385e74f1dc7ff4b Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Sat, 3 Jan 2026 22:05:08 +0800 Subject: [PATCH 4/4] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../explain_error/PipelineLogExtractor.java | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/explain_error/PipelineLogExtractor.java b/src/main/java/io/jenkins/plugins/explain_error/PipelineLogExtractor.java index 8a65821..7bed19c 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/PipelineLogExtractor.java +++ b/src/main/java/io/jenkins/plugins/explain_error/PipelineLogExtractor.java @@ -22,6 +22,21 @@ 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()); @@ -65,7 +80,7 @@ private List readLimitedLog(AnnotatedLargeText logTe if (queue.size() >= maxLines) { queue.removeFirst(); } - line = line.replace("\n", "").replace("\r", ""); + queue.add(line); } return new ArrayList<>(queue); @@ -80,7 +95,7 @@ private List readLimitedLog(AnnotatedLargeText logTe * * @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 + * @throws IOException if there is an error reading the build logs. */ public List getFailedStepLog() throws IOException { @@ -124,6 +139,17 @@ private void setUrl(String node) } } + /** + * 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; }