From 8782c274ebbb49f43728cb27add08c84f7a4ba4d Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Wed, 3 Jun 2026 07:44:28 +0300 Subject: [PATCH 1/4] feat: add Pipeline Graph View integration for AI Explain Add an 'Explain Error' button to the Pipeline Graph View page that appears when a failed node is selected. The feature reuses the existing ErrorExplanationAction caching mechanism (shows cached explanation when available, generates a new one otherwise). New files: - GraphViewExplainErrorAction: RunAction2 with AJAX endpoints for checking node failure status and triggering AI explanations - GraphViewExplainErrorActionFactory: TransientActionFactory that injects the action into all runs (only when pipeline-graph-view is installed) - PipelineGraphViewDecorator: PageDecorator that injects JS into the /stages page - explain-error-graph-view.js: Frontend logic for monitoring node selection, showing/hiding the Explain button, and displaying results - GraphViewExplainErrorActionTest: 13 unit tests covering build status checks, node status queries, cache hits, and force-new generation Modified files: - PipelineLogExtractor: added extractNodeLog(String nodeId) method to extract logs from a specific flow node by ID --- .../GraphViewExplainErrorAction.java | 333 +++++++++++++++ .../GraphViewExplainErrorActionFactory.java | 46 ++ .../PipelineGraphViewDecorator.java | 77 ++++ .../explain_error/PipelineLogExtractor.java | 50 +++ .../PipelineGraphViewDecorator/footer.jelly | 19 + .../webapp/js/explain-error-graph-view.js | 398 ++++++++++++++++++ .../GraphViewExplainErrorActionTest.java | 251 +++++++++++ 7 files changed, 1174 insertions(+) create mode 100644 src/main/java/io/jenkins/plugins/explain_error/GraphViewExplainErrorAction.java create mode 100644 src/main/java/io/jenkins/plugins/explain_error/GraphViewExplainErrorActionFactory.java create mode 100644 src/main/java/io/jenkins/plugins/explain_error/PipelineGraphViewDecorator.java create mode 100644 src/main/resources/io/jenkins/plugins/explain_error/PipelineGraphViewDecorator/footer.jelly create mode 100644 src/main/webapp/js/explain-error-graph-view.js create mode 100644 src/test/java/io/jenkins/plugins/explain_error/GraphViewExplainErrorActionTest.java 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..c027d92d --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/GraphViewExplainErrorAction.java @@ -0,0 +1,333 @@ +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.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import java.util.Set; +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()) { + 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 of its descendants) represents + * a failure. + *

+ * A node is considered failed if: + *

+ * + * @param nodeId the flow node ID to check + * @return {@code true} if the node or its descendants contain a failure + */ + @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; + } + + FlowGraphWalker walker = new FlowGraphWalker(execution); + FlowNode targetNode = null; + for (FlowNode node : walker) { + if (node.getId().equals(nodeId)) { + targetNode = node; + break; + } + } + if (targetNode == null) { + return false; + } + + // Check the node itself + if (targetNode.getError() != null) { + return true; + } + WarningAction warn = targetNode.getAction(WarningAction.class); + if (warn != null && warn.getResult() == Result.FAILURE) { + return true; + } + + // Walk the full graph and check if any node with ErrorAction + // has targetNode in its ancestor chain + FlowGraphWalker walker2 = new FlowGraphWalker(execution); + for (FlowNode node : walker2) { + if (node.getError() == null) { + continue; + } + if (node.getId().equals(nodeId)) { + return true; + } + if (isDescendantOf(node, targetNode)) { + return true; + } + } + return false; + } + + /** + * Checks whether {@code node} is a descendant of {@code ancestor} by + * walking the node's parent chain. + */ + private boolean isDescendantOf(FlowNode node, FlowNode ancestor) { + Set visited = new HashSet<>(); + Queue queue = new LinkedList<>(); + queue.add(node); + + while (!queue.isEmpty()) { + FlowNode current = queue.poll(); + if (!visited.add(current.getId())) { + continue; + } + if (current.getId().equals(ancestor.getId())) { + return true; + } + // Check parents — if we reach the ancestor, node is a descendant + queue.addAll(current.getParents()); + // Stop searching if we've gone past the ancestor's position + // (approximation: stop if we've searched too many nodes) + if (visited.size() > 10_000) { + return false; + } + } + 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..e437e821 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/PipelineGraphViewDecorator.java @@ -0,0 +1,77 @@ +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; + +/** + * 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; + } + String uri = Stapler.getCurrentRequest2().getRequestURI(); + 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..16727537 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/PipelineLogExtractor.java +++ b/src/main/java/io/jenkins/plugins/explain_error/PipelineLogExtractor.java @@ -318,6 +318,56 @@ 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. + *

+ * Supports the {@code catchError + sh(returnStatus:true) + error()} pattern + * by falling back to the immediate parent when the target node has no + * {@link LogAction}. + * + * @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(); + } + + FlowGraphWalker walker = new FlowGraphWalker(execution); + for (FlowNode node : walker) { + if (!node.getId().equals(nodeId)) { + continue; + } + LogAction logAction = node.getAction(LogAction.class); + // catchError + sh(returnStatus:true) + error() fallback + if (logAction == null) { + FlowNode immediateParent = findImmediateParentWithLog(node); + if (immediateParent != null) { + logAction = immediateParent.getAction(LogAction.class); + } + } + if (logAction == null) { + return Collections.emptyList(); + } + List stepLog = readLimitedLog(logAction.getLogText(), maxLines); + if (stepLog.isEmpty()) { + return Collections.emptyList(); + } + addHeaderLog(node, stepLog); + setUrl(nodeId); + return stepLog; + } + return Collections.emptyList(); + } + 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 @@ + + + + + + + + +