diff --git a/src/main/java/io/jenkins/plugins/explain_error/GraphViewExplainErrorAction.java b/src/main/java/io/jenkins/plugins/explain_error/GraphViewExplainErrorAction.java new file mode 100644 index 00000000..149cf10e --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/GraphViewExplainErrorAction.java @@ -0,0 +1,313 @@ +package io.jenkins.plugins.explain_error; + +import com.google.common.annotations.VisibleForTesting; +import hudson.model.Result; +import hudson.model.Run; +import io.jenkins.plugins.explain_error.provider.BaseAIProvider; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.List; +import java.util.logging.Logger; +import jenkins.model.Jenkins; +import jenkins.model.RunAction2; +import net.sf.json.JSONObject; +import org.jenkinsci.plugins.workflow.actions.ErrorAction; +import org.jenkinsci.plugins.workflow.actions.WarningAction; +import org.jenkinsci.plugins.workflow.flow.FlowExecution; +import org.jenkinsci.plugins.workflow.graph.FlowGraphWalker; +import org.jenkinsci.plugins.workflow.graph.FlowNode; +import org.jenkinsci.plugins.workflow.job.WorkflowRun; +import org.kohsuke.stapler.StaplerRequest2; +import org.kohsuke.stapler.StaplerResponse2; +import org.kohsuke.stapler.interceptor.RequirePOST; + +/** + * Action for the Pipeline Graph View integration. + * Provides AJAX endpoints to check node status and explain errors + * for a specific selected node in the Pipeline Graph View. + */ +public class GraphViewExplainErrorAction implements RunAction2 { + + private static final Logger LOGGER = Logger.getLogger(GraphViewExplainErrorAction.class.getName()); + + private transient Run run; + private String urlString; + + public GraphViewExplainErrorAction(Run run) { + this.run = run; + } + + @Override + public void onAttached(Run r) { + this.run = r; + } + + @Override + public void onLoad(Run r) { + this.run = r; + } + + @Override + public String getIconFileName() { + return null; + } + + @Override + public String getDisplayName() { + return null; + } + + @Override + public String getUrlName() { + return "graph-explain-error"; + } + + /** + * AJAX endpoint to check build status. + * Returns JSON with buildingStatus: 0 = SUCCESS, 1 = RUNNING, 2 = FINISHED and FAILURE. + */ + @RequirePOST + public void doCheckBuildStatus(StaplerRequest2 req, StaplerResponse2 rsp) { + try { + run.checkPermission(hudson.model.Item.READ); + + int buildingStatus = run.isBuilding() ? 1 : 0; + + if (buildingStatus == 0) { + Result result = run.getResult(); + if (result == Result.SUCCESS) { + buildingStatus = 0; + } else { + buildingStatus = 2; + } + } + + rsp.setContentType("application/json"); + rsp.setCharacterEncoding("UTF-8"); + PrintWriter writer = rsp.getWriter(); + writer.write("{\"buildingStatus\": " + buildingStatus + "}"); + writer.flush(); + } catch (Exception e) { + LOGGER.warning("Error checking build status: " + e.getMessage()); + rsp.setStatus(500); + } + } + + /** + * AJAX endpoint to check whether a specific flow node is a failed node. + * Returns {@code isFailed: true} if the node (or any of its descendants) + * has an {@link ErrorAction} or a {@link WarningAction} with result + * {@link Result#FAILURE}. + */ + @RequirePOST + public void doCheckNodeStatus(StaplerRequest2 req, StaplerResponse2 rsp) { + try { + run.checkPermission(hudson.model.Item.READ); + + String nodeId = req.getParameter("nodeId"); + boolean isFailed = isFailedNode(nodeId); + + rsp.setContentType("application/json"); + rsp.setCharacterEncoding("UTF-8"); + PrintWriter writer = rsp.getWriter(); + + JSONObject json = new JSONObject(); + json.put("isFailed", isFailed); + writer.write(json.toString()); + writer.flush(); + } catch (Exception e) { + LOGGER.warning("Error checking node status: " + e.getMessage()); + rsp.setStatus(500); + } + } + + /** + * AJAX endpoint to explain an error for a specific selected node. + * First checks if an existing {@link ErrorExplanationAction} is already + * present (cache hit). Otherwise extracts logs from the specified node + * and calls the AI provider. + */ + @RequirePOST + public void doExplainNodeError(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException { + long startTimeNanos = System.nanoTime(); + try { + run.checkPermission(hudson.model.Item.READ); + + GlobalConfigurationImpl config = GlobalConfigurationImpl.get(); + if (!config.isEnableExplanation()) { + BaseAIProvider provider = config.getAiProvider(); + recordUsage(UsageEvent.Result.DISABLED, + provider != null ? provider.getProviderName() : null, + provider != null ? provider.getModel() : null, + startTimeNanos, 0); + writeJsonResponse(rsp, "warning", "Unknown", + "AI error explanation is disabled in global configuration."); + return; + } + + String nodeId = req.getParameter("nodeId"); + if (nodeId == null || nodeId.isBlank()) { + writeJsonResponse(rsp, "error", "Unknown", "No node selected."); + return; + } + + // Check if user wants to force a new explanation + boolean forceNew = "true".equals(req.getParameter("forceNew")); + + // Check if an explanation already exists + ErrorExplanationAction existingAction = run.getAction(ErrorExplanationAction.class); + if (!forceNew && existingAction != null && existingAction.hasValidExplanation()) { + this.urlString = existingAction.getUrlString(); + recordUsage(UsageEvent.Result.CACHE_HIT, existingAction.getProviderName(), + existingAction.getProviderModel(), startTimeNanos, + existingAction.getInputLogLineCount()); + writeJsonResponse(rsp, "success", existingAction.getProviderName(), + createCachedResponse(existingAction.getExplanation())); + return; + } + + // Extract logs from the selected node + PipelineLogExtractor logExtractor = new PipelineLogExtractor(run, 200, + Jenkins.getAuthentication2(), false, null); + List logLines = logExtractor.extractNodeLog(nodeId); + this.urlString = logExtractor.getUrl(); + + if (logLines.isEmpty()) { + writeJsonResponse(rsp, "error", "Unknown", + "No log output found for the selected node."); + return; + } + + String errorText = String.join("\n", logLines); + + ErrorExplainer explainer = new ErrorExplainer(); + try { + 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()); + } + } catch (Exception e) { + LOGGER.severe("Error explaining node error: " + e.getMessage()); + writeJsonResponse(rsp, "error", "Unknown", "Error: " + e.getMessage()); + } + } + + /** + * Determines whether a flow node (or any node it encloses) represents + * a failure. + *

+ * A node is considered failed if: + *

+ * Containment is determined via {@link FlowNode#getEnclosingBlocks()} + * rather than the parent chain: parents link to execution-order + * predecessors, so following them would wrongly mark every node that ran + * before the failure as failed. + * + * @param nodeId the flow node ID to check + * @return {@code true} if the node or a node it encloses failed + */ + @VisibleForTesting + boolean isFailedNode(String nodeId) { + if (nodeId == null || nodeId.isBlank()) { + return false; + } + if (!(run instanceof WorkflowRun)) { + return false; + } + FlowExecution execution = ((WorkflowRun) run).getExecution(); + if (execution == null) { + return false; + } + + FlowNode targetNode = findNode(execution, nodeId); + if (targetNode == null) { + return false; + } + + if (isFailure(targetNode)) { + return true; + } + + FlowGraphWalker walker = new FlowGraphWalker(execution); + for (FlowNode node : walker) { + if (isFailure(node) && isEnclosedBy(node, nodeId)) { + return true; + } + } + return false; + } + + private static FlowNode findNode(FlowExecution execution, String nodeId) { + for (FlowNode node : new FlowGraphWalker(execution)) { + if (node.getId().equals(nodeId)) { + return node; + } + } + return null; + } + + private static boolean isFailure(FlowNode node) { + if (node.getError() != null) { + return true; + } + WarningAction warn = node.getAction(WarningAction.class); + return warn != null && warn.getResult() == Result.FAILURE; + } + + private static boolean isEnclosedBy(FlowNode node, String blockId) { + for (FlowNode enclosing : node.getEnclosingBlocks()) { + if (enclosing.getId().equals(blockId)) { + return true; + } + } + return false; + } + + private void writeJsonResponse(StaplerResponse2 rsp, String status, String providerName, + String message) throws IOException { + rsp.setContentType("application/json"); + rsp.setCharacterEncoding("UTF-8"); + PrintWriter writer = rsp.getWriter(); + + JSONObject json = new JSONObject(); + json.put("status", status); + json.put("providerName", providerName); + json.put("message", message); + json.put("url", urlString); + writer.write(json.toString()); + writer.flush(); + } + + /** + * Create a response indicating this is a cached result. + */ + @VisibleForTesting + String createCachedResponse(String explanation) { + return explanation + + "\n\n[Note: This is a previously generated explanation. " + + "Use the 'Generate New' option to create a new one.]"; + } + + public Run getRun() { + return run; + } + + private void recordUsage(UsageEvent.Result result, String providerName, String model, + long startTimeNanos, int inputLogLineCount) { + UsageRecorders.get().record(new UsageEvent( + System.currentTimeMillis(), + UsageEvent.EntryPoint.CONSOLE_ACTION, + result, + providerName, + model, + Math.max(0L, (System.nanoTime() - startTimeNanos) / 1_000_000L), + inputLogLineCount, + false)); + } +} diff --git a/src/main/java/io/jenkins/plugins/explain_error/GraphViewExplainErrorActionFactory.java b/src/main/java/io/jenkins/plugins/explain_error/GraphViewExplainErrorActionFactory.java new file mode 100644 index 00000000..e1ce40a4 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/GraphViewExplainErrorActionFactory.java @@ -0,0 +1,46 @@ +package io.jenkins.plugins.explain_error; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.model.Action; +import hudson.model.Run; +import java.util.Collection; +import java.util.Collections; +import java.util.logging.Logger; +import jenkins.model.Jenkins; +import jenkins.model.TransientActionFactory; + +/** + * TransientActionFactory to dynamically inject + * {@link GraphViewExplainErrorAction} into all runs when the + * {@code pipeline-graph-view} plugin is installed. + */ +@Extension +public class GraphViewExplainErrorActionFactory extends TransientActionFactory> { + + private static final Logger LOGGER = Logger.getLogger( + GraphViewExplainErrorActionFactory.class.getName()); + + @Override + @SuppressWarnings("unchecked") + public Class> type() { + return (Class>) (Class) Run.class; + } + + @NonNull + @Override + public Collection createFor(@NonNull Run run) { + try { + // Only inject when pipeline-graph-view plugin is installed + if (Jenkins.get().getPlugin("pipeline-graph-view") == null) { + return Collections.emptyList(); + } + GraphViewExplainErrorAction action = new GraphViewExplainErrorAction(run); + return Collections.singletonList(action); + } catch (Exception e) { + LOGGER.severe("Failed to create GraphViewExplainErrorAction for run: " + + run.getFullDisplayName() + ". Error: " + e.getMessage()); + return Collections.emptyList(); + } + } +} diff --git a/src/main/java/io/jenkins/plugins/explain_error/PipelineGraphViewDecorator.java b/src/main/java/io/jenkins/plugins/explain_error/PipelineGraphViewDecorator.java new file mode 100644 index 00000000..0120831a --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/PipelineGraphViewDecorator.java @@ -0,0 +1,84 @@ +package io.jenkins.plugins.explain_error; + +import hudson.Extension; +import hudson.model.PageDecorator; +import hudson.model.Run; +import jenkins.model.Jenkins; +import org.kohsuke.stapler.Ancestor; +import org.kohsuke.stapler.Stapler; +import org.kohsuke.stapler.StaplerRequest2; + +/** + * Page decorator to add "Explain Error" functionality to Pipeline Graph View pages. + * Injects JavaScript that monitors node selection and provides an + * "Explain Error" button for failed nodes. + */ +@Extension +public class PipelineGraphViewDecorator extends PageDecorator { + + public PipelineGraphViewDecorator() { + super(); + } + + /** + * Returns {@code true} when the explain-error plugin is configured + * and enabled. + */ + public boolean isExplainErrorEnabled() { + GlobalConfigurationImpl config = GlobalConfigurationImpl.get(); + + if (!config.isEnableExplanation()) { + return false; + } + + if (config.getAiProvider() == null) { + return false; + } + + return !config.getAiProvider().isNotValid(null); + } + + public String getProviderName() { + if (GlobalConfigurationImpl.get().getAiProvider() == null) { + return null; + } + return GlobalConfigurationImpl.get().getAiProvider().getProviderName(); + } + + /** + * Checks whether the current request is on a Pipeline Graph View page. + * Only active when the {@code pipeline-graph-view} plugin is installed + * and the URL matches the graph view pattern. + */ + public boolean isPluginActive() { + if (Jenkins.get().getPlugin("pipeline-graph-view") == null) { + return false; + } + StaplerRequest2 req = Stapler.getCurrentRequest2(); + if (req == null) { + return false; + } + String uri = req.getRequestURI(); + // Pipeline Graph View renders as a Tab at e.g. /job/xxx/1/stages/ + // The trailing slash is part of the URL; also handle query params + return uri.matches(".*/stages(/.*)?(\\?.*)?$"); + } + + public String getRunUrl() { + Ancestor ancestor = Stapler.getCurrentRequest2().findAncestor(Run.class); + if (ancestor != null && ancestor.getObject() instanceof Run run) { + return run.getUrl(); + } else { + return null; + } + } + + public ErrorExplanationAction getExistingExplanation() { + Ancestor ancestor = Stapler.getCurrentRequest2().findAncestor(Run.class); + if (ancestor != null && ancestor.getObject() instanceof Run run) { + return run.getAction(ErrorExplanationAction.class); + } else { + return null; + } + } +} 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 4d3232b0..6a7baf72 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/PipelineLogExtractor.java +++ b/src/main/java/io/jenkins/plugins/explain_error/PipelineLogExtractor.java @@ -6,6 +6,7 @@ import org.jenkinsci.plugins.workflow.job.WorkflowRun; import org.jenkinsci.plugins.workflow.flow.FlowExecution; +import org.jenkinsci.plugins.workflow.graph.BlockStartNode; import org.jenkinsci.plugins.workflow.graph.FlowNode; import org.jenkinsci.plugins.workflow.graph.FlowGraphWalker; import org.jenkinsci.plugins.workflow.actions.ErrorAction; @@ -318,6 +319,129 @@ public List getFailedStepLog() throws IOException { return extractFailedStepLog().logLines(); } + /** + * Extracts log lines from a specific flow node identified by its node ID. + * Used by the Pipeline Graph View integration to extract logs for the + * node that the user selected. + *

+ * When the target node itself has no {@link LogAction}: + *

    + *
  • For a block node (e.g. a stage selected in the graph view), the + * log of the failing node enclosed by the block is used — + * the failure lives inside the block, not before it.
  • + *
  • For a step node, the immediate parent is checked to support the + * {@code catchError + sh(returnStatus:true) + error()} pattern.
  • + *
+ * + * @param nodeId the flow node ID to extract logs from + * @return the log lines for the specified node, or an empty list if the + * node is not found, is not a {@link WorkflowRun}, or has no log + * @throws IOException if there is an error reading the log + */ + public List extractNodeLog(String nodeId) throws IOException { + if (!(run instanceof WorkflowRun)) { + return Collections.emptyList(); + } + FlowExecution execution = ((WorkflowRun) run).getExecution(); + if (execution == null) { + return Collections.emptyList(); + } + + FlowNode target = null; + for (FlowNode node : new FlowGraphWalker(execution)) { + if (node.getId().equals(nodeId)) { + target = node; + break; + } + } + if (target == null) { + return Collections.emptyList(); + } + + FlowNode logNode = target; + if (target.getAction(LogAction.class) == null + || !hasLogContent(target.getAction(LogAction.class))) { + // A block node's failure output lives in the nodes it encloses; + // a step node's missing log may live in its immediate parent + // (catchError + sh(returnStatus:true) + error() pattern). + // Also enter this branch when the target has a LogAction but its + // content is empty (e.g. the error() step creates a node with a + // LogAction that carries no output). + logNode = target instanceof BlockStartNode + ? findFailedEnclosedNodeWithLog(execution, nodeId) + : findImmediateParentWithLog(target); + if (logNode == null) { + return Collections.emptyList(); + } + } + + LogAction logAction = logNode.getAction(LogAction.class); + if (logAction == null) { + return Collections.emptyList(); + } + List stepLog = readLimitedLog(logAction.getLogText(), maxLines); + if (stepLog.isEmpty()) { + return Collections.emptyList(); + } + addHeaderLog(logNode, stepLog); + setUrl(nodeId); + return stepLog; + } + + /** + * Finds a failed node (one with an {@link ErrorAction}, or a + * {@link WarningAction} with result {@link Result#FAILURE}) that is + * enclosed by the given block node and carries a {@link LogAction}. + * When the failed node itself has no log (e.g. an {@code error()} step), + * its immediate parent is used if it has a log and lives inside the same + * block. + * + * @param execution the flow execution to search + * @param blockId the ID of the enclosing block node (e.g. a stage) + * @return the enclosed failed node with a log, or {@code null} if none + */ + private FlowNode findFailedEnclosedNodeWithLog(FlowExecution execution, String blockId) { + for (FlowNode node : new FlowGraphWalker(execution)) { + if (!isEnclosedBy(node, blockId) || !isFailedFlowNode(node)) { + continue; + } + if (node.getAction(LogAction.class) != null && hasLogContent(node.getAction(LogAction.class))) { + return node; + } + FlowNode parent = findImmediateParentWithLog(node); + if (parent != null && isEnclosedBy(parent, blockId)) { + return parent; + } + } + return null; + } + + private static boolean hasLogContent(LogAction logAction) { + try { + StringWriter sw = new StringWriter(); + return logAction.getLogText().writeLogTo(0, sw) > 0; + } catch (IOException e) { + return false; + } + } + + private static boolean isFailedFlowNode(FlowNode node) { + if (node.getError() != null) { + return true; + } + WarningAction warn = node.getAction(WarningAction.class); + return warn != null && warn.getResult() == Result.FAILURE; + } + + private static boolean isEnclosedBy(FlowNode node, String blockId) { + for (FlowNode enclosing : node.getEnclosingBlocks()) { + if (enclosing.getId().equals(blockId)) { + return true; + } + } + return false; + } + public ExtractionResult extractFailedStepLog() throws IOException { return extractFailedStepLog(true); } diff --git a/src/main/resources/io/jenkins/plugins/explain_error/PipelineGraphViewDecorator/footer.jelly b/src/main/resources/io/jenkins/plugins/explain_error/PipelineGraphViewDecorator/footer.jelly new file mode 100644 index 00000000..659a4248 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/explain_error/PipelineGraphViewDecorator/footer.jelly @@ -0,0 +1,19 @@ + + + + + + + + +