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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,21 @@ node {
}
```

## Publishing results as a GitHub check

The plugin can publish the TestingBot outcome of a build as a GitHub check (✅/❌ on the commit and pull request) through the [Checks API](https://plugins.jenkins.io/checks-api/) plugin.

Add the **Publish TestingBot results as a GitHub check** post-build action (freestyle), or call the `testingbotChecks` step in a pipeline:

```groovy
testingbotChecks(name: 'TestingBot', message: 'End-to-end tests on TestingBot')
```
Comment on lines +157 to +161

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Show testingbotChecks after junit.

The publisher only reads sessions already attached through AbstractTestResultAction. Calling this example before junit finds no sessions and can publish a success fallback. Document it after junit—typically in an always post block—so failed test runs still publish the correct check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 157 - 161, Update the README example to invoke
testingbotChecks after junit, preferably within an always post-build block, so
published checks include sessions attached by the JUnit publisher even when
tests fail.


* `name` — the check name (its *context* on the commit/PR). Defaults to `TestingBot`. This is the modern equivalent of the custom context you would set for the GitHub Pull Request Builder Plugin.
* `message` — an optional summary; when omitted, a summary is generated from the TestingBot sessions in the build. The check links to the embedded TestingBot build report.

The check conclusion reflects the TestingBot sessions found in the build (all passed → success), falling back to the overall build result when no sessions are present. Actual delivery to GitHub is handled by the [GitHub Checks](https://plugins.jenkins.io/github-checks/) plugin together with a GitHub App; if that is not installed, the step is a safe no-op.

## Configuration as Code (JCasC)

The TestingBot credentials can be configured with the [Configuration as Code](https://plugins.jenkins.io/configuration-as-code/) plugin using the `testingbot` symbol:
Expand Down
12 changes: 12 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -197,5 +197,17 @@
<groupId>org.jenkins-ci.plugins</groupId>
<artifactId>structs</artifactId>
</dependency>

<!-- Publish TestingBot results as a GitHub check. checks-api is the generic API; the actual
GitHub delivery is provided at runtime by the github-checks plugin (+ a GitHub App). When
that implementation is absent, publishing is a safe no-op (NullChecksPublisher). -->
<dependency>
<groupId>io.jenkins.plugins</groupId>
<artifactId>checks-api</artifactId>
</dependency>
<dependency>
<groupId>org.jenkins-ci.plugins</groupId>
<artifactId>display-url-api</artifactId>
</dependency>
</dependencies>
</project>
70 changes: 59 additions & 11 deletions src/main/java/testingbot/TestingBotBuildWrapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,13 @@
import hudson.model.BuildableItemWithBuildWrappers;
import hudson.model.Job;
import hudson.model.Item;
import hudson.model.Computer;
import hudson.model.Node;
import hudson.model.listeners.ItemListener;
import hudson.remoting.VirtualChannel;
import hudson.security.ACL;
import hudson.util.DescribableList;
import testingbot.pipeline.TestingBotTunnelStep;
import java.util.Map;
import hudson.util.ListBoxModel;
import java.util.ArrayList;
Expand Down Expand Up @@ -92,24 +96,45 @@ public Environment setUp(AbstractBuild build, Launcher launcher, BuildListener l
if (this.useTunnel) {
if (credentials == null) {
listener.getLogger().println("No TestingBot key/secret found while trying to start a TestingBot Tunnel");
return new TestingBotBuildEnvironment(null, null, null);
return new TestingBotBuildEnvironment(null, buildId, null, null);
}

// Run the tunnel on the build's node (not the controller) so that a test connecting to
// localhost reaches the tunnel. The identifier keeps parallel builds on one node isolated.
String tunnelIdentifier = TunnelManager.generateTunnelIdentifier(build.getParent().getFullName(), build.getNumber());
String key = credentials.getKey();
String secret = credentials.getDecryptedSecret();
VirtualChannel channel = launcher.getChannel();

if (channel != null) {
try {
TunnelManager.startOnChannel(channel, key, secret, "", tunnelIdentifier, listener);
} catch (InterruptedException ie) {
TunnelManager.stopOnChannel(channel, tunnelIdentifier, listener);
throw ie;
} catch (Exception ex) {
// Do not continue the build with a broken tunnel: clean up and fail.
TunnelManager.stopOnChannel(channel, tunnelIdentifier, listener);
throw new IOException("Failed to start TestingBot tunnel", ex);
}
return new TestingBotBuildEnvironment(credentials, buildId, tunnelIdentifier, null);
}

// No agent channel available (unusual launcher); fall back to a controller-local tunnel.
final App app = new App();
try {
TunnelManager.start(app, credentials.getKey(), credentials.getDecryptedSecret(), "", null, listener);
TunnelManager.start(app, key, secret, "", tunnelIdentifier, listener);
} catch (InterruptedException ie) {
TunnelManager.stop(app, listener);
throw ie;
} catch (Exception ex) {
// Do not continue the build with a broken tunnel: clean up and fail.
TunnelManager.stop(app, listener);
throw new IOException("Failed to start TestingBot tunnel", ex);
}
return new TestingBotBuildEnvironment(credentials, app, buildId);
return new TestingBotBuildEnvironment(credentials, buildId, tunnelIdentifier, app);
}

return new TestingBotBuildEnvironment(credentials, null, buildId);
return new TestingBotBuildEnvironment(credentials, buildId, null, null);
}

/**
Expand Down Expand Up @@ -151,14 +176,19 @@ public ListBoxModel doFillCredentialsIdItems(@AncestorInPath final Item context)

private class TestingBotBuildEnvironment extends BuildWrapper.Environment {

private final App app;
private final TestingBotCredentials credentials;
private final String buildId;
/** Non-null when a tunnel was started for this build. */
private final String tunnelIdentifier;
/** Controller-local tunnel handle, used only in the no-channel fallback (otherwise null → runs on the agent). */
private final App localApp;

public TestingBotBuildEnvironment(TestingBotCredentials credentials, @Nullable App app, @Nullable String buildId) {
public TestingBotBuildEnvironment(TestingBotCredentials credentials, @Nullable String buildId,
@Nullable String tunnelIdentifier, @Nullable App localApp) {
this.credentials = credentials;
this.app = app;
this.buildId = buildId;
this.tunnelIdentifier = tunnelIdentifier;
this.localApp = localApp;
}

@Override
Expand All @@ -171,19 +201,37 @@ public void buildEnvVars(Map<String, String> env) {
env.put(TestingBotBuildReportAction.TESTINGBOT_BUILD, buildId);
}

if (app != null) {
env.put(TESTINGBOT_TUNNEL,"true");
if (tunnelIdentifier != null) {
env.put(TESTINGBOT_TUNNEL, "true");
env.put(TestingBotTunnelStep.TESTINGBOT_TUNNEL_IDENTIFIER, tunnelIdentifier);
}

super.buildEnvVars(env);
}

@Override
public boolean tearDown(AbstractBuild build, BuildListener listener) throws IOException, InterruptedException {
TunnelManager.stop(app, listener);
if (tunnelIdentifier == null) {
return true;
}
if (localApp != null) {
// Controller-local fallback tunnel.
TunnelManager.stop(localApp, listener);
} else {
// Resolve the agent's live channel now rather than holding a reference captured in
// setUp, which could be stale if the agent reconnected during the build.
TunnelManager.stopOnChannel(currentChannel(build), tunnelIdentifier, listener);
}
return true;
}
}

/** The live channel of the node the build ran on, or null if it is offline/unavailable. */
private static VirtualChannel currentChannel(AbstractBuild<?, ?> build) {
Node node = build.getBuiltOn();
Computer computer = node == null ? null : node.toComputer();
return computer == null ? null : computer.getChannel();
}

protected boolean migrateCredentials(AbstractProject project) {
Logger.getLogger(TestingBotBuildWrapper.class.getName()).log(Level.INFO, "TestingBot Plugin: migrateCredentials: " + this.credentialsId);
Expand Down
197 changes: 197 additions & 0 deletions src/main/java/testingbot/TestingBotChecksPublisher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package testingbot;

import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.EnvVars;
import hudson.Extension;
import hudson.FilePath;
import hudson.Launcher;
import hudson.Util;
import hudson.model.AbstractProject;
import hudson.model.Result;
import hudson.model.Run;
import hudson.model.TaskListener;
import hudson.tasks.BuildStepDescriptor;
import hudson.tasks.BuildStepMonitor;
import hudson.tasks.Publisher;
import hudson.tasks.Recorder;
import hudson.tasks.junit.CaseResult;
import hudson.tasks.junit.SuiteResult;
import hudson.tasks.junit.TestResult;
import hudson.tasks.test.AbstractTestResultAction;
import io.jenkins.plugins.checks.api.ChecksConclusion;
import io.jenkins.plugins.checks.api.ChecksDetails;
import io.jenkins.plugins.checks.api.ChecksOutput;
import io.jenkins.plugins.checks.api.ChecksPublisherFactory;
import io.jenkins.plugins.checks.api.ChecksStatus;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import jenkins.tasks.SimpleBuildStep;
import org.jenkinsci.Symbol;
import org.jenkinsci.plugins.displayurlapi.DisplayURLProvider;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.DataBoundSetter;

/**
* Publishes the TestingBot outcome of a build as a GitHub check (✓/✗ on the commit &amp; PR), via the
* {@code checks-api} plugin. The check <em>name</em> (its context) and <em>summary</em> (message) are
* configurable — the modern equivalent of the "custom context + message" that Sauce Labs exposes for
* upstream GitHub Pull Request Builder jobs.
*
* <p>Works in freestyle (post-build action) and Pipeline (the {@code testingbotChecks} step). The
* actual GitHub delivery is done by the {@code github-checks} plugin; when it is not installed the
* publish is a safe no-op (a {@code NullChecksPublisher}).</p>
*/
public class TestingBotChecksPublisher extends Recorder implements SimpleBuildStep {

private static final String DEFAULT_NAME = "TestingBot";

private String name;
private String message;

@DataBoundConstructor
public TestingBotChecksPublisher() {
}

/** The check name — the "context" shown on the commit/PR. Defaults to {@code TestingBot}. */
public String getName() {
return Util.fixEmptyAndTrim(name) == null ? DEFAULT_NAME : name;
}

@DataBoundSetter
public void setName(String name) {
this.name = Util.fixEmptyAndTrim(name);
}

/** Optional custom summary/message for the check; a session summary is generated when empty. */
public String getMessage() {
return message;
}

@DataBoundSetter
public void setMessage(String message) {
this.message = Util.fixEmptyAndTrim(message);
}

@Override
public BuildStepMonitor getRequiredMonitorService() {
return BuildStepMonitor.NONE;
}

@Override
public void perform(@NonNull Run<?, ?> run, @NonNull FilePath workspace, @NonNull EnvVars env,
@NonNull Launcher launcher, @NonNull TaskListener listener) {
int total = 0;
int passed = 0;
List<String> rows = new ArrayList<>();
Set<String> seenSessions = new LinkedHashSet<>();

AbstractTestResultAction<?> testResultAction = run.getAction(AbstractTestResultAction.class);
if (testResultAction != null && testResultAction.getResult() instanceof TestResult) {
TestResult testResult = (TestResult) testResultAction.getResult();
for (SuiteResult sr : testResult.getSuites()) {
for (CaseResult cr : sr.getCases()) {
boolean casePassed = cr.isPassed();
for (String sessionId : TestingBotReportFactory.findSessionIDs(cr)) {
String id = sessionId == null ? "" : sessionId.trim();
// Count each distinct TestingBot session once, even if it appears in several
// cases or a case logs several sessions; cases with no sessions add nothing.
if (id.isEmpty() || !seenSessions.add(id)) {
continue;
}
total++;
if (casePassed) {
passed++;
}
rows.add("| " + cr.getFullName() + " | " + (casePassed ? "✅ passed" : "❌ failed") + " |");
Comment on lines +98 to +110

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Aggregate duplicate session outcomes before choosing the conclusion.

Lines 101-104 intentionally allow one session to occur in several cases, but the first case wins. If that occurrence passed and a later case for the same session failed, the failure is discarded and this check can incorrectly conclude SUCCESS. Track each session’s aggregate result (failure should dominate) and generate rows after aggregation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/testingbot/TestingBotChecksPublisher.java` around lines 98 -
110, The session-processing logic around TestingBotChecksPublisher should
aggregate outcomes for duplicate session IDs before determining the final
conclusion. Replace the first-occurrence counting and row creation with
per-session state where any failed case marks that session failed, then
calculate total/passed counts and generate rows from the aggregated results
after all cases are processed.

}
}
}
}

