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
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* The MIT License
*
* Copyright (c) 2025, Nikolas Falco
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package com.cloudbees.jenkins.plugins.bitbucket.api.webhook;

import com.cloudbees.jenkins.plugins.bitbucket.api.endpoint.BitbucketEndpoint;
import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.ExtensionPoint;

/**
* Listener for {@link BitbucketWebhookProcessor} to receive notification about
* each steps done by the matching processor for an incoming webhook.
*/
public interface BitbucketWebhookProcessorListener extends ExtensionPoint {

/**
* Notify when the processor has been matches.
*
* @param processor class
*/
void onStart(@NonNull Class<? extends BitbucketWebhookProcessor> processor);

/**
* Notify after the processor has processed the incoming webhook payload.
*
* @param eventType of incoming request
* @param payload content that comes with incoming request
* @param endpoint that match the incoming request
*/
void onProcess(@NonNull String eventType, @NonNull String payload, @NonNull BitbucketEndpoint endpoint);

/**
* Notify of failure while processing the incoming webhook.
*
* @param failure exception raised by webhook consumer or by processor.
*/
void onFailure(@NonNull BitbucketWebhookProcessorException failure);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import com.cloudbees.jenkins.plugins.bitbucket.api.endpoint.BitbucketEndpointProvider;
import com.cloudbees.jenkins.plugins.bitbucket.api.webhook.BitbucketWebhookProcessor;
import com.cloudbees.jenkins.plugins.bitbucket.api.webhook.BitbucketWebhookProcessorException;
import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.Extension;
import hudson.ExtensionList;
import hudson.model.UnprotectedRootAction;
Expand Down Expand Up @@ -90,23 +91,26 @@
* @throws IOException if there is any issue reading the HTTP content payload.
*/
public HttpResponse doNotify(StaplerRequest2 req) throws IOException {
WebhookProcessorListenersHandler listenersHandler = new WebhookProcessorListenersHandler();

try {
Map<String, String> reqHeaders = getHeaders(req);
MultiValuedMap<String, String> reqParameters = getParameters(req);
BitbucketWebhookProcessor hookProcessor = getHookProcessor(reqHeaders, reqParameters);
listenersHandler.onStart(hookProcessor.getClass());

String body = IOUtils.toString(req.getInputStream(), StandardCharsets.UTF_8);
if (StringUtils.isEmpty(body)) {
return HttpResponses.error(HttpServletResponse.SC_BAD_REQUEST, "Payload is empty.");
throw new BitbucketWebhookProcessorException(HttpServletResponse.SC_BAD_REQUEST, "Payload is empty.");
}

String serverURL = hookProcessor.getServerURL(Collections.unmodifiableMap(reqHeaders), MultiMapUtils.unmodifiableMultiValuedMap(reqParameters));
BitbucketEndpoint endpoint = BitbucketEndpointProvider
.lookupEndpoint(serverURL)
.orElse(null);
if (endpoint == null) {
logger.log(Level.SEVERE, "No configured bitbucket endpoint found for {0}.", serverURL);
return HttpResponses.error(HttpServletResponse.SC_BAD_REQUEST, "No bitbucket endpoint found for " + serverURL);
throw new BitbucketWebhookProcessorException(HttpServletResponse.SC_BAD_REQUEST, "No bitbucket endpoint found for " + serverURL);

Check warning on line 113 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/BitbucketSCMSourcePushHookReceiver.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 104-113 are not covered by tests
}

logger.log(Level.FINE, "Payload endpoint host {0}, request endpoint host {1}", new Object[] { endpoint, req.getRemoteAddr() });
Expand All @@ -116,14 +120,16 @@
String eventType = hookProcessor.getEventType(Collections.unmodifiableMap(reqHeaders), MultiMapUtils.unmodifiableMultiValuedMap(reqParameters));

hookProcessor.process(eventType, body, context, endpoint);
listenersHandler.onProcess(eventType, body, endpoint);

Choose a reason for hiding this comment

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

Failures such as

BitbucketPushEvent push = BitbucketCloudWebhookPayload.pushEventFromPayload(payload);
failing to parse the json(?) will still show success

Copy link
Member Author

Choose a reason for hiding this comment

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

That depends on processors. If processor will handle payload without throw exception than listener will be notified as processed with success

} catch(BitbucketWebhookProcessorException e) {
listenersHandler.onFailure(e);
return HttpResponses.error(e.getHttpCode(), e.getMessage());
}
return HttpResponses.ok();
}

private BitbucketWebhookProcessor getHookProcessor(Map<String, String> reqHeaders,
MultiValuedMap<String, String> reqParameters) {
@NonNull
private BitbucketWebhookProcessor getHookProcessor(Map<String, String> reqHeaders, MultiValuedMap<String, String> reqParameters) {
BitbucketWebhookProcessor hookProcessor;

List<BitbucketWebhookProcessor> matchingProcessors = getHookProcessors()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* The MIT License
*
* Copyright (c) 2025, Nikolas Falco
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package com.cloudbees.jenkins.plugins.bitbucket.hooks;

import com.cloudbees.jenkins.plugins.bitbucket.api.endpoint.BitbucketEndpoint;
import com.cloudbees.jenkins.plugins.bitbucket.api.webhook.BitbucketWebhookProcessor;
import com.cloudbees.jenkins.plugins.bitbucket.api.webhook.BitbucketWebhookProcessorException;
import com.cloudbees.jenkins.plugins.bitbucket.api.webhook.BitbucketWebhookProcessorListener;
import hudson.ExtensionList;
import hudson.triggers.SafeTimerTask;
import hudson.util.DaemonThreadFactory;
import hudson.util.NamingThreadFactory;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.logging.Logger;

public class WebhookProcessorListenersHandler implements BitbucketWebhookProcessorListener {
private static ExecutorService executorService;
private static final Logger logger = Logger.getLogger(WebhookProcessorListenersHandler.class.getName());

// We need a single thread executor to run webhooks operations in background
// but in order.
private static synchronized ExecutorService getExecutorService() {
if (executorService == null) {
executorService = Executors.newSingleThreadExecutor(new NamingThreadFactory(new DaemonThreadFactory(), WebhookProcessorListenersHandler.class.getName()));
}
return executorService;
}

private List<BitbucketWebhookProcessorListener> listeners;

public WebhookProcessorListenersHandler() {
listeners = ExtensionList.lookup(BitbucketWebhookProcessorListener.class);
}

@Override
public void onStart(Class<? extends BitbucketWebhookProcessor> processorClass) {
execute(listener -> listener.onStart(processorClass));
}

@Override
public void onFailure(BitbucketWebhookProcessorException e) {
execute(listener -> listener.onFailure(e));
}

@Override
public void onProcess(String eventType, String body, BitbucketEndpoint endpoint) {
execute(listener -> listener.onProcess(eventType, body, endpoint));
}

private void execute(Consumer<BitbucketWebhookProcessorListener> predicate) {
getExecutorService().submit(new SafeTimerTask() {
@Override
public void doRun() {
listeners.forEach(listener -> {
String listenerName = listener.getClass().getName();
logger.log(Level.FINEST, () -> "Processing listener " + listenerName);
try {
predicate.accept(listener);
logger.log(Level.FINEST, () -> "Processing listener " + listenerName + " completed");
} catch (Exception e) {
logger.log(Level.SEVERE, e, () -> "Processing failed on listener " + listenerName);

Check warning on line 86 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/WebhookProcessorListenersHandler.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 85-86 are not covered by tests
}
});
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import com.cloudbees.jenkins.plugins.bitbucket.api.webhook.BitbucketWebhookConfigurationBuilder;
import com.cloudbees.jenkins.plugins.bitbucket.api.webhook.NativeBitbucketWebhookConfigurationBuilder;
import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.Util;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;

Expand All @@ -38,13 +39,13 @@ public abstract class AbstractBitbucketWebhookConfigurationBuilderImpl implement

@Override
public NativeBitbucketWebhookConfigurationBuilder autoManaged(@NonNull String credentialsId) {
this.credentialsId = credentialsId;
this.credentialsId = Util.fixEmptyAndTrim(credentialsId);
return this;
}

@Override
public NativeBitbucketWebhookConfigurationBuilder signature(@NonNull String credentialsId) {
this.signatureId = credentialsId;
this.signatureId = Util.fixEmptyAndTrim(credentialsId);
return this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,112 +33,73 @@
import com.cloudbees.jenkins.plugins.bitbucket.impl.webhook.cloud.CloudWebhookManager;
import com.cloudbees.jenkins.plugins.bitbucket.impl.webhook.plugin.PluginWebhookManager;
import com.cloudbees.jenkins.plugins.bitbucket.impl.webhook.server.ServerWebhookManager;
import com.cloudbees.jenkins.plugins.bitbucket.test.util.HookProcessorTestUtil;
import com.cloudbees.jenkins.plugins.bitbucket.trait.WebhookRegistrationTrait;
import hudson.model.listeners.ItemListener;
import hudson.util.RingBufferLogHandler;
import java.io.File;
import java.text.MessageFormat;
import java.util.List;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import java.util.logging.SimpleFormatter;
import jenkins.branch.BranchSource;
import jenkins.branch.DefaultBranchPropertyStrategy;
import jenkins.model.JenkinsLocationConfiguration;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.jvnet.hudson.test.JenkinsRule;
import org.jvnet.hudson.test.junit.jupiter.WithJenkins;

@WithJenkins
class WebhooksAutoregisterTest {

private JenkinsRule j;

@BeforeEach
void init(JenkinsRule rule) {
j = rule;
}

@WithJenkins
@Test
void test_register_webhook_using_item_configuration() throws Exception {
void test_register_webhook_using_item_configuration(JenkinsRule rule) throws Exception {
BitbucketApi client = BitbucketIntegrationClientFactory.getApiMockClient(BitbucketCloudEndpoint.SERVER_URL);
BitbucketMockApiFactory.add(BitbucketCloudEndpoint.SERVER_URL, client);
RingBufferLogHandler log = createJULTestHandler();
RingBufferLogHandler log = HookProcessorTestUtil.createJULTestHandler(WebhookAutoRegisterListener.class,
CloudWebhookManager.class,
ServerWebhookManager.class,
PluginWebhookManager.class);

MockMultiBranchProjectImpl p = j.jenkins.createProject(MockMultiBranchProjectImpl.class, "test");
MockMultiBranchProjectImpl p = rule.jenkins.createProject(MockMultiBranchProjectImpl.class, "test");
BitbucketSCMSource source = new BitbucketSCMSource("amuniz", "test-repos");
source.setTraits(List.of(new WebhookRegistrationTrait(WebhookRegistration.ITEM)));
BranchSource branchSource = new BranchSource(source);
branchSource.setStrategy(new DefaultBranchPropertyStrategy(null));
p.getSourcesList().add(branchSource);
p.scheduleBuild2(0);
waitForLogFileMessage("Can not register hook. Jenkins root URL is not valid", log);
HookProcessorTestUtil.waitForLogFileMessage(rule, "Can not register hook. Jenkins root URL is not valid", log);

setRootUrl();
setRootUrl(rule);
p.save(); // force item listener to run onUpdated

waitForLogFileMessage("Registering cloud hook for amuniz/test-repos", log);
HookProcessorTestUtil.waitForLogFileMessage(rule, "Registering cloud hook for amuniz/test-repos", log);

}

@WithJenkins
@Test
void test_register_webhook_using_system_configuration() throws Exception {
void test_register_webhook_using_system_configuration(JenkinsRule rule) throws Exception {
BitbucketApi client = BitbucketIntegrationClientFactory.getApiMockClient(BitbucketCloudEndpoint.SERVER_URL);
BitbucketMockApiFactory.add(BitbucketCloudEndpoint.SERVER_URL, client);
RingBufferLogHandler log = createJULTestHandler();
RingBufferLogHandler log = HookProcessorTestUtil.createJULTestHandler(WebhookAutoRegisterListener.class,
CloudWebhookManager.class,
ServerWebhookManager.class,
PluginWebhookManager.class);

BitbucketEndpointConfiguration.get().setEndpoints(List.of(new BitbucketCloudEndpoint(false, 0, 0, new CloudWebhookConfiguration(true, "dummy"))));

MockMultiBranchProjectImpl p = j.jenkins.createProject(MockMultiBranchProjectImpl.class, "test");
MockMultiBranchProjectImpl p = rule.jenkins.createProject(MockMultiBranchProjectImpl.class, "test");
BitbucketSCMSource source = new BitbucketSCMSource( "amuniz", "test-repos");
p.getSourcesList().add(new BranchSource(source));
p.scheduleBuild2(0);
waitForLogFileMessage("Can not register hook. Jenkins root URL is not valid", log);
HookProcessorTestUtil.waitForLogFileMessage(rule, "Can not register hook. Jenkins root URL is not valid", log);

setRootUrl();
setRootUrl(rule);
ItemListener.fireOnUpdated(p);

waitForLogFileMessage("Registering cloud hook for amuniz/test-repos", log);

}

private void setRootUrl() throws Exception {
JenkinsLocationConfiguration.get().setUrl(j.getURL().toString().replace("localhost", "127.0.0.1"));
}
HookProcessorTestUtil.waitForLogFileMessage(rule, "Registering cloud hook for amuniz/test-repos", log);

private void waitForLogFileMessage(String string, RingBufferLogHandler logs) throws InterruptedException {
File rootDir = j.jenkins.getRootDir();
synchronized (rootDir) {
int limit = 0;
while (limit < 5) {
rootDir.wait(1000);
for (LogRecord r : logs.getView()) {
String message = r.getMessage();
if (r.getParameters() != null) {
message = MessageFormat.format(message, r.getParameters());
}
if (message.contains(string)) {
return;
}
}
limit++;
}
}
Assertions.fail("Expected log not found: " + string);
}

@SuppressWarnings("deprecation")
private RingBufferLogHandler createJULTestHandler() throws SecurityException {
RingBufferLogHandler handler = new RingBufferLogHandler(RingBufferLogHandler.getDefaultRingBufferSize());
SimpleFormatter formatter = new SimpleFormatter();
handler.setFormatter(formatter);
Logger.getLogger(WebhookAutoRegisterListener.class.getName()).addHandler(handler);
Logger.getLogger(CloudWebhookManager.class.getName()).addHandler(handler);
Logger.getLogger(ServerWebhookManager.class.getName()).addHandler(handler);
Logger.getLogger(PluginWebhookManager.class.getName()).addHandler(handler);
return handler;
private void setRootUrl(JenkinsRule rule) throws Exception {
JenkinsLocationConfiguration.get().setUrl(rule.getURL().toString().replace("localhost", "127.0.0.1"));
}

}
Loading
Loading