diff --git a/README.md b/README.md index 77b567a..a18f9b7 100644 --- a/README.md +++ b/README.md @@ -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') +``` + +* `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/TestingBotBuildWrapper.java b/src/main/java/testingbot/TestingBotBuildWrapper.java index e23a3fb..d319241 100644 --- a/src/main/java/testingbot/TestingBotBuildWrapper.java +++ b/src/main/java/testingbot/TestingBotBuildWrapper.java @@ -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; @@ -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); } /** @@ -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 @@ -171,8 +201,9 @@ public void buildEnvVars(Map 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); @@ -180,10 +211,27 @@ public void buildEnvVars(Map 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); 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/java/testingbot/TunnelManager.java b/src/main/java/testingbot/TunnelManager.java index c3bc2e0..169b7f4 100644 --- a/src/main/java/testingbot/TunnelManager.java +++ b/src/main/java/testingbot/TunnelManager.java @@ -4,12 +4,16 @@ import com.testingbot.tunnel.Api; import com.testingbot.tunnel.App; import hudson.model.TaskListener; +import hudson.remoting.VirtualChannel; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Matcher; import java.util.regex.Pattern; +import jenkins.security.MasterToSlaveCallable; /** * Shared TestingBot Tunnel lifecycle helper used by both the freestyle @@ -203,4 +207,138 @@ public static void stop(App app, TaskListener listener) { app.stop(); } } + + // ------------------------------------------------------------------------------------------ + // Remote (agent) tunnel lifecycle. + // + // The tunnel must boot on the same node where the tests run, so that a test connecting to + // {@code localhost} reaches the tunnel. Both the freestyle build wrapper and the pipeline step + // therefore run the tunnel inside a MasterToSlaveCallable on the build node's channel. The live + // App is kept in a per-JVM static registry (co-located with the tunnel on the agent) so teardown + // can find and stop it again without serializing the handle back across the channel. + // ------------------------------------------------------------------------------------------ + + /** Tunnels running in this JVM, keyed by tunnel identifier. Lives in the agent's static space. */ + private static final ConcurrentHashMap RUNNING_TUNNELS = new ConcurrentHashMap<>(); + + /** Monotonic suffix so multiple generated identifiers within one run stay distinct. */ + private static final AtomicInteger TUNNEL_SEQ = new AtomicInteger(); + + /** + * Generates a tunnel identifier unique to a build (and to each tunnel within it), so that + * parallel tunnels on the same node stay isolated. + */ + public static String generateTunnelIdentifier(String jobFullName, int buildNumber) { + return "jenkins-" + jobFullName.replace('/', '-') + "-" + buildNumber + "-" + TUNNEL_SEQ.incrementAndGet(); + } + + /** + * Boots a tunnel on the given channel's node and registers it under {@code tunnelIdentifier}. + * Credentials are passed as already-decrypted strings (only plaintext crosses the encrypted + * remoting link, never the credential object). + */ + public static void startOnChannel(VirtualChannel channel, String key, String secret, String options, + String tunnelIdentifier, TaskListener listener) throws Exception { + channel.call(new StartTunnelHandler(key, secret, options, tunnelIdentifier, listener)); + } + + /** + * Stops the tunnel registered under {@code tunnelIdentifier} on the given channel's node. + * Best-effort: a {@code null} channel (offline agent) or a remoting failure is tolerated so a + * teardown never fails the build; an offline agent is reported to the log. + */ + public static void stopOnChannel(VirtualChannel channel, String tunnelIdentifier, TaskListener listener) { + if (channel == null) { + RUNNING_TUNNELS.remove(tunnelIdentifier); + if (listener != null) { + listener.getLogger().println("[TestingBot] Agent offline: remote teardown of tunnel " + + tunnelIdentifier + " could not be performed. It may still be running on the agent " + + "and should stop when the agent process exits (best-effort cleanup)."); + } + return; + } + try { + channel.call(new StopTunnelHandler(tunnelIdentifier, listener)); + } catch (InterruptedException ie) { + // Restore the interrupt status; teardown stays best-effort and does not rethrow. + Thread.currentThread().interrupt(); + if (listener != null) { + listener.getLogger().println("[TestingBot] Interrupted while stopping tunnel " + tunnelIdentifier); + } + } catch (Exception e) { + // Best-effort, but do not silently discard the failure — record why teardown could not complete. + if (listener != null) { + listener.getLogger().println("[TestingBot] Failed to stop tunnel " + tunnelIdentifier + ": " + e); + } + } + } + + private static final class StartTunnelHandler extends MasterToSlaveCallable { + + private static final long serialVersionUID = 1L; + private final String key; + private final String secret; + private final String tunnelOptions; + private final String tunnelIdentifier; + private final TaskListener listener; + + StartTunnelHandler(String key, String secret, String tunnelOptions, String tunnelIdentifier, TaskListener listener) { + this.key = key; + this.secret = secret; + this.tunnelOptions = tunnelOptions; + this.tunnelIdentifier = tunnelIdentifier; + this.listener = listener; + } + + @Override + public Void call() throws Exception { + App app = new App(); + // Reserve the identifier before booting: a duplicate identifier is rejected rather than + // silently overwriting (and leaking) an existing tunnel, and teardown can find and stop + // the App even while it is still booting. + App previous = RUNNING_TUNNELS.putIfAbsent(tunnelIdentifier, app); + if (previous != null) { + try { + app.stop(); + } catch (Exception ignored) { + // best effort + } + throw new IllegalStateException( + "A TestingBot tunnel with identifier '" + tunnelIdentifier + "' is already running"); + } + try { + start(app, key, secret, tunnelOptions, tunnelIdentifier, listener); + } catch (Exception e) { + // Startup failed: remove only our own registry entry (leaving any concurrent owner + // untouched) and stop the App so we don't leak a half-booted tunnel. + RUNNING_TUNNELS.remove(tunnelIdentifier, app); + try { + app.stop(); + } catch (Exception ignored) { + // best effort + } + throw e; + } + return null; + } + } + + private static final class StopTunnelHandler extends MasterToSlaveCallable { + + private static final long serialVersionUID = 1L; + private final String tunnelIdentifier; + private final TaskListener listener; + + StopTunnelHandler(String tunnelIdentifier, TaskListener listener) { + this.tunnelIdentifier = tunnelIdentifier; + this.listener = listener; + } + + @Override + public Void call() { + App app = RUNNING_TUNNELS.remove(tunnelIdentifier); + stop(app, listener); + return null; + } + } } diff --git a/src/main/java/testingbot/pipeline/TestingBotTunnelStep.java b/src/main/java/testingbot/pipeline/TestingBotTunnelStep.java index ce98eb4..b5b7c19 100644 --- a/src/main/java/testingbot/pipeline/TestingBotTunnelStep.java +++ b/src/main/java/testingbot/pipeline/TestingBotTunnelStep.java @@ -20,10 +20,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; import jenkins.model.Jenkins; -import jenkins.security.MasterToSlaveCallable; import org.jenkinsci.plugins.workflow.steps.BodyExecution; import org.jenkinsci.plugins.workflow.steps.BodyExecutionCallback; import org.jenkinsci.plugins.workflow.steps.BodyInvoker; @@ -46,16 +43,6 @@ public class TestingBotTunnelStep extends Step { /** Env var exposing the (possibly auto-generated) tunnel identifier to the build. */ public static final String TESTINGBOT_TUNNEL_IDENTIFIER = "TESTINGBOT_TUNNEL_IDENTIFIER"; - /** - * Tunnels currently running in this JVM, keyed by tunnel identifier. Replaces the - * previous single static {@code App}, which caused concurrent executions on the same - * node to corrupt and tear down each other's tunnel. - */ - private static final ConcurrentHashMap RUNNING_TUNNELS = new ConcurrentHashMap<>(); - - /** Monotonic suffix so multiple tunnels within one run get distinct generated identifiers. */ - private static final AtomicInteger TUNNEL_SEQ = new AtomicInteger(); - private String options; private String credentialsId; @@ -125,59 +112,6 @@ public ListBoxModel doFillCredentialsIdItems(final @AncestorInPath Item context) } } - private static final class TbStartTunnelHandler extends MasterToSlaveCallable { - - private final String key; - private final String secret; - private final String tunnelOptions; - private final String tunnelIdentifier; - private final TaskListener listener; - - TbStartTunnelHandler(String key, String secret, String tunnelOptions, String tunnelIdentifier, TaskListener listener) { - this.key = key; - this.secret = secret; - this.tunnelOptions = tunnelOptions; - this.tunnelIdentifier = tunnelIdentifier; - this.listener = listener; - } - - @Override - public Void call() throws Exception { - App app = new App(); - try { - TunnelManager.start(app, key, secret, tunnelOptions, tunnelIdentifier, listener); - } catch (Exception e) { - // Startup failed after the App was created; stop it so we don't leak a half-booted tunnel. - try { - app.stop(); - } catch (Exception ignored) { - // best effort - } - throw e; - } - RUNNING_TUNNELS.put(tunnelIdentifier, app); - return null; - } - } - - private static final class TbStopTunnelHandler extends MasterToSlaveCallable { - - private final String tunnelIdentifier; - private final TaskListener listener; - - TbStopTunnelHandler(String tunnelIdentifier, TaskListener listener) { - this.tunnelIdentifier = tunnelIdentifier; - this.listener = listener; - } - - @Override - public Void call() { - App app = RUNNING_TUNNELS.remove(tunnelIdentifier); - TunnelManager.stop(app, listener); - return null; - } - } - public static class TestingBotTunnelStepExecution extends StepExecution { private static final long serialVersionUID = 1L; @@ -228,7 +162,7 @@ public boolean start() throws Exception { String userIdentifier = TunnelManager.extractTunnelIdentifier(options); tunnelIdentifier = (userIdentifier != null && !userIdentifier.isEmpty()) ? userIdentifier - : "jenkins-" + run.getParent().getFullName().replace('/', '-') + "-" + run.getNumber() + "-" + TUNNEL_SEQ.incrementAndGet(); + : TunnelManager.generateTunnelIdentifier(run.getParent().getFullName(), run.getNumber()); // Resolve secrets on the controller; only plaintext crosses the channel (over the encrypted remoting link). String key = tbCredentials.getKey(); @@ -249,7 +183,7 @@ public boolean start() throws Exception { env.put("SELENIUM_HOST", hubHost); env.put("SELENIUM_PORT", hubPort); - requireChannel(computer).call(new TbStartTunnelHandler(key, secret, options, tunnelIdentifier, listener)); + TunnelManager.startOnChannel(requireChannel(computer), key, secret, options, tunnelIdentifier, listener); try { body = getContext().newBodyInvoker() @@ -278,21 +212,7 @@ private static VirtualChannel requireChannel(Computer computer) throws Exception /** Best-effort tunnel teardown that tolerates an offline agent (no channel). */ private static void stopTunnelQuietly(Computer computer, String tunnelIdentifier, TaskListener listener) { - VirtualChannel channel = computer == null ? null : computer.getChannel(); - if (channel == null) { - RUNNING_TUNNELS.remove(tunnelIdentifier); - if (listener != null) { - listener.getLogger().println("[TestingBot] Agent offline: remote teardown of tunnel " - + tunnelIdentifier + " could not be performed. It may still be running on the agent " - + "and should stop when the agent process exits (best-effort cleanup)."); - } - return; - } - try { - channel.call(new TbStopTunnelHandler(tunnelIdentifier, listener)); - } catch (Exception ignored) { - // best effort - } + TunnelManager.stopOnChannel(computer == null ? null : computer.getChannel(), tunnelIdentifier, listener); } @Override 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. +