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..d66cfb2 --- /dev/null +++ b/src/main/java/testingbot/TestingBotChecksPublisher.java @@ -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 & 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<>(); + 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()) { + 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 { + + @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..8a6f117 --- /dev/null +++ b/src/main/resources/testingbot/TestingBotChecksPublisher/help-message.html @@ -0,0 +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 links to the embedded TestingBot build report when one is available, otherwise to the + Jenkins run. +
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. +