Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -86,22 +86,37 @@
<artifactId>workflow-step-api</artifactId>
</dependency>

<dependency>
<groupId>org.jenkins-ci.plugins.workflow</groupId>
<artifactId>workflow-api</artifactId>
</dependency>

<dependency>
<groupId>org.jenkins-ci.plugins.workflow</groupId>
<artifactId>workflow-job</artifactId>
</dependency>

<!-- Commons Lang3 API -->
<dependency>
<groupId>io.jenkins.plugins</groupId>
<artifactId>commons-lang3-api</artifactId>
</dependency>

<!-- Needed only for testing -->
<dependency>
<groupId>org.jenkins-ci.plugins.workflow</groupId>
<artifactId>workflow-cps</artifactId>
<scope>test</scope>
<groupId>io.jenkins.plugins</groupId>
<artifactId>pipeline-graph-view</artifactId>
<optional>true</optional>
</dependency>

<dependency>
<groupId>io.jenkins.plugins</groupId>
<artifactId>ionicons-api</artifactId>
</dependency>

<!-- Needed only for testing -->
<dependency>
<groupId>org.jenkins-ci.plugins.workflow</groupId>
<artifactId>workflow-job</artifactId>
<artifactId>workflow-cps</artifactId>
<scope>test</scope>
</dependency>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -86,12 +89,15 @@ public void doExplainConsoleError(StaplerRequest2 req, StaplerResponse2 rsp) thr
}

// Fetch the last N lines of the log
java.util.List<String> logLines = run.getLog(maxLines);
PipelineLogExtractor logExtractor = new PipelineLogExtractor(run, maxLines);
List<String> 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());
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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());
Expand All @@ -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<String> logLines = run.getLog(maxLines);
PipelineLogExtractor logExtractor = new PipelineLogExtractor(run, maxLines);
List<String> logLines = logExtractor.getFailedStepLog();
this.urlString = logExtractor.getUrl();

if (StringUtils.isBlank(logPattern)) {
// Return last few lines if no pattern specified
Expand All @@ -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();
Expand All @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -63,6 +65,10 @@ public String getProviderName() {
return providerName;
}

public String getUrlString() {
return urlString;
}

@Override
public void onAttached(Run<?, ?> r) {
this.run = r;
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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<String> 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<String> 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<String> 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<String> result = readLimitedLog(logText, this.maxLines);
if (result == null || result.isEmpty())
{
continue;
}
setUrl(nodeThatThrewException.getId());
return result;
}
Comment on lines +105 to +123
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The execution obtained from ((WorkflowRun) this.run).getExecution() can be null if the workflow has not started yet or has been cancelled. This would cause a NullPointerException when creating the FlowGraphWalker on line 69. Add a null check for execution before proceeding.

Suggested change
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<String> result = readLimitedLog(logText, this.maxLines);
if (result == null || result.isEmpty())
{
continue;
}
setUrl(nodeThatThrewException.getId());
return result;
}
if (execution != null) {
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<String> result = readLimitedLog(logText, this.maxLines);
if (result == null || result.isEmpty())
{
continue;
}
setUrl(nodeThatThrewException.getId());
return result;
}
}

Copilot uses AI. Check for mistakes.
}
}
}
/* 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.
* <p>
* 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.
* </p>
*
* @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;
}
}
Comment on lines +157 to +164
Copy link

Copilot AI Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing Javadoc for the constructor. Public constructors should document their parameters and purpose.

Copilot uses AI. Check for mistakes.
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:l="/lib/layout">
<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:l="/lib/layout">
<j:set var="active" value="${it.pluginActive}" />
<j:if test="${active}">
<j:set var="enabled" value="${it.explainErrorEnabled}" />
Expand All @@ -24,6 +24,12 @@
<l:spinner text="Analyzing error logs..."/>
</div>
<pre id="explain-error-content" class="jenkins-!-margin-bottom-0 ${hasExplanation?'':'jenkins-hidden'}">${hasExplanation ? existingExplanation.explanation : ''}</pre>
<div class="jenkins-!-margin-top-3">
<a id="explain-error-url" href="${hasExplanation ? existingExplanation.urlString: ''}" class="jenkins-button jenkins-!-destructive-color ${hasExplanation?'':'jenkins-hidden'}" target="_self" >
<l:icon src="symbol-warning-outline plugin-ionicons-api" class="icon-md" />
View failure output
</a>
</div>
</l:card>
</div>
</j:if>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@

<l:card title="Generated on: ${it.formattedTimestamp}">
<pre style="white-space: pre-wrap; word-wrap: break-word;" class="jenkins-!-margin-bottom-0">${it.explanation}</pre>
<div class="jenkins-!-margin-top-3">
<a id="explain-error-url" href="${it.urlString}" class="jenkins-button jenkins-!-destructive-color" target="_self" >
<l:icon src="symbol-warning-outline plugin-ionicons-api" class="icon-md" />
View failure output
</a>
</div>
</l:card>
</l:main-panel>
</l:layout>
Expand Down
Loading
Loading