diff --git a/pom.xml b/pom.xml index 9ce46c5..b1b9749 100644 --- a/pom.xml +++ b/pom.xml @@ -86,22 +86,37 @@ workflow-step-api + + org.jenkins-ci.plugins.workflow + workflow-api + + + + org.jenkins-ci.plugins.workflow + workflow-job + + io.jenkins.plugins commons-lang3-api - - org.jenkins-ci.plugins.workflow - workflow-cps - test + io.jenkins.plugins + pipeline-graph-view + true + + + + io.jenkins.plugins + ionicons-api + org.jenkins-ci.plugins.workflow - workflow-job + workflow-cps test 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..2898210 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,11 @@ 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.kohsuke.stapler.StaplerRequest2; import org.kohsuke.stapler.StaplerResponse2; import org.kohsuke.stapler.interceptor.RequirePOST; @@ -22,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; @@ -86,12 +89,15 @@ public void doExplainConsoleError(StaplerRequest2 req, StaplerResponse2 rsp) thr } // Fetch the last N lines of the log - java.util.List logLines = run.getLog(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()); @@ -145,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/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/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java index 476fe11..7e8a8c5 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java @@ -18,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() { @@ -45,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()); @@ -60,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 = run.getLog(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 @@ -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 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 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 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;