EVENT_COUNTS_TRACKER = new ConcurrentHashMap<>();
+ private static final long TTL_MILLIS = TimeUnit.MINUTES.toMillis(10);
+
+ @VisibleForTesting
+ record EventCountWithTTL(int count, long lastUpdated) { }
+
+ /**
+ * This method is retained only because it is an abstract method.
+ * It is no longer used to determine event delivery to subscribers.
+ * Instead, {@link #isInterestedIn} and {@link #events()} are now used to
+ * decide whether an event should be delivered to a subscriber.
+ * @see com.cloudbees.jenkins.GitHubWebHook#doIndex
+ */
+ @Override
+ protected boolean isApplicable(@Nullable Item item) {
+ return false;
+ }
+
+ /**
+ * Subscribe to events that can trigger some kind of action within Jenkins, such as repository scan, build launch,
+ * etc.
+ *
+ * There are about 63 specific events mentioned in the {@link GHEvent} enum, but not all of them are useful in
+ * Jenkins. Subscribing to and tracking them in duplicates tracker would cause an increase in memory usage, and
+ * those events' occurrences are likely larger than those that cause an action in Jenkins.
+ *
+ *
+ * Documentation reference (as also referenced in {@link GHEvent})
+ * */
+ @Override
+ protected Set events() {
+ return immutableEnumSet(
+ GHEvent.CHECK_RUN, // associated with GitHub action Re-run button to trigger build
+ GHEvent.CHECK_SUITE, // associated with GitHub action Re-run button to trigger build
+ GHEvent.CREATE, // branch or tag creation
+ GHEvent.DELETE, // branch or tag deletion
+ GHEvent.PULL_REQUEST, // PR creation (also PR close or merge)
+ GHEvent.PUSH // commit push
+ );
+ }
+
+ @Override
+ protected void onEvent(final GHSubscriberEvent event) {
+ String eventGuid = event.getEventGuid();
+ if (eventGuid == null) {
+ return;
+ }
+ long now = Instant.now().toEpochMilli();
+ EVENT_COUNTS_TRACKER.compute(
+ eventGuid, (key, value) -> new EventCountWithTTL(value == null ? 1 : value.count() + 1, now));
+ }
+
+ public static Map getDuplicateEventCounts() {
+ return EVENT_COUNTS_TRACKER.entrySet().stream()
+ .filter(entry -> entry.getValue().count() > 1)
+ .collect(Collectors.toMap(
+ Map.Entry::getKey, entry -> entry.getValue().count()));
+ }
+
+ @VisibleForTesting
+ static void cleanUpOldEntries(long now) {
+ EVENT_COUNTS_TRACKER
+ .entrySet()
+ .removeIf(entry -> (now - entry.getValue().lastUpdated()) > TTL_MILLIS);
+ }
+
+ /**
+ * Only for testing purpose
+ */
+ @VisibleForTesting
+ static Map getEventCountsTracker() {
+ return new ConcurrentHashMap<>(EVENT_COUNTS_TRACKER);
+ }
+
+ @SuppressWarnings("unused")
+ @Extension
+ public static class EventCountTrackerCleanup extends PeriodicWork {
+
+ /**
+ * At present, as the {@link #TTL_MILLIS} is set to 10 minutes, we consider half of it for cleanup.
+ * This recurrence period is chosen to balance removing stale entries from accumulating in memory vs.
+ * additional load on Jenkins due to a new periodic job execution.
+ *
+ * If we want to keep the stale entries to a minimum, there appear to be three different ways to achieve this:
+ *
+ * - Increasing the frequency of this periodic task, which will contribute to load
+ * - Event-driven cleanup: for every event from GH, clean up expired entries (need to use
+ * better data structures and algorithms; simply calling the current {@link #cleanUpOldEntries} will
+ * result in {@code O(n)} for every {@code insert}, which may lead to slowness in this hot code path)
+ * - Adaptive cleanup: based on the number of stale entries being seen, the system itself will adjust
+ * the periodic task's frequency (if such adaptive scheduling does not already exist in Jenkins core,
+ * this wouldn't be a good idea to implement here)
+ *
+ *
+ */
+ @Override
+ public long getRecurrencePeriod() {
+ return TTL_MILLIS / 2;
+ }
+
+ @Override
+ protected void doRun() {
+ LOGGER.log(
+ Level.FINE,
+ () -> "Cleaning up entries older than " + TTL_MILLIS + "ms, remaining entries: "
+ + EVENT_COUNTS_TRACKER.size());
+ cleanUpOldEntries(Instant.now().toEpochMilli());
+ }
+ }
+}
diff --git a/src/main/resources/org/jenkinsci/plugins/github/Messages.properties b/src/main/resources/org/jenkinsci/plugins/github/Messages.properties
index 7263d17ac..c952d87ad 100644
--- a/src/main/resources/org/jenkinsci/plugins/github/Messages.properties
+++ b/src/main/resources/org/jenkinsci/plugins/github/Messages.properties
@@ -11,3 +11,8 @@ github.trigger.check.method.warning.details=The webhook for repo {0}/{1} on {2}
More info can be found on the global configuration page. This message will be dismissed if Jenkins receives \
a PING event from repo webhook or if you add the repo to the ignore list in the global configuration.
unknown.error=Unknown error
+duplicate.events.administrative.monitor.displayname=GitHub Duplicate Events
+duplicate.events.administrative.monitor.description=Warns about duplicate events received from GitHub.
+duplicate.events.administrative.monitor.blurb=Duplicate events were received from GitHub, possibly due to \
+ misconfiguration (e.g., multiple webhooks targeting the same Jenkins controller at the repository or organization \
+ level), potentially causing redundant job executions.
diff --git a/src/main/resources/org/jenkinsci/plugins/github/admin/GitHubDuplicateEventsMonitor/description.jelly b/src/main/resources/org/jenkinsci/plugins/github/admin/GitHubDuplicateEventsMonitor/description.jelly
new file mode 100644
index 000000000..11cde3e78
--- /dev/null
+++ b/src/main/resources/org/jenkinsci/plugins/github/admin/GitHubDuplicateEventsMonitor/description.jelly
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/main/resources/org/jenkinsci/plugins/github/admin/GitHubDuplicateEventsMonitor/message.jelly b/src/main/resources/org/jenkinsci/plugins/github/admin/GitHubDuplicateEventsMonitor/message.jelly
new file mode 100644
index 000000000..d67740516
--- /dev/null
+++ b/src/main/resources/org/jenkinsci/plugins/github/admin/GitHubDuplicateEventsMonitor/message.jelly
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/src/test/java/org/jenkinsci/plugins/github/admin/GitHubDuplicateEventsMonitorTest.java b/src/test/java/org/jenkinsci/plugins/github/admin/GitHubDuplicateEventsMonitorTest.java
new file mode 100644
index 000000000..10b505769
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/github/admin/GitHubDuplicateEventsMonitorTest.java
@@ -0,0 +1,98 @@
+package org.jenkinsci.plugins.github.admin;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.not;
+
+import java.io.IOException;
+import java.net.URL;
+
+import org.htmlunit.HttpMethod;
+
+import org.htmlunit.WebRequest;
+import org.jenkinsci.plugins.github.Messages;
+import org.jenkinsci.plugins.github.extension.GHEventsSubscriber;
+import org.jenkinsci.plugins.github.webhook.subscriber.DuplicateEventsSubscriber;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.jvnet.hudson.test.JenkinsRule;
+import org.jvnet.hudson.test.JenkinsRule.WebClient;
+import org.mockito.Mockito;
+
+public class GitHubDuplicateEventsMonitorTest {
+
+ @Rule
+ public JenkinsRule j = new JenkinsRule();
+ private WebClient wc;
+
+ @Before
+ public void setUp() throws Exception {
+ j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
+ wc = j.createWebClient();
+ wc.login("admin", "admin");
+ }
+
+ @Test
+ public void testAdminMonitorDisplaysForDuplicateEvents() throws Exception {
+ try (var mockSubscriber = Mockito.mockStatic(GHEventsSubscriber.class)) {
+ var subscribers = j.jenkins.getExtensionList(GHEventsSubscriber.class);
+ var nonDuplicateSubscribers = subscribers.stream()
+ .filter(e -> !(e instanceof DuplicateEventsSubscriber))
+ .toList();
+ nonDuplicateSubscribers.forEach(subscribers::remove);
+ mockSubscriber.when(GHEventsSubscriber::all).thenReturn(subscribers);
+
+ // to begin with, monitor doesn't show automatically
+ assertMonitorNotDisplayed();
+
+ // normal case: unique events don't cause admin monitor
+ sendGHEvents(wc, "event1");
+ sendGHEvents(wc, "event2");
+ assertMonitorNotDisplayed();
+
+ // duplicate events cause admin monitor
+ sendGHEvents(wc, "event3");
+ sendGHEvents(wc, "event3");
+ assertMonitorDisplayed();
+ }
+ }
+
+ private void sendGHEvents(WebClient wc, String eventGuid) throws IOException {
+ wc.addRequestHeader("Content-Type", "application/json");
+ wc.addRequestHeader("X-GitHub-Delivery", eventGuid);
+ wc.addRequestHeader("X-Github-Event", "push");
+ String url = j.getURL() + "/github-webhook/";
+ String content = """
+ {
+ "repository":
+ {
+ "url": "http://dummy",
+ "html_url": "http://dummy"
+ },
+ "pusher":
+ {
+ "name": "dummy",
+ "email": "dummy@dummy.com"
+ }
+ }
+ """;
+ var webRequest = new WebRequest(new URL(url), HttpMethod.POST);
+ webRequest.setRequestBody(content);
+ wc.getPage(webRequest).getWebResponse();
+ }
+
+ private void assertMonitorNotDisplayed() throws IOException {
+ String manageUrl = j.getURL() + "/manage";
+ assertThat(
+ wc.getPage(manageUrl).getWebResponse().getContentAsString(),
+ not(containsString(Messages.duplicate_events_administrative_monitor_blurb())));
+ }
+
+ private void assertMonitorDisplayed() throws IOException {
+ String manageUrl = j.getURL() + "/manage";
+ assertThat(
+ wc.getPage(manageUrl).getWebResponse().getContentAsString(),
+ containsString(Messages.duplicate_events_administrative_monitor_blurb()));
+ }
+}
diff --git a/src/test/java/org/jenkinsci/plugins/github/webhook/subscriber/DuplicateEventsSubscriberTest.java b/src/test/java/org/jenkinsci/plugins/github/webhook/subscriber/DuplicateEventsSubscriberTest.java
new file mode 100644
index 000000000..3e35d9403
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/github/webhook/subscriber/DuplicateEventsSubscriberTest.java
@@ -0,0 +1,69 @@
+package org.jenkinsci.plugins.github.webhook.subscriber;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+import java.lang.reflect.Field;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.util.Map;
+import java.util.Set;
+import org.jenkinsci.plugins.github.extension.GHSubscriberEvent;
+import org.junit.Before;
+import org.junit.Test;
+import org.kohsuke.github.GHEvent;
+import org.mockito.Mockito;
+
+
+public class DuplicateEventsSubscriberTest {
+
+ @Before
+ public void setUp() throws NoSuchFieldException, IllegalAccessException {
+ // make sure the static hashmap is empty
+ Field eventCountTracker = DuplicateEventsSubscriber.class.getDeclaredField("EVENT_COUNTS_TRACKER");
+ eventCountTracker.setAccessible(true);
+ ((Map) eventCountTracker.get(DuplicateEventsSubscriber.class)).clear();
+ }
+
+ @Test
+ public void shouldReturnEventsWithCountMoreThanOne() {
+ var subscriber = new DuplicateEventsSubscriber();
+ subscriber.onEvent(new GHSubscriberEvent("1", "origin", GHEvent.PUSH, "payload"));
+ subscriber.onEvent(new GHSubscriberEvent("1", "origin", GHEvent.PUSH, "payload"));
+ subscriber.onEvent(new GHSubscriberEvent("2", "origin", GHEvent.PUSH, "payload"));
+ subscriber.onEvent(new GHSubscriberEvent("3", "origin", GHEvent.PUSH, "payload"));
+ assertThat(DuplicateEventsSubscriber.getDuplicateEventCounts(), is(Map.of("1", 2)));
+ assertThat(DuplicateEventsSubscriber.getEventCountsTracker().keySet(), is(Set.of("1", "2", "3")));
+
+ // also notice the `null` guid event is ignored
+ subscriber.onEvent(new GHSubscriberEvent(null, "origin", GHEvent.PUSH, "payload"));
+ assertThat(DuplicateEventsSubscriber.getEventCountsTracker().keySet(), is(Set.of("1", "2", "3")));
+ }
+
+ @Test
+ public void shouldCleanupEventsOlderThanTTLMills() {
+ var subscriber = new DuplicateEventsSubscriber();
+ Instant past = OffsetDateTime.parse("2021-01-01T00:00:00Z").toInstant();
+ Instant later = OffsetDateTime.parse("2021-01-01T11:00:00Z").toInstant();
+ try (var mockedInstant = Mockito.mockStatic(Instant.class)) {
+ mockedInstant.when(Instant::now).thenReturn(past);
+ // add two events in `past` instant
+ subscriber.onEvent(new GHSubscriberEvent("1", "origin", GHEvent.PUSH, "payload"));
+ subscriber.onEvent(new GHSubscriberEvent("2", "origin", GHEvent.PUSH, "payload"));
+ assertThat(DuplicateEventsSubscriber.getEventCountsTracker().size(), is(2));
+
+ // add a new event in `later` instant
+ mockedInstant.when(Instant::now).thenReturn(later);
+ subscriber.onEvent(new GHSubscriberEvent("3", "origin", GHEvent.PUSH, "payload"));
+
+ // run the cleanup
+ DuplicateEventsSubscriber.cleanUpOldEntries(later.toEpochMilli());
+
+ // assert only the new event is present, and old events are cleaned up
+ var tracker = DuplicateEventsSubscriber.getEventCountsTracker();
+ assertThat(tracker.size(), is(1));
+ assertThat(tracker.get("3").count(), is(1));
+ assertThat(tracker.get("3").lastUpdated(), is(later.toEpochMilli()));
+ }
+ }
+}