type() {
+ return Run.class;
+ }
+
+ @NonNull
+ @Override
+ public Collection extends Action> createFor(@NonNull Run target) {
+ return Collections.singleton(new BuildFlowAction(target));
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowEdge.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowEdge.java
new file mode 100644
index 000000000..58a147729
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowEdge.java
@@ -0,0 +1,8 @@
+package io.jenkins.plugins.pipelinegraphview.buildflow;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+
+/**
+ * DTO representing an edge (trigger relationship) between two builds in the flow graph.
+ */
+public record BuildFlowEdge(@NonNull String from, @NonNull String to) {}
diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowGraph.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowGraph.java
new file mode 100644
index 000000000..a5d223d10
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowGraph.java
@@ -0,0 +1,294 @@
+package io.jenkins.plugins.pipelinegraphview.buildflow;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import edu.umd.cs.findbugs.annotations.Nullable;
+import hudson.model.Cause;
+import hudson.model.CauseAction;
+import hudson.model.Job;
+import hudson.model.Queue;
+import hudson.model.Result;
+import hudson.model.Run;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import jenkins.model.Jenkins;
+import jenkins.model.lazy.LazyBuildMixIn;
+import org.jenkinsci.plugins.workflow.support.steps.build.DownstreamBuildAction;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Builds the full upstream/downstream build flow graph for a given run.
+ *
+ * Upstream traversal uses {@link CauseAction} / {@link Cause.UpstreamCause}.
+ * Downstream traversal uses {@link DownstreamBuildAction} (from pipeline-build-step-plugin).
+ */
+public class BuildFlowGraph {
+
+ private static final Logger logger = LoggerFactory.getLogger(BuildFlowGraph.class);
+
+ /** Maximum number of nodes in the graph to prevent runaway traversal. */
+ static final int MAX_NODES = 200;
+
+ /** Maximum depth to traverse in either direction. */
+ static final int MAX_DEPTH = 50;
+
+ private final Run, ?> target;
+ private final boolean showUpstream;
+ private final boolean showDownstream;
+
+ public BuildFlowGraph(@NonNull Run, ?> target, boolean showUpstream, boolean showDownstream) {
+ this.target = target;
+ this.showUpstream = showUpstream;
+ this.showDownstream = showDownstream;
+ }
+
+ /**
+ * Builds the full flow graph response.
+ */
+ public BuildFlowResponse build() {
+ Map nodeMap = new LinkedHashMap<>();
+ List edges = new ArrayList<>();
+ boolean anyOngoing = false;
+
+ // Find the root of the chain
+ Run, ?> root = showUpstream ? findRootUpstream(target) : target;
+ if (root == null) {
+ root = target;
+ }
+
+ // BFS downstream from root
+ Deque> queue = new ArrayDeque<>();
+ Set visited = new HashSet<>();
+ queue.add(root);
+
+ while (!queue.isEmpty() && nodeMap.size() < MAX_NODES) {
+ Run, ?> current = queue.poll();
+ String nodeId = toNodeId(current);
+ if (visited.contains(nodeId)) {
+ continue;
+ }
+ visited.add(nodeId);
+
+ boolean isCurrent = current.equals(target);
+ BuildFlowNode node = toNode(current, isCurrent);
+ nodeMap.put(nodeId, node);
+
+ if (current.isBuilding()) {
+ anyOngoing = true;
+ }
+
+ // Find downstream builds.
+ // When showDownstream=false, still traverse downstream to reach the target,
+ // but don't go beyond the target node (hide its children).
+ if (showDownstream || !isCurrent) {
+ List> downstreamBuilds = getDownstreamBuilds(current);
+ for (Run, ?> downstream : downstreamBuilds) {
+ String downId = toNodeId(downstream);
+ edges.add(new BuildFlowEdge(nodeId, downId));
+ if (!visited.contains(downId)) {
+ queue.add(downstream);
+ }
+ }
+ }
+ }
+
+ // Check for queued downstream items (skip items triggered by target when hiding downstream)
+ String targetNodeId = toNodeId(target);
+ {
+ Queue.Item[] queueItems = Queue.getInstance().getItems();
+ for (Queue.Item item : queueItems) {
+ if (nodeMap.size() >= MAX_NODES) {
+ break;
+ }
+ CauseAction causeAction = item.getAction(CauseAction.class);
+ if (causeAction == null) {
+ continue;
+ }
+ for (Cause cause : causeAction.getCauses()) {
+ if (cause instanceof Cause.UpstreamCause upstreamCause) {
+ String upstreamId = toNodeId(upstreamCause);
+ // Skip queued items triggered by the target when hiding downstream
+ if (!showDownstream && upstreamId.equals(targetNodeId)) {
+ continue;
+ }
+ if (nodeMap.containsKey(upstreamId)) {
+ String queuedId = "queued-" + item.getId();
+ if (!nodeMap.containsKey(queuedId)) {
+ String jobName = item.task.getDisplayName();
+ nodeMap.put(
+ queuedId,
+ new BuildFlowNode(
+ queuedId, jobName, jobName, 0, jobName, "", "QUEUED", null, null, null,
+ false, null));
+ edges.add(new BuildFlowEdge(upstreamId, queuedId));
+ anyOngoing = true;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return new BuildFlowResponse(new ArrayList<>(nodeMap.values()), edges, anyOngoing, nodeMap.size() >= MAX_NODES);
+ }
+
+ /**
+ * Returns true if this build has any upstream or downstream relationships.
+ */
+ public static boolean hasUpstreamOrDownstream(@Nullable Run, ?> run) {
+ if (run == null) {
+ return false;
+ }
+ // Check upstream
+ if (getUpstreamBuild(run) != null) {
+ return true;
+ }
+ // Check downstream
+ return !getDownstreamBuilds(run).isEmpty();
+ }
+
+ private static Run, ?> findRootUpstream(@NonNull Run, ?> build) {
+ Run, ?> current = build;
+ int depth = 0;
+ Run, ?> parent;
+ while ((parent = getUpstreamBuild(current)) != null && depth < MAX_DEPTH) {
+ current = parent;
+ depth++;
+ }
+ return current;
+ }
+
+ @Nullable
+ static Run, ?> getUpstreamBuild(@NonNull Run, ?> build) {
+ CauseAction causeAction = build.getAction(CauseAction.class);
+ if (causeAction == null) {
+ return null;
+ }
+ for (Cause cause : causeAction.getCauses()) {
+ if (cause instanceof Cause.UpstreamCause upstreamCause) {
+ Jenkins jenkins = Jenkins.getInstanceOrNull();
+ if (jenkins == null) {
+ return null;
+ }
+ Job, ?> upstreamJob = jenkins.getItemByFullName(upstreamCause.getUpstreamProject(), Job.class);
+ if (upstreamJob == null) {
+ continue;
+ }
+ // Skip rebuilds (same job as parent)
+ if (build.getParent().equals(upstreamJob)) {
+ continue;
+ }
+ Run, ?> upstreamRun = upstreamJob.getBuildByNumber(upstreamCause.getUpstreamBuild());
+ if (upstreamRun != null) {
+ return upstreamRun;
+ }
+ }
+ }
+ return null;
+ }
+
+ @NonNull
+ static List> getDownstreamBuilds(@NonNull Run, ?> run) {
+ List> result = new ArrayList<>();
+ DownstreamBuildAction action = run.getAction(DownstreamBuildAction.class);
+ if (action != null) {
+ for (DownstreamBuildAction.DownstreamBuild downstreamBuild : action.getDownstreamBuilds()) {
+ try {
+ Run, ?> downstream = downstreamBuild.getBuild();
+ if (downstream != null) {
+ result.add(downstream);
+ }
+ } catch (Exception e) {
+ logger.warn("Could not resolve downstream build: {}", e.getMessage());
+ }
+ }
+ }
+ return result;
+ }
+
+ private BuildFlowNode toNode(@NonNull Run, ?> run, boolean isCurrent) {
+ String nodeId = toNodeId(run);
+ String jobName = run.getParent().getDisplayName();
+ String jobFullName = run.getParent().getFullName();
+ int buildNumber = run.getNumber();
+ String displayName = run.getFullDisplayName();
+ String url = run.getUrl();
+ String status = mapStatus(run);
+ Long durationMs = run.isBuilding() ? null : run.getDuration();
+ Long startTimeMs = run.isBuilding() ? run.getStartTimeInMillis() : null;
+ String description = run.getDescription();
+ List recentResults = getRecentResults(run);
+
+ return new BuildFlowNode(
+ nodeId,
+ jobName,
+ jobFullName,
+ buildNumber,
+ displayName,
+ url,
+ status,
+ durationMs,
+ startTimeMs,
+ description,
+ isCurrent,
+ recentResults);
+ }
+
+ private static String toNodeId(@NonNull Run, ?> run) {
+ return run.getParent().getFullName() + "#" + run.getNumber();
+ }
+
+ private static String toNodeId(@NonNull Cause.UpstreamCause cause) {
+ return cause.getUpstreamProject() + "#" + cause.getUpstreamBuild();
+ }
+
+ private static String mapStatus(@NonNull Run, ?> run) {
+ if (run.isBuilding()) {
+ return "IN_PROGRESS";
+ }
+
+ Result result = run.getResult();
+ if (result == null) {
+ return "IN_PROGRESS";
+ }
+
+ return switch (result.toString()) {
+ case "SUCCESS", "FAILURE", "UNSTABLE", "ABORTED" -> result.toString();
+ default -> "NOT_BUILT";
+ };
+ }
+
+ private static List getRecentResults(@NonNull Run, ?> run) {
+ List results = new ArrayList<>(6);
+ // Include the current build's result as the most recent entry
+ results.add(mapStatus(run));
+
+ // Avoid loading previous builds into memory if they aren't already there.
+ // Pattern from JUnit plugin's AbstractTestResultAction.
+ Set loadedBuilds = null;
+ if (run.getParent() instanceof LazyBuildMixIn.LazyLoadingJob, ?> lazyJob) {
+ loadedBuilds =
+ lazyJob.getLazyBuildMixIn()._getRuns().getLoadedBuilds().keySet();
+ }
+
+ Run, ?> previousBuild = run;
+ int count = 0;
+ while (count < 5) {
+ previousBuild = loadedBuilds == null || loadedBuilds.contains(previousBuild.getNumber() - 1)
+ ? previousBuild.getPreviousBuild()
+ : null;
+ if (previousBuild == null) {
+ break;
+ }
+ results.add(mapStatus(previousBuild));
+ count++;
+ }
+ return results;
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobAction.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobAction.java
new file mode 100644
index 000000000..2e91058b5
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobAction.java
@@ -0,0 +1,83 @@
+package io.jenkins.plugins.pipelinegraphview.buildflow;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import hudson.model.Action;
+import hudson.model.Item;
+import hudson.model.Job;
+import hudson.model.Run;
+import io.jenkins.plugins.pipelinegraphview.Messages;
+import io.jenkins.plugins.pipelinegraphview.PipelineGraphViewConfiguration;
+import jakarta.servlet.ServletException;
+import java.io.IOException;
+import org.kohsuke.stapler.StaplerRequest2;
+import org.kohsuke.stapler.StaplerResponse2;
+import org.kohsuke.stapler.WebMethod;
+import org.kohsuke.stapler.verb.GET;
+
+/**
+ * Action on a {@link Job} that provides the Build Flow card on the job page.
+ * Shows the upstream/downstream chain of the most recent build as an embedded card,
+ * similar to how the Stages card appears on the job page.
+ */
+public class BuildFlowJobAction implements Action {
+
+ public static final String URL_NAME = "build-flow";
+
+ private final Job, ?> job;
+
+ public BuildFlowJobAction(@NonNull Job, ?> job) {
+ this.job = job;
+ }
+
+ @Override
+ public String getDisplayName() {
+ return Messages.buildFlow_title();
+ }
+
+ @Override
+ public String getUrlName() {
+ return URL_NAME;
+ }
+
+ @Override
+ public String getIconFileName() {
+ return null;
+ }
+
+ public Job, ?> getJob() {
+ return job;
+ }
+
+ /**
+ * Returns the latest build for use in Jelly views.
+ */
+ public Run, ?> getLatestBuild() {
+ return job.getLastBuild();
+ }
+
+ public boolean shouldDisplay() {
+ if (!PipelineGraphViewConfiguration.get().isShowBuildFlowOnJobPage()) {
+ return false;
+ }
+ Run, ?> latest = job.getLastBuild();
+ return latest != null && BuildFlowGraph.hasUpstreamOrDownstream(latest);
+ }
+
+ @GET
+ @WebMethod(name = "api")
+ public void getApi(StaplerRequest2 request, StaplerResponse2 response) throws IOException, ServletException {
+ job.checkPermission(Item.READ);
+
+ Run, ?> latest = job.getLastBuild();
+ if (latest == null) {
+ new BuildFlowResponse(java.util.List.of(), java.util.List.of(), false, false).writeTo(response);
+ return;
+ }
+
+ boolean showUpstream = !"false".equals(request.getParameter("showUpstream"));
+ boolean showDownstream = !"false".equals(request.getParameter("showDownstream"));
+ BuildFlowGraph graph = new BuildFlowGraph(latest, showUpstream, showDownstream);
+ BuildFlowResponse buildFlowResponse = graph.build();
+ buildFlowResponse.writeTo(response);
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobActionFactory.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobActionFactory.java
new file mode 100644
index 000000000..b5aeeabde
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobActionFactory.java
@@ -0,0 +1,27 @@
+package io.jenkins.plugins.pipelinegraphview.buildflow;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import hudson.Extension;
+import hudson.model.Action;
+import hudson.model.Job;
+import java.util.Collection;
+import java.util.Collections;
+import jenkins.model.TransientActionFactory;
+
+/**
+ * Registers {@link BuildFlowJobAction} on every {@link Job}.
+ */
+@Extension
+public class BuildFlowJobActionFactory extends TransientActionFactory {
+
+ @Override
+ public Class type() {
+ return Job.class;
+ }
+
+ @NonNull
+ @Override
+ public Collection extends Action> createFor(@NonNull Job target) {
+ return Collections.singleton(new BuildFlowJobAction(target));
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowNode.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowNode.java
new file mode 100644
index 000000000..c3ce30e3d
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowNode.java
@@ -0,0 +1,22 @@
+package io.jenkins.plugins.pipelinegraphview.buildflow;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import edu.umd.cs.findbugs.annotations.Nullable;
+import java.util.List;
+
+/**
+ * DTO representing a single node (build) in the build flow graph.
+ */
+public record BuildFlowNode(
+ @NonNull String id,
+ @NonNull String jobName,
+ @NonNull String jobFullName,
+ int buildNumber,
+ @NonNull String displayName,
+ @NonNull String url,
+ @NonNull String status,
+ @Nullable Long durationMs,
+ @Nullable Long startTimeMs,
+ @Nullable String description,
+ boolean isCurrentBuild,
+ @Nullable List recentResults) {}
diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowResponse.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowResponse.java
new file mode 100644
index 000000000..f120e4686
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowResponse.java
@@ -0,0 +1,29 @@
+package io.jenkins.plugins.pipelinegraphview.buildflow;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import io.jenkins.plugins.pipelinegraphview.utils.PipelineJsonWriter;
+import java.io.IOException;
+import java.util.List;
+import org.kohsuke.stapler.StaplerResponse2;
+
+/**
+ * Response envelope for the build flow API endpoint.
+ */
+public record BuildFlowResponse(
+ @NonNull List nodes,
+ @NonNull List edges,
+ boolean isAnyBuildOngoing,
+ boolean isTruncated) {
+
+ /** Writes this response as JSON with appropriate cache headers. */
+ void writeTo(@NonNull StaplerResponse2 response) throws IOException {
+ response.setStatus(200);
+ response.setContentType("application/json;charset=UTF-8");
+ if (!isAnyBuildOngoing) {
+ response.setHeader("Cache-Control", "private, max-age=60");
+ } else {
+ response.setHeader("Cache-Control", "private, no-store");
+ }
+ PipelineJsonWriter.write(this, response.getOutputStream());
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction.java b/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction.java
index 79e52ce4f..945dc57ec 100644
--- a/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction.java
+++ b/src/main/java/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction.java
@@ -233,6 +233,15 @@ public boolean isShowGraphOnBuildPage() {
return PipelineGraphViewConfiguration.get().isShowGraphOnBuildPage();
}
+ public boolean isShowBuildFlowOnJobPage() {
+ return PipelineGraphViewConfiguration.get().isShowBuildFlowOnJobPage();
+ }
+
+ public boolean hasBuildFlow() {
+ return isShowBuildFlowOnJobPage()
+ && io.jenkins.plugins.pipelinegraphview.buildflow.BuildFlowGraph.hasUpstreamOrDownstream(run);
+ }
+
public boolean isBuildable() {
return run.getParent().isBuildable();
}
diff --git a/src/main/resources/io/jenkins/plugins/pipelinegraphview/Messages.properties b/src/main/resources/io/jenkins/plugins/pipelinegraphview/Messages.properties
index ca7ba8c0b..97bb9f690 100644
--- a/src/main/resources/io/jenkins/plugins/pipelinegraphview/Messages.properties
+++ b/src/main/resources/io/jenkins/plugins/pipelinegraphview/Messages.properties
@@ -28,6 +28,12 @@ FlowNodeWrapper.noStage=System Generated
run.alreadyCancelled=Run was already cancelled
run.isFinished=Run is already finished
+buildFlow.title=Build Flow
+buildFlow.loading=Loading build flow\u2026
+buildFlow.error=Failed to load build flow: {0}
+buildFlow.empty=No upstream or downstream builds found.
+buildFlow.truncated=Graph truncated at {0} nodes. Some builds may not be shown.
+
scheduled.success=Build scheduled
scheduled.failure=Could not schedule a build
@@ -35,6 +41,7 @@ scheduled.failure=Could not schedule a build
settings=Settings
settings.showStageName=Show stage names
settings.showStageDuration=Show stage duration
+settings.showBuildFlow=Show Build Flow
console.newTab=View step as plain text
diff --git a/src/main/resources/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration/config.jelly b/src/main/resources/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration/config.jelly
index 8f75e9bab..ea4892720 100644
--- a/src/main/resources/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration/config.jelly
+++ b/src/main/resources/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration/config.jelly
@@ -18,4 +18,9 @@
+
+
+
+
+
diff --git a/src/main/resources/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration/config.properties b/src/main/resources/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration/config.properties
index 6aede16d4..049a5f1fb 100644
--- a/src/main/resources/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration/config.properties
+++ b/src/main/resources/io/jenkins/plugins/pipelinegraphview/PipelineGraphViewConfiguration/config.properties
@@ -4,3 +4,5 @@ pipelineStageShowStageNames=Show stage names by default
pipelineStageShowStageDurations=Show stage durations by default
pipelineGraph=Pipeline Graph
pipelineGraphShowOnPage=Show pipeline graph on build page
+buildFlow=Build Flow
+buildFlowShowOnPage=Show build flow on job page
diff --git a/src/main/resources/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowAction/index.jelly b/src/main/resources/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowAction/index.jelly
new file mode 100644
index 000000000..7c87b800a
--- /dev/null
+++ b/src/main/resources/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowAction/index.jelly
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowAction/summary.jelly b/src/main/resources/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowAction/summary.jelly
new file mode 100644
index 000000000..69e5df5fb
--- /dev/null
+++ b/src/main/resources/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowAction/summary.jelly
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobAction/index.jelly b/src/main/resources/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobAction/index.jelly
new file mode 100644
index 000000000..25f6cff6d
--- /dev/null
+++ b/src/main/resources/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobAction/index.jelly
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobAction/jobMain.jelly b/src/main/resources/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobAction/jobMain.jelly
new file mode 100644
index 000000000..202aa5d31
--- /dev/null
+++ b/src/main/resources/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowJobAction/jobMain.jelly
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction/index.jelly b/src/main/resources/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction/index.jelly
index 2dd9c1ec5..be1ef49d6 100644
--- a/src/main/resources/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction/index.jelly
+++ b/src/main/resources/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction/index.jelly
@@ -27,6 +27,9 @@
diff --git a/src/main/resources/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction/widget.jelly b/src/main/resources/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction/widget.jelly
index 6a0eaac9d..e1e068b42 100644
--- a/src/main/resources/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction/widget.jelly
+++ b/src/main/resources/io/jenkins/plugins/pipelinegraphview/consoleview/PipelineConsoleViewAction/widget.jelly
@@ -6,4 +6,13 @@
data-current-run-path="${rootURL + '/' + it.url}"
data-previous-run-path="${it.previousBuild != null ? rootURL + '/' + it.previousBuild.url : null}" />
+
+
+
+
diff --git a/src/test/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowActionTest.java b/src/test/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowActionTest.java
new file mode 100644
index 000000000..4dc09abc5
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowActionTest.java
@@ -0,0 +1,62 @@
+package io.jenkins.plugins.pipelinegraphview.buildflow;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.*;
+
+import hudson.model.Result;
+import io.jenkins.plugins.pipelinegraphview.utils.TestUtils;
+import net.sf.json.JSONArray;
+import net.sf.json.JSONObject;
+import org.htmlunit.Page;
+import org.jenkinsci.plugins.workflow.job.WorkflowRun;
+import org.junit.jupiter.api.Test;
+import org.jvnet.hudson.test.JenkinsRule;
+import org.jvnet.hudson.test.junit.jupiter.WithJenkins;
+
+@WithJenkins
+class BuildFlowActionTest {
+
+ @Test
+ void apiReturnsNodesAndEdges(JenkinsRule j) throws Exception {
+ TestUtils.createJob(j, "downstream", "helloWorldScriptedPipeline.jenkinsfile");
+ WorkflowRun upstream = TestUtils.createAndRunJob(j, "upstream", "buildStep.jenkinsfile", Result.SUCCESS);
+
+ JenkinsRule.WebClient wc = j.createWebClient();
+ Page page = wc.goTo(upstream.getUrl() + "build-flow/api", "application/json");
+ String json = page.getWebResponse().getContentAsString();
+ JSONObject envelope = JSONObject.fromObject(json);
+ JSONObject response = envelope.getJSONObject("data");
+
+ JSONArray nodes = response.getJSONArray("nodes");
+ JSONArray edges = response.getJSONArray("edges");
+
+ assertThat(nodes.size(), is(2));
+ assertThat(edges.size(), is(1));
+ assertThat(response.getBoolean("isAnyBuildOngoing"), is(false));
+ }
+
+ @Test
+ void actionNotRegisteredForStandaloneBuild(JenkinsRule j) throws Exception {
+ WorkflowRun run =
+ TestUtils.createAndRunJob(j, "standalone", "helloWorldScriptedPipeline.jenkinsfile", Result.SUCCESS);
+ BuildFlowAction action = run.getAction(BuildFlowAction.class);
+ assertThat(action, is(nullValue()));
+ }
+
+ @Test
+ void actionRegisteredForTriggeredBuild(JenkinsRule j) throws Exception {
+ TestUtils.createJob(j, "downstream", "helloWorldScriptedPipeline.jenkinsfile");
+ WorkflowRun upstream = TestUtils.createAndRunJob(j, "upstream", "buildStep.jenkinsfile", Result.SUCCESS);
+ BuildFlowAction action = upstream.getAction(BuildFlowAction.class);
+ assertThat(action, is(notNullValue()));
+ }
+
+ @Test
+ void shouldDisplayBuildFlow_trueForTriggeredBuild(JenkinsRule j) throws Exception {
+ TestUtils.createJob(j, "downstream", "helloWorldScriptedPipeline.jenkinsfile");
+ WorkflowRun upstream = TestUtils.createAndRunJob(j, "upstream", "buildStep.jenkinsfile", Result.SUCCESS);
+ BuildFlowAction action = upstream.getAction(BuildFlowAction.class);
+ assertThat(action, is(notNullValue()));
+ assertThat(action.shouldDisplayBuildFlow(), is(true));
+ }
+}
diff --git a/src/test/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowGraphTest.java b/src/test/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowGraphTest.java
new file mode 100644
index 000000000..8a5579e75
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/pipelinegraphview/buildflow/BuildFlowGraphTest.java
@@ -0,0 +1,140 @@
+package io.jenkins.plugins.pipelinegraphview.buildflow;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.*;
+
+import hudson.model.Result;
+import io.jenkins.plugins.pipelinegraphview.utils.TestUtils;
+import org.jenkinsci.plugins.workflow.job.WorkflowRun;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.jvnet.hudson.test.JenkinsRule;
+import org.jvnet.hudson.test.junit.jupiter.WithJenkins;
+
+@WithJenkins
+class BuildFlowGraphTest {
+
+ private JenkinsRule j;
+
+ @BeforeEach
+ void setup(JenkinsRule j) {
+ this.j = j;
+ }
+
+ @Test
+ void linearChain_upstreamTriggersDownstream() throws Exception {
+ TestUtils.createJob(j, "downstream", "helloWorldScriptedPipeline.jenkinsfile");
+ WorkflowRun upstream = TestUtils.createAndRunJob(j, "upstream", "buildStep.jenkinsfile", Result.SUCCESS);
+
+ BuildFlowGraph graph = new BuildFlowGraph(upstream, true, true);
+ BuildFlowResponse response = graph.build();
+
+ assertThat(response.nodes(), hasSize(2));
+ assertThat(response.edges(), hasSize(1));
+ assertThat(response.isAnyBuildOngoing(), is(false));
+
+ // Verify node IDs
+ BuildFlowNode upstreamNode = response.nodes().stream()
+ .filter(BuildFlowNode::isCurrentBuild)
+ .findFirst()
+ .orElseThrow();
+ assertThat(upstreamNode.jobName(), is("upstream"));
+ assertThat(upstreamNode.status(), is("SUCCESS"));
+
+ BuildFlowNode downstreamNode = response.nodes().stream()
+ .filter(n -> !n.isCurrentBuild())
+ .findFirst()
+ .orElseThrow();
+ assertThat(downstreamNode.jobName(), is("downstream"));
+ assertThat(downstreamNode.status(), is("SUCCESS"));
+
+ // Verify edge
+ BuildFlowEdge edge = response.edges().get(0);
+ assertThat(edge.from(), is(upstreamNode.id()));
+ assertThat(edge.to(), is(downstreamNode.id()));
+ }
+
+ @Test
+ void downstreamBuild_showsUpstreamWhenQueried() throws Exception {
+ TestUtils.createJob(j, "downstream", "helloWorldScriptedPipeline.jenkinsfile");
+ TestUtils.createAndRunJob(j, "upstream", "buildStep.jenkinsfile", Result.SUCCESS);
+
+ // Query from downstream's perspective
+ WorkflowRun downstreamRun = j.jenkins
+ .getItemByFullName("downstream", org.jenkinsci.plugins.workflow.job.WorkflowJob.class)
+ .getLastBuild();
+
+ BuildFlowGraph graphWithUpstream = new BuildFlowGraph(downstreamRun, true, true);
+ BuildFlowResponse responseWithUpstream = graphWithUpstream.build();
+ assertThat(responseWithUpstream.nodes(), hasSize(2));
+
+ BuildFlowGraph graphWithoutUpstream = new BuildFlowGraph(downstreamRun, false, true);
+ BuildFlowResponse responseWithoutUpstream = graphWithoutUpstream.build();
+ // Without upstream, only the downstream itself (and its own downstream, if any)
+ assertThat(responseWithoutUpstream.nodes(), hasSize(1));
+ }
+
+ @Test
+ void hideDownstream_showsPathToTargetOnly() throws Exception {
+ TestUtils.createJob(j, "downstream", "helloWorldScriptedPipeline.jenkinsfile");
+ WorkflowRun upstream = TestUtils.createAndRunJob(j, "upstream", "buildStep.jenkinsfile", Result.SUCCESS);
+
+ // From upstream's perspective, hiding downstream should still show upstream (itself)
+ // but hide its children
+ BuildFlowGraph graph = new BuildFlowGraph(upstream, true, false);
+ BuildFlowResponse response = graph.build();
+ // Should show only upstream (target), not downstream
+ assertThat(response.nodes(), hasSize(1));
+ assertThat(response.nodes().get(0).jobName(), is("upstream"));
+ }
+
+ @Test
+ void isTruncated_falseForSmallGraphs() throws Exception {
+ TestUtils.createJob(j, "downstream", "helloWorldScriptedPipeline.jenkinsfile");
+ WorkflowRun upstream = TestUtils.createAndRunJob(j, "upstream", "buildStep.jenkinsfile", Result.SUCCESS);
+
+ BuildFlowGraph graph = new BuildFlowGraph(upstream, true, true);
+ BuildFlowResponse response = graph.build();
+ assertThat(response.isTruncated(), is(false));
+ }
+
+ @Test
+ void standaloneBuild_hasNoFlow() throws Exception {
+ WorkflowRun run =
+ TestUtils.createAndRunJob(j, "standalone", "helloWorldScriptedPipeline.jenkinsfile", Result.SUCCESS);
+
+ assertThat(BuildFlowGraph.hasUpstreamOrDownstream(run), is(false));
+
+ BuildFlowGraph graph = new BuildFlowGraph(run, true, true);
+ BuildFlowResponse response = graph.build();
+ assertThat(response.nodes(), hasSize(1));
+ assertThat(response.edges(), is(empty()));
+ }
+
+ @Test
+ void hasUpstreamOrDownstream_trueForTriggeredBuild() throws Exception {
+ TestUtils.createJob(j, "downstream", "helloWorldScriptedPipeline.jenkinsfile");
+ WorkflowRun upstream = TestUtils.createAndRunJob(j, "upstream", "buildStep.jenkinsfile", Result.SUCCESS);
+
+ assertThat(BuildFlowGraph.hasUpstreamOrDownstream(upstream), is(true));
+ }
+
+ @Test
+ void recentResults_returnsUpToSixHistoryItems() throws Exception {
+ // Create the job once, then run it 8 times to exceed the cap of 6 (current + 5 previous)
+ org.jenkinsci.plugins.workflow.job.WorkflowJob job =
+ TestUtils.createJob(j, "multi-run", "helloWorldScriptedPipeline.jenkinsfile");
+ for (int i = 0; i < 8; i++) {
+ j.assertBuildStatus(Result.SUCCESS, job.scheduleBuild2(0));
+ }
+
+ WorkflowRun lastRun = job.getLastBuild();
+
+ BuildFlowGraph graph = new BuildFlowGraph(lastRun, true, true);
+ BuildFlowResponse response = graph.build();
+
+ BuildFlowNode node = response.nodes().get(0);
+ assertThat(node.recentResults(), hasSize(6)); // current + 5 previous (capped)
+ assertThat(node.recentResults(), everyItem(is("SUCCESS")));
+ }
+}
diff --git a/vite.config.ts b/vite.config.ts
index 0d2e472e4..5560aecd4 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -21,6 +21,7 @@ export default defineConfig({
"src/main/frontend/pipeline-graph-view/index.tsx",
"multi-pipeline-graph-view":
"src/main/frontend/multi-pipeline-graph-view/index.tsx",
+ "build-flow-view": "src/main/frontend/build-flow-view/index.tsx",
},
output: {
entryFileNames: "[name]-bundle.js",