From bfba787b257eab3e26d2605ede7832b8460fb5f5 Mon Sep 17 00:00:00 2001 From: Jochen Delabie Date: Thu, 16 Jul 2026 16:20:43 +0200 Subject: [PATCH 1/2] Publish TestingBot results as a GitHub check (testingbotChecks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a TestingBotChecksPublisher (freestyle post-build action + the testingbotChecks pipeline step) that publishes the build's TestingBot outcome as a GitHub check via the checks-api plugin. The check name (its context) and summary message are configurable — the modern equivalent of the custom context/message Sauce exposes for GitHub Pull Request Builder jobs. The conclusion reflects the TestingBot sessions in the build (all passed -> success), falling back to the overall build result when none are present, and the check links to the embedded TestingBot build report. Delivery to GitHub is provided at runtime by the github-checks plugin; when absent the publish is a safe no-op (NullChecksPublisher). Depends on checks-api + display-url-api (both BOM-managed). --- README.md | 15 ++ pom.xml | 12 ++ .../testingbot/TestingBotChecksPublisher.java | 189 ++++++++++++++++++ .../TestingBotChecksPublisher/config.jelly | 9 + .../help-message.html | 5 + .../TestingBotChecksPublisher/help-name.html | 5 + 6 files changed, 235 insertions(+) create mode 100644 src/main/java/testingbot/TestingBotChecksPublisher.java create mode 100644 src/main/resources/testingbot/TestingBotChecksPublisher/config.jelly create mode 100644 src/main/resources/testingbot/TestingBotChecksPublisher/help-message.html create mode 100644 src/main/resources/testingbot/TestingBotChecksPublisher/help-name.html diff --git a/README.md b/README.md index bf54ded..50f22bc 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,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') +``` + +* `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: diff --git a/pom.xml b/pom.xml index 7998b0e..e13719f 100644 --- a/pom.xml +++ b/pom.xml @@ -197,5 +197,17 @@ org.jenkins-ci.plugins structs + + + + io.jenkins.plugins + checks-api + + + org.jenkins-ci.plugins + display-url-api + diff --git a/src/main/java/testingbot/TestingBotChecksPublisher.java b/src/main/java/testingbot/TestingBotChecksPublisher.java new file mode 100644 index 0000000..30c5eed --- /dev/null +++ b/src/main/java/testingbot/TestingBotChecksPublisher.java @@ -0,0 +1,189 @@ +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.List; +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 & PR), via the + * {@code checks-api} plugin. The check name (its context) and summary (message) are + * configurable — the modern equivalent of the "custom context + message" that Sauce Labs exposes for + * upstream GitHub Pull Request Builder jobs. + * + *

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}).

+ */ +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 rows = new ArrayList<>(); + + 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()) { + if (TestingBotReportFactory.findSessionIDs(cr).isEmpty()) { + continue; + } + total++; + boolean casePassed = cr.isPassed(); + 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 { + + @NonNull + @Override + public String getDisplayName() { + return "Publish TestingBot results as a GitHub check"; + } + + @Override + public boolean isApplicable(Class jobType) { + return true; + } + } +} diff --git a/src/main/resources/testingbot/TestingBotChecksPublisher/config.jelly b/src/main/resources/testingbot/TestingBotChecksPublisher/config.jelly new file mode 100644 index 0000000..6bf69cb --- /dev/null +++ b/src/main/resources/testingbot/TestingBotChecksPublisher/config.jelly @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/main/resources/testingbot/TestingBotChecksPublisher/help-message.html b/src/main/resources/testingbot/TestingBotChecksPublisher/help-message.html new file mode 100644 index 0000000..bb3625e --- /dev/null +++ b/src/main/resources/testingbot/TestingBotChecksPublisher/help-message.html @@ -0,0 +1,5 @@ +
+ Optional summary shown on the check. When left empty, a summary is generated from the TestingBot + sessions in the build (for example, 3 of 4 TestingBot session(s) passed.). + The check also links to the embedded TestingBot build report. +
diff --git a/src/main/resources/testingbot/TestingBotChecksPublisher/help-name.html b/src/main/resources/testingbot/TestingBotChecksPublisher/help-name.html new file mode 100644 index 0000000..61e3f17 --- /dev/null +++ b/src/main/resources/testingbot/TestingBotChecksPublisher/help-name.html @@ -0,0 +1,5 @@ +
+ The name of the GitHub check (its context) that appears on the commit and pull request, + e.g. TestingBot. Use a distinct name if you publish more than one check from a job. + Defaults to TestingBot. +
From 20758c0abf30b250996216d1ceab0f1673acc89e Mon Sep 17 00:00:00 2001 From: Jochen Delabie Date: Thu, 16 Jul 2026 17:58:30 +0200 Subject: [PATCH 2/2] Count unique TestingBot sessions in the check; clarify help text (review on #30) - TestingBotChecksPublisher now counts and renders each distinct session id (trimmed + deduplicated across cases) instead of one entry per CaseResult, so a case logging several sessions, or a session shared by several cases, is reflected accurately in total/passed/rows and the conclusion. Cases without sessions still contribute nothing. - help-message.html now states the check links to the embedded build report when available and otherwise to the Jenkins run. Skipped the README setext-heading suggestion: the repo uses ATX headings (## / ###) for every section and subsection; only the H1 title is setext, so ## here is consistent. --- .../testingbot/TestingBotChecksPublisher.java | 22 +++++++++++++------ .../help-message.html | 3 ++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/main/java/testingbot/TestingBotChecksPublisher.java b/src/main/java/testingbot/TestingBotChecksPublisher.java index 30c5eed..d66cfb2 100644 --- a/src/main/java/testingbot/TestingBotChecksPublisher.java +++ b/src/main/java/testingbot/TestingBotChecksPublisher.java @@ -27,7 +27,9 @@ 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; @@ -86,21 +88,27 @@ public void perform(@NonNull Run run, @NonNull FilePath workspace, @NonNul int total = 0; int passed = 0; List rows = new ArrayList<>(); + Set 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()) { - if (TestingBotReportFactory.findSessionIDs(cr).isEmpty()) { - continue; - } - total++; boolean casePassed = cr.isPassed(); - if (casePassed) { - passed++; + 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") + " |"); } - rows.add("| " + cr.getFullName() + " | " + (casePassed ? "✅ passed" : "❌ failed") + " |"); } } } diff --git a/src/main/resources/testingbot/TestingBotChecksPublisher/help-message.html b/src/main/resources/testingbot/TestingBotChecksPublisher/help-message.html index bb3625e..8a6f117 100644 --- a/src/main/resources/testingbot/TestingBotChecksPublisher/help-message.html +++ b/src/main/resources/testingbot/TestingBotChecksPublisher/help-message.html @@ -1,5 +1,6 @@
Optional summary shown on the check. When left empty, a summary is generated from the TestingBot sessions in the build (for example, 3 of 4 TestingBot session(s) passed.). - The check also links to the embedded TestingBot build report. + The check links to the embedded TestingBot build report when one is available, otherwise to the + Jenkins run.