Skip to content

Commit

Permalink
Add includeStage to allow starting check immediately for JUnit (#254)
Browse files Browse the repository at this point in the history
Co-authored-by: Ullrich Hafner <ullrich.hafner@gmail.com>
  • Loading branch information
timja and uhafner authored Apr 4, 2024
1 parent fb71c46 commit eb5589d
Show file tree
Hide file tree
Showing 9 changed files with 191 additions and 71 deletions.
49 changes: 47 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,57 @@ see [the handler](https://github.com/jenkinsci/github-checks-plugin/blob/ea060be

- withChecks: you can inject the check's name into the closure for other steps to use:

```
withChecks(name: 'injected name') {
```groovy
withChecks('injected name') {
// some other steps that will extract the name
}
```

`withChecks` will publish an in progress check immediately and then other consuming plugins will publish the final check.

You can also include the checks stage name with `includeStage`:

```groovy
withChecks(name: 'Tests', includeStage: true) {
sh 'mvn -Dmaven.test.failure.ignore=true clean verify'
junit '**/target/surefire-reports/TEST-*.xml'
}
```

Combining `includeStage` with the JUnit plugin works well to publish checks for each test suite:

![With Checks multiple stages](docs/images/github-status.png)

<details>

<summary>Example full pipeline with parallel stages</summary>

```groovy
def axes = [
platforms: ['linux', 'windows'],
jdks: [17, 21],
]
def builds = [:]
axes.values().combinations {
def (platform, jdk) = it
builds["${platform}-jdk${jdk}"] = {
node(platform) {
stage("${platform.capitalize()} - JDK ${jdk} - Test") {
checkout scm
withChecks(name: 'Tests', includeStage: true) {
sh 'mvn -Dmaven.test.failure.ignore=true clean verify'
junit '**/target/surefire-reports/TEST-*.xml'
}
}
}
}
}
parallel builds
```

</details>

## Guides

- [Consumers Guide](docs/consumers-guide.md)
Expand Down
Binary file added docs/images/multiple-with-checks.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 7 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<description>Defines an API for Jenkins to publish checks to SCM platforms.</description>

<properties>
<revision>2.1.0</revision>
<revision>2.2.0</revision>
<changelist>-SNAPSHOT</changelist>
<gitHubRepo>jenkinsci/${project.artifactId}-plugin</gitHubRepo>

Expand Down Expand Up @@ -166,6 +166,12 @@
<serialVersionUID>1</serialVersionUID>
<justification>Adding actions list with an initial empty value should not break the compatibility.</justification>
</item>
<item>
<code>java.field.serialVersionUIDUnchanged</code>
<old>field io.jenkins.plugins.checks.steps.WithChecksStep.serialVersionUID</old>
<serialVersionUID>1</serialVersionUID>
<justification>Boolean value added, will have no impact on backwards compat.</justification>
</item>
<item>
<regex>true</regex>
<code>java.annotation.*</code>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
package io.jenkins.plugins.checks.status;

import static io.jenkins.plugins.checks.utils.FlowNodeUtils.getEnclosingBlockNames;
import static io.jenkins.plugins.checks.utils.FlowNodeUtils.getEnclosingStagesAndParallels;

import edu.umd.cs.findbugs.annotations.CheckForNull;
import hudson.model.Result;
import hudson.model.Run;
import io.jenkins.plugins.checks.api.ChecksOutput;
import io.jenkins.plugins.checks.api.TruncatedString;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Stack;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;

import org.apache.commons.collections.iterators.ReverseListIterator;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;

import edu.umd.cs.findbugs.annotations.CheckForNull;
import edu.umd.cs.findbugs.annotations.NonNull;

import org.jenkinsci.plugins.workflow.actions.ArgumentsAction;
import org.jenkinsci.plugins.workflow.actions.ErrorAction;
import org.jenkinsci.plugins.workflow.actions.LabelAction;
Expand All @@ -28,14 +30,7 @@
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.StepNode;
import org.jenkinsci.plugins.workflow.steps.StepDescriptor;
import org.jenkinsci.plugins.workflow.support.visualization.table.FlowGraphTable;
import hudson.model.Result;
import hudson.model.Run;

import io.jenkins.plugins.checks.api.ChecksOutput;
import io.jenkins.plugins.checks.api.TruncatedString;

@SuppressWarnings("PMD.GodClass")
class FlowExecutionAnalyzer {
Expand Down Expand Up @@ -200,54 +195,6 @@ private String getPotentialTitle(final FlowNode flowNode, final ErrorAction erro
return StringUtils.join(new ReverseListIterator(enclosingBlockNames), "/") + ": " + whereBuildFailed;
}

private static boolean isStageNode(@NonNull final FlowNode node) {
if (node instanceof StepNode) {
StepDescriptor d = ((StepNode) node).getDescriptor();
return d != null && d.getFunctionName().equals("stage");
}
else {
return false;
}
}

/**
* Get the stage and parallel branch start node IDs (not the body nodes) for this node, innermost first.
* @param node A flownode.
* @return A nonnull, possibly empty list of stage/parallel branch start nodes, innermost first.
*/
@NonNull
private static List<FlowNode> getEnclosingStagesAndParallels(final FlowNode node) {
List<FlowNode> enclosingBlocks = new ArrayList<>();
for (FlowNode enclosing : node.getEnclosingBlocks()) {
if (enclosing != null && enclosing.getAction(LabelAction.class) != null
&& (isStageNode(enclosing) || enclosing.getAction(ThreadNameAction.class) != null)) {
enclosingBlocks.add(enclosing);
}
}

return enclosingBlocks;
}

@NonNull
private static List<String> getEnclosingBlockNames(@NonNull final List<FlowNode> nodes) {
List<String> names = new ArrayList<>();
for (FlowNode n : nodes) {
ThreadNameAction threadNameAction = n.getPersistentAction(ThreadNameAction.class);
LabelAction labelAction = n.getPersistentAction(LabelAction.class);
if (threadNameAction != null) {
// If we're on a parallel branch with the same name as the previous (inner) node, that generally
// means we're in a Declarative parallel stages situation, so don't add the redundant branch name.
if (names.isEmpty() || !threadNameAction.getThreadName().equals(names.get(names.size() - 1))) {
names.add(threadNameAction.getThreadName());
}
}
else if (labelAction != null) {
names.add(labelAction.getDisplayName());
}
}
return names;
}

@CheckForNull
private static String getLog(final FlowNode flowNode) {
LogAction logAction = flowNode.getAction(LogAction.class);
Expand Down
42 changes: 37 additions & 5 deletions src/main/java/io/jenkins/plugins/checks/steps/WithChecksStep.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@
import hudson.model.Run;
import hudson.model.TaskListener;
import io.jenkins.plugins.checks.api.*;
import io.jenkins.plugins.checks.utils.FlowNodeUtils;
import io.jenkins.plugins.util.PluginLogger;
import jenkins.model.CauseOfInterruption;
import org.apache.commons.collections.iterators.ReverseListIterator;
import org.apache.commons.lang3.StringUtils;
import org.jenkinsci.plugins.displayurlapi.DisplayURLProvider;
import org.jenkinsci.plugins.workflow.graph.FlowNode;
import org.jenkinsci.plugins.workflow.steps.*;
import org.kohsuke.stapler.DataBoundConstructor;

Expand All @@ -18,6 +22,7 @@
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import org.kohsuke.stapler.DataBoundSetter;

import static hudson.Util.fixNull;

Expand All @@ -28,6 +33,7 @@ public class WithChecksStep extends Step implements Serializable {
private static final long serialVersionUID = 1L;

private final String name;
private boolean includeStage;

/**
* Creates the step with a name to inject.
Expand All @@ -45,6 +51,15 @@ public String getName() {
return name;
}

public boolean isIncludeStage() {
return includeStage;
}

@DataBoundSetter
public void setIncludeStage(boolean includeStage) {
this.includeStage = includeStage;
}

@Override
public StepExecution start(final StepContext stepContext) {
return new WithChecksStepExecution(stepContext, this);
Expand Down Expand Up @@ -109,7 +124,7 @@ static class WithChecksStepExecution extends AbstractStepExecutionImpl {
}

@Override
public boolean start() {
public boolean start() throws IOException, InterruptedException {
ChecksInfo info = extractChecksInfo();
getContext().newBodyInvoker()
.withContext(info)
Expand All @@ -119,19 +134,36 @@ public boolean start() {
}

@VisibleForTesting
ChecksInfo extractChecksInfo() {
return new ChecksInfo(step.name);
ChecksInfo extractChecksInfo() throws IOException, InterruptedException {
return new ChecksInfo(getName());
}

private String getName() throws IOException, InterruptedException {
if (step.isIncludeStage()) {
FlowNode flowNode = getContext().get(FlowNode.class);
if (flowNode == null) {
throw new IllegalStateException("No FlowNode found in the context.");
}

List<FlowNode> enclosingStagesAndParallels = FlowNodeUtils.getEnclosingStagesAndParallels(flowNode);
List<String> checksComponents = FlowNodeUtils.getEnclosingBlockNames(enclosingStagesAndParallels);

checksComponents.add(step.getName());

return StringUtils.join(new ReverseListIterator(checksComponents), " / ");
}
return step.getName();
}

@Override
public void stop(final Throwable cause) {
try {
publish(getContext(), new ChecksDetails.ChecksDetailsBuilder()
.withName(step.getName())
.withName(getName())
.withStatus(ChecksStatus.COMPLETED)
.withConclusion(ChecksConclusion.CANCELED));
}
catch (WithChecksPublishException e) {
catch (WithChecksPublishException | IOException | InterruptedException e) {
cause.addSuppressed(e);
}
getContext().onFailure(cause);
Expand Down
71 changes: 71 additions & 0 deletions src/main/java/io/jenkins/plugins/checks/utils/FlowNodeUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package io.jenkins.plugins.checks.utils;

import edu.umd.cs.findbugs.annotations.NonNull;
import java.util.ArrayList;
import java.util.List;
import org.jenkinsci.plugins.workflow.actions.LabelAction;
import org.jenkinsci.plugins.workflow.actions.ThreadNameAction;
import org.jenkinsci.plugins.workflow.graph.FlowNode;
import org.jenkinsci.plugins.workflow.graph.StepNode;
import org.jenkinsci.plugins.workflow.steps.StepDescriptor;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;

/**
* Utility methods for working with FlowNodes.
*/
@Restricted(NoExternalUse.class)
public class FlowNodeUtils {
/**
* Get the stage and parallel branch start node IDs (not the body nodes) for this node, innermost first.
* @param node A flownode.
* @return A nonnull, possibly empty list of stage/parallel branch start nodes, innermost first.
*/
@NonNull
public static List<FlowNode> getEnclosingStagesAndParallels(final FlowNode node) {
List<FlowNode> enclosingBlocks = new ArrayList<>();
for (FlowNode enclosing : node.getEnclosingBlocks()) {
if (enclosing != null && enclosing.getAction(LabelAction.class) != null
&& (isStageNode(enclosing) || enclosing.getAction(ThreadNameAction.class) != null)) {
enclosingBlocks.add(enclosing);
}
}

return enclosingBlocks;
}

/**
* Get the stage and parallel branch names for these nodes, innermost first.
* @param nodes A flownode.
* @return A nonnull, possibly empty list of stage/parallel branch names, innermost first.
*/
@NonNull
public static List<String> getEnclosingBlockNames(@NonNull final List<FlowNode> nodes) {
List<String> names = new ArrayList<>();
for (FlowNode n : nodes) {
ThreadNameAction threadNameAction = n.getPersistentAction(ThreadNameAction.class);
LabelAction labelAction = n.getPersistentAction(LabelAction.class);
if (threadNameAction != null) {
// If we're on a parallel branch with the same name as the previous (inner) node, that generally
// means we're in a Declarative parallel stages situation, so don't add the redundant branch name.
if (names.isEmpty() || !threadNameAction.getThreadName().equals(names.get(names.size() - 1))) {
names.add(threadNameAction.getThreadName());
}
}
else if (labelAction != null) {
names.add(labelAction.getDisplayName());
}
}
return names;
}

private static boolean isStageNode(@NonNull final FlowNode node) {
if (node instanceof StepNode) {
StepDescriptor d = ((StepNode) node).getDescriptor();
return d != null && d.getFunctionName().equals("stage");
}
else {
return false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,14 @@ public void shouldNotPublishStatusWhenSkipped() {
* a status checks using the specified name should be published.
*/
@Test
public void shouldPublishStatusWithProperties() {
public void shouldPublishStatusWithProperties() throws Exception {
getProperties().setApplicable(true);
getProperties().setSkipped(false);
getProperties().setName("Test Status");

buildSuccessfully(createFreeStyleProject());

// Wait for the job to finish to work around slow Windows builds sometimes
this.getJenkins().waitUntilNoActivity();
assertThat(getFactory().getPublishedChecks()).hasSize(3);

ChecksDetails details = getFactory().getPublishedChecks().get(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,21 @@ public void publishChecksShouldTakeNameFromWithChecks() {
assertThat(manualChecks.getConclusion()).isEqualTo(ChecksConclusion.SUCCESS);
}

@Test
public void publishChecksShouldIncludeEnclosingBlocksWhenEnabled() {
WorkflowJob job = createPipeline();
job.setDefinition(asStage("withChecks(name: 'tests', includeStage: true) {}"));

buildSuccessfully(job);

assertThat(getFactory().getPublishedChecks()).hasSize(1);
ChecksDetails autoChecks = getFactory().getPublishedChecks().get(0);

assertThat(autoChecks.getName()).contains("tests / Integration Test");
assertThat(autoChecks.getStatus()).isEqualTo(ChecksStatus.IN_PROGRESS);
assertThat(autoChecks.getConclusion()).isEqualTo(ChecksConclusion.NONE);
}

/**
* Tests that withChecks step ignores names from the withChecks context if one has been explicitly set.
*/
Expand Down
Loading

0 comments on commit eb5589d

Please sign in to comment.