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 @@ -148,6 +148,21 @@ node {
}
```

## Publishing results as a GitHub check

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the repository’s required heading style.

markdownlint-cli2 reports MD003 here because this section uses an ATX heading while the repository expects setext headings. Convert it to:

Proposed fix
-## Publishing results as a GitHub check
+Publishing results as a GitHub check
+-----------------------------------
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## Publishing results as a GitHub check
Publishing results as a GitHub check
-----------------------------------
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 151-151: Heading style
Expected: setext; Actual: atx

(MD003, heading-style)

🤖 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` at line 151, Update the “Publishing results as a GitHub check”
heading in README.md to use the repository’s required setext heading style
instead of an ATX heading, preserving the heading text and section structure.

Source: Linters/SAST tools


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')
```

* `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>
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") + " |");
}
}
}
}

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))
.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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form">
<f:entry title="Check name (context)" field="name">
<f:textbox default="TestingBot"/>
</f:entry>
<f:entry title="Summary message" field="message">
<f:textbox/>
</f:entry>
</j:jelly>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div>
Optional summary shown on the check. When left empty, a summary is generated from the TestingBot
sessions in the build (for example, <code>3 of 4 TestingBot session(s) passed.</code>).
The check links to the embedded TestingBot build report when one is available, otherwise to the
Jenkins run.
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div>
The name of the GitHub check (its <em>context</em>) that appears on the commit and pull request,
e.g. <code>TestingBot</code>. Use a distinct name if you publish more than one check from a job.
Defaults to <code>TestingBot</code>.
</div>