Skip to content

Commit 43b6f8e

Browse files
authored
[JENKINS-76156] Add webhook processor listener of incoming payload (#1121)
Add an extension point to notify the listener of all processing steps for each incoming webhook, allowing it to perform additional operations. Each listener runs asynchronously with respect to the webhook's processing, but receives processor events sequentially and in an ordered manner.
1 parent 88e3abc commit 43b6f8e

File tree

7 files changed

+284
-68
lines changed

7 files changed

+284
-68
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* The MIT License
3+
*
4+
* Copyright (c) 2025, Nikolas Falco
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
*/
24+
package com.cloudbees.jenkins.plugins.bitbucket.api.webhook;
25+
26+
import com.cloudbees.jenkins.plugins.bitbucket.api.endpoint.BitbucketEndpoint;
27+
import edu.umd.cs.findbugs.annotations.NonNull;
28+
import hudson.ExtensionPoint;
29+
30+
/**
31+
* Listener for {@link BitbucketWebhookProcessor} to receive notification about
32+
* each steps done by the matching processor for an incoming webhook.
33+
*/
34+
public interface BitbucketWebhookProcessorListener extends ExtensionPoint {
35+
36+
/**
37+
* Notify when the processor has been matches.
38+
*
39+
* @param processor class
40+
*/
41+
void onStart(@NonNull Class<? extends BitbucketWebhookProcessor> processor);
42+
43+
/**
44+
* Notify after the processor has processed the incoming webhook payload.
45+
*
46+
* @param eventType of incoming request
47+
* @param payload content that comes with incoming request
48+
* @param endpoint that match the incoming request
49+
*/
50+
void onProcess(@NonNull String eventType, @NonNull String payload, @NonNull BitbucketEndpoint endpoint);
51+
52+
/**
53+
* Notify of failure while processing the incoming webhook.
54+
*
55+
* @param failure exception raised by webhook consumer or by processor.
56+
*/
57+
void onFailure(@NonNull BitbucketWebhookProcessorException failure);
58+
}

src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/BitbucketSCMSourcePushHookReceiver.java

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import com.cloudbees.jenkins.plugins.bitbucket.api.endpoint.BitbucketEndpointProvider;
2828
import com.cloudbees.jenkins.plugins.bitbucket.api.webhook.BitbucketWebhookProcessor;
2929
import com.cloudbees.jenkins.plugins.bitbucket.api.webhook.BitbucketWebhookProcessorException;
30+
import edu.umd.cs.findbugs.annotations.NonNull;
3031
import hudson.Extension;
3132
import hudson.ExtensionList;
3233
import hudson.model.UnprotectedRootAction;
@@ -90,14 +91,17 @@ public String getUrlName() {
9091
* @throws IOException if there is any issue reading the HTTP content payload.
9192
*/
9293
public HttpResponse doNotify(StaplerRequest2 req) throws IOException {
94+
WebhookProcessorListenersHandler listenersHandler = new WebhookProcessorListenersHandler();
95+
9396
try {
9497
Map<String, String> reqHeaders = getHeaders(req);
9598
MultiValuedMap<String, String> reqParameters = getParameters(req);
9699
BitbucketWebhookProcessor hookProcessor = getHookProcessor(reqHeaders, reqParameters);
100+
listenersHandler.onStart(hookProcessor.getClass());
97101

98102
String body = IOUtils.toString(req.getInputStream(), StandardCharsets.UTF_8);
99103
if (StringUtils.isEmpty(body)) {
100-
return HttpResponses.error(HttpServletResponse.SC_BAD_REQUEST, "Payload is empty.");
104+
throw new BitbucketWebhookProcessorException(HttpServletResponse.SC_BAD_REQUEST, "Payload is empty.");
101105
}
102106

103107
String serverURL = hookProcessor.getServerURL(Collections.unmodifiableMap(reqHeaders), MultiMapUtils.unmodifiableMultiValuedMap(reqParameters));
@@ -106,7 +110,7 @@ public HttpResponse doNotify(StaplerRequest2 req) throws IOException {
106110
.orElse(null);
107111
if (endpoint == null) {
108112
logger.log(Level.SEVERE, "No configured bitbucket endpoint found for {0}.", serverURL);
109-
return HttpResponses.error(HttpServletResponse.SC_BAD_REQUEST, "No bitbucket endpoint found for " + serverURL);
113+
throw new BitbucketWebhookProcessorException(HttpServletResponse.SC_BAD_REQUEST, "No bitbucket endpoint found for " + serverURL);
110114
}
111115

112116
logger.log(Level.FINE, "Payload endpoint host {0}, request endpoint host {1}", new Object[] { endpoint, req.getRemoteAddr() });
@@ -116,14 +120,16 @@ public HttpResponse doNotify(StaplerRequest2 req) throws IOException {
116120
String eventType = hookProcessor.getEventType(Collections.unmodifiableMap(reqHeaders), MultiMapUtils.unmodifiableMultiValuedMap(reqParameters));
117121

118122
hookProcessor.process(eventType, body, context, endpoint);
123+
listenersHandler.onProcess(eventType, body, endpoint);
119124
} catch(BitbucketWebhookProcessorException e) {
125+
listenersHandler.onFailure(e);
120126
return HttpResponses.error(e.getHttpCode(), e.getMessage());
121127
}
122128
return HttpResponses.ok();
123129
}
124130

125-
private BitbucketWebhookProcessor getHookProcessor(Map<String, String> reqHeaders,
126-
MultiValuedMap<String, String> reqParameters) {
131+
@NonNull
132+
private BitbucketWebhookProcessor getHookProcessor(Map<String, String> reqHeaders, MultiValuedMap<String, String> reqParameters) {
127133
BitbucketWebhookProcessor hookProcessor;
128134

129135
List<BitbucketWebhookProcessor> matchingProcessors = getHookProcessors()
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* The MIT License
3+
*
4+
* Copyright (c) 2025, Nikolas Falco
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
*/
24+
package com.cloudbees.jenkins.plugins.bitbucket.hooks;
25+
26+
import com.cloudbees.jenkins.plugins.bitbucket.api.endpoint.BitbucketEndpoint;
27+
import com.cloudbees.jenkins.plugins.bitbucket.api.webhook.BitbucketWebhookProcessor;
28+
import com.cloudbees.jenkins.plugins.bitbucket.api.webhook.BitbucketWebhookProcessorException;
29+
import com.cloudbees.jenkins.plugins.bitbucket.api.webhook.BitbucketWebhookProcessorListener;
30+
import hudson.ExtensionList;
31+
import hudson.triggers.SafeTimerTask;
32+
import hudson.util.DaemonThreadFactory;
33+
import hudson.util.NamingThreadFactory;
34+
import java.util.List;
35+
import java.util.concurrent.ExecutorService;
36+
import java.util.concurrent.Executors;
37+
import java.util.function.Consumer;
38+
import java.util.logging.Level;
39+
import java.util.logging.Logger;
40+
41+
public class WebhookProcessorListenersHandler implements BitbucketWebhookProcessorListener {
42+
private static ExecutorService executorService;
43+
private static final Logger logger = Logger.getLogger(WebhookProcessorListenersHandler.class.getName());
44+
45+
// We need a single thread executor to run webhooks operations in background
46+
// but in order.
47+
private static synchronized ExecutorService getExecutorService() {
48+
if (executorService == null) {
49+
executorService = Executors.newSingleThreadExecutor(new NamingThreadFactory(new DaemonThreadFactory(), WebhookProcessorListenersHandler.class.getName()));
50+
}
51+
return executorService;
52+
}
53+
54+
private List<BitbucketWebhookProcessorListener> listeners;
55+
56+
public WebhookProcessorListenersHandler() {
57+
listeners = ExtensionList.lookup(BitbucketWebhookProcessorListener.class);
58+
}
59+
60+
@Override
61+
public void onStart(Class<? extends BitbucketWebhookProcessor> processorClass) {
62+
execute(listener -> listener.onStart(processorClass));
63+
}
64+
65+
@Override
66+
public void onFailure(BitbucketWebhookProcessorException e) {
67+
execute(listener -> listener.onFailure(e));
68+
}
69+
70+
@Override
71+
public void onProcess(String eventType, String body, BitbucketEndpoint endpoint) {
72+
execute(listener -> listener.onProcess(eventType, body, endpoint));
73+
}
74+
75+
private void execute(Consumer<BitbucketWebhookProcessorListener> predicate) {
76+
getExecutorService().submit(new SafeTimerTask() {
77+
@Override
78+
public void doRun() {
79+
listeners.forEach(listener -> {
80+
String listenerName = listener.getClass().getName();
81+
logger.log(Level.FINEST, () -> "Processing listener " + listenerName);
82+
try {
83+
predicate.accept(listener);
84+
logger.log(Level.FINEST, () -> "Processing listener " + listenerName + " completed");
85+
} catch (Exception e) {
86+
logger.log(Level.SEVERE, e, () -> "Processing failed on listener " + listenerName);
87+
}
88+
});
89+
}
90+
});
91+
}
92+
}

src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/webhook/AbstractBitbucketWebhookConfigurationBuilderImpl.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import com.cloudbees.jenkins.plugins.bitbucket.api.webhook.BitbucketWebhookConfigurationBuilder;
2727
import com.cloudbees.jenkins.plugins.bitbucket.api.webhook.NativeBitbucketWebhookConfigurationBuilder;
2828
import edu.umd.cs.findbugs.annotations.NonNull;
29+
import hudson.Util;
2930
import org.kohsuke.accmod.Restricted;
3031
import org.kohsuke.accmod.restrictions.NoExternalUse;
3132

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

3940
@Override
4041
public NativeBitbucketWebhookConfigurationBuilder autoManaged(@NonNull String credentialsId) {
41-
this.credentialsId = credentialsId;
42+
this.credentialsId = Util.fixEmptyAndTrim(credentialsId);
4243
return this;
4344
}
4445

4546
@Override
4647
public NativeBitbucketWebhookConfigurationBuilder signature(@NonNull String credentialsId) {
47-
this.signatureId = credentialsId;
48+
this.signatureId = Util.fixEmptyAndTrim(credentialsId);
4849
return this;
4950
}
5051

src/test/java/com/cloudbees/jenkins/plugins/bitbucket/WebhooksAutoregisterTest.java

Lines changed: 23 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -33,112 +33,73 @@
3333
import com.cloudbees.jenkins.plugins.bitbucket.impl.webhook.cloud.CloudWebhookManager;
3434
import com.cloudbees.jenkins.plugins.bitbucket.impl.webhook.plugin.PluginWebhookManager;
3535
import com.cloudbees.jenkins.plugins.bitbucket.impl.webhook.server.ServerWebhookManager;
36+
import com.cloudbees.jenkins.plugins.bitbucket.test.util.HookProcessorTestUtil;
3637
import com.cloudbees.jenkins.plugins.bitbucket.trait.WebhookRegistrationTrait;
3738
import hudson.model.listeners.ItemListener;
3839
import hudson.util.RingBufferLogHandler;
39-
import java.io.File;
40-
import java.text.MessageFormat;
4140
import java.util.List;
42-
import java.util.logging.LogRecord;
43-
import java.util.logging.Logger;
44-
import java.util.logging.SimpleFormatter;
4541
import jenkins.branch.BranchSource;
4642
import jenkins.branch.DefaultBranchPropertyStrategy;
4743
import jenkins.model.JenkinsLocationConfiguration;
48-
import org.assertj.core.api.Assertions;
49-
import org.junit.jupiter.api.BeforeEach;
5044
import org.junit.jupiter.api.Test;
5145
import org.jvnet.hudson.test.JenkinsRule;
5246
import org.jvnet.hudson.test.junit.jupiter.WithJenkins;
5347

54-
@WithJenkins
5548
class WebhooksAutoregisterTest {
5649

57-
private JenkinsRule j;
58-
59-
@BeforeEach
60-
void init(JenkinsRule rule) {
61-
j = rule;
62-
}
63-
50+
@WithJenkins
6451
@Test
65-
void test_register_webhook_using_item_configuration() throws Exception {
52+
void test_register_webhook_using_item_configuration(JenkinsRule rule) throws Exception {
6653
BitbucketApi client = BitbucketIntegrationClientFactory.getApiMockClient(BitbucketCloudEndpoint.SERVER_URL);
6754
BitbucketMockApiFactory.add(BitbucketCloudEndpoint.SERVER_URL, client);
68-
RingBufferLogHandler log = createJULTestHandler();
55+
RingBufferLogHandler log = HookProcessorTestUtil.createJULTestHandler(WebhookAutoRegisterListener.class,
56+
CloudWebhookManager.class,
57+
ServerWebhookManager.class,
58+
PluginWebhookManager.class);
6959

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

79-
setRootUrl();
69+
setRootUrl(rule);
8070
p.save(); // force item listener to run onUpdated
8171

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

8474
}
8575

76+
@WithJenkins
8677
@Test
87-
void test_register_webhook_using_system_configuration() throws Exception {
78+
void test_register_webhook_using_system_configuration(JenkinsRule rule) throws Exception {
8879
BitbucketApi client = BitbucketIntegrationClientFactory.getApiMockClient(BitbucketCloudEndpoint.SERVER_URL);
8980
BitbucketMockApiFactory.add(BitbucketCloudEndpoint.SERVER_URL, client);
90-
RingBufferLogHandler log = createJULTestHandler();
81+
RingBufferLogHandler log = HookProcessorTestUtil.createJULTestHandler(WebhookAutoRegisterListener.class,
82+
CloudWebhookManager.class,
83+
ServerWebhookManager.class,
84+
PluginWebhookManager.class);
9185

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

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

100-
setRootUrl();
94+
setRootUrl(rule);
10195
ItemListener.fireOnUpdated(p);
10296

103-
waitForLogFileMessage("Registering cloud hook for amuniz/test-repos", log);
104-
105-
}
106-
107-
private void setRootUrl() throws Exception {
108-
JenkinsLocationConfiguration.get().setUrl(j.getURL().toString().replace("localhost", "127.0.0.1"));
109-
}
97+
HookProcessorTestUtil.waitForLogFileMessage(rule, "Registering cloud hook for amuniz/test-repos", log);
11098

111-
private void waitForLogFileMessage(String string, RingBufferLogHandler logs) throws InterruptedException {
112-
File rootDir = j.jenkins.getRootDir();
113-
synchronized (rootDir) {
114-
int limit = 0;
115-
while (limit < 5) {
116-
rootDir.wait(1000);
117-
for (LogRecord r : logs.getView()) {
118-
String message = r.getMessage();
119-
if (r.getParameters() != null) {
120-
message = MessageFormat.format(message, r.getParameters());
121-
}
122-
if (message.contains(string)) {
123-
return;
124-
}
125-
}
126-
limit++;
127-
}
128-
}
129-
Assertions.fail("Expected log not found: " + string);
13099
}
131100

132-
@SuppressWarnings("deprecation")
133-
private RingBufferLogHandler createJULTestHandler() throws SecurityException {
134-
RingBufferLogHandler handler = new RingBufferLogHandler(RingBufferLogHandler.getDefaultRingBufferSize());
135-
SimpleFormatter formatter = new SimpleFormatter();
136-
handler.setFormatter(formatter);
137-
Logger.getLogger(WebhookAutoRegisterListener.class.getName()).addHandler(handler);
138-
Logger.getLogger(CloudWebhookManager.class.getName()).addHandler(handler);
139-
Logger.getLogger(ServerWebhookManager.class.getName()).addHandler(handler);
140-
Logger.getLogger(PluginWebhookManager.class.getName()).addHandler(handler);
141-
return handler;
101+
private void setRootUrl(JenkinsRule rule) throws Exception {
102+
JenkinsLocationConfiguration.get().setUrl(rule.getURL().toString().replace("localhost", "127.0.0.1"));
142103
}
143104

144105
}

0 commit comments

Comments
 (0)