ChecksConclusion conclusion;
if (total > 0) {
conclusion = passed == total ? ChecksConclusion.SUCCESS : ChecksConclusion.FAILURE;
} else {
conclusion = fromBuildResult(run.getResult());
}

String summary = getMessage() != null ? getMessage() : defaultSummary(total, passed);

StringBuilder text = new StringBuilder();
if (total > 0) {
text.append("| Test | Result |\n| --- | --- |\n");
for (String row : rows) {
text.append(row).append('\n');
}
}
TestingBotBuildReportAction reportAction = run.getAction(TestingBotBuildReportAction.class);
if (reportAction != null) {
text.append("\n[View the full TestingBot build report](").append(reportAction.getReportUrl()).append(")\n");
}

ChecksOutput output = new ChecksOutput.ChecksOutputBuilder()
.withTitle(getName())
.withSummary(summary)
.withText(text.toString())
.build();

ChecksDetails details = new ChecksDetails.ChecksDetailsBuilder()
.withName(getName())
.withStatus(ChecksStatus.COMPLETED)
.withConclusion(conclusion)
.withDetailsURL(detailsUrl(run, reportAction))
Comment on lines +132 to +147

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not publish the TestingBot report auth token to GitHub.

TestingBotBuildReportAction.getReportUrl() includes ?auth=<authHash> (src/main/java/testingbot/TestingBotBuildReportAction.java, Lines 90-92). Lines 134 and 147 place that authenticated URL in GitHub-hosted check fields, exposing report access to anyone permitted to view the repository’s checks. Use a Jenkins-controlled or token-free URL instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/testingbot/TestingBotChecksPublisher.java` around lines 132 -
147, The TestingBot report link added by the publisher must not expose the
authentication hash through GitHub checks. Update the report-link construction
around TestingBotBuildReportAction.getReportUrl() and the ChecksDetails
withDetailsURL(detailsUrl(run, reportAction)) flow to use a Jenkins-controlled
or token-free URL, while preserving the existing report-link behavior otherwise.

.withStartedAt(LocalDateTime.ofInstant(Instant.ofEpochMilli(run.getStartTimeInMillis()), ZoneOffset.UTC))
.withCompletedAt(LocalDateTime.now(ZoneOffset.UTC))
.withOutput(output)
.build();

// A NullChecksPublisher is returned (and this is a no-op) when no GitHub checks implementation
// is installed, so this is always safe to call.
ChecksPublisherFactory.fromRun(run, listener).publish(details);
}

private static String detailsUrl(Run<?, ?> run, TestingBotBuildReportAction reportAction) {
if (reportAction != null) {
return reportAction.getReportUrl();
}
return DisplayURLProvider.get().getRunURL(run);
}

private static String defaultSummary(int total, int passed) {
if (total == 0) {
return "No TestingBot sessions were found in this build.";
}
return passed + " of " + total + " TestingBot session(s) passed.";
}

private static ChecksConclusion fromBuildResult(Result result) {
if (result == null || result == Result.SUCCESS) {
return ChecksConclusion.SUCCESS;
}
if (result == Result.FAILURE || result == Result.UNSTABLE) {
return ChecksConclusion.FAILURE;
}
return ChecksConclusion.NEUTRAL;
}

@Extension
@Symbol("testingbotChecks")
public static final class DescriptorImpl extends BuildStepDescriptor<Publisher> {

@NonNull
@Override
public String getDisplayName() {
return "Publish TestingBot results as a GitHub check";
}

@Override
public boolean isApplicable(Class<? extends AbstractProject> jobType) {
return true;
}
}
}
Loading