This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 71b9419ab477 CAMEL-25572: camel-github2 - move the consumer position
per routed exchange, route a commit whose author lookup failed (#27756)
71b9419ab477 is described below
commit 71b9419ab4776bf8c1492a37717e45afb02c9e97
Author: allthingssecurity <[email protected]>
AuthorDate: Tue Oct 13 11:44:22 2026 +0530
CAMEL-25572: camel-github2 - move the consumer position per routed
exchange, route a commit whose author lookup failed (#27756)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../github2/consumer/AbstractGitHub2Consumer.java | 11 +
.../component/github2/consumer/CommitConsumer.java | 102 +++--
.../component/github2/consumer/EventsConsumer.java | 9 +-
.../consumer/PullRequestCommentConsumer.java | 7 +-
.../github2/consumer/PullRequestConsumer.java | 10 +-
.../component/github2/consumer/TagConsumer.java | 6 +-
.../consumer/GitHub2ConsumerPositionTest.java | 471 +++++++++++++++++++++
7 files changed, 578 insertions(+), 38 deletions(-)
diff --git
a/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/AbstractGitHub2Consumer.java
b/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/AbstractGitHub2Consumer.java
index 7a630350e294..43b852d4fe7a 100644
---
a/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/AbstractGitHub2Consumer.java
+++
b/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/AbstractGitHub2Consumer.java
@@ -93,9 +93,20 @@ public abstract class AbstractGitHub2Consumer extends
ScheduledBatchPollingConsu
exchange.setProperty(ExchangePropertyKey.BATCH_SIZE, total);
exchange.setProperty(ExchangePropertyKey.BATCH_COMPLETE, index ==
total - 1);
this.pendingExchanges = total - index - 1;
+ onRouting(exchange);
getProcessor().process(exchange);
}
return answer;
}
+
+ /**
+ * Called for each exchange of a poll right before it is handed to the
route, in the order of the batch. A consumer
+ * moves its position (the last id, the names or hashes it has seen) here
and not when it builds the exchanges of a
+ * poll: the batch can end early, when the route is being suspended or
stopped or when {@code maxMessagesPerPoll} is
+ * reached, and what was not handed to the route has to be found again by
the next poll.
+ */
+ protected void onRouting(Exchange exchange) {
+ // noop
+ }
}
diff --git
a/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/CommitConsumer.java
b/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/CommitConsumer.java
index e9164e712535..a408c3ad7e9f 100644
---
a/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/CommitConsumer.java
+++
b/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/CommitConsumer.java
@@ -16,6 +16,7 @@
*/
package org.apache.camel.component.github2.consumer;
+import java.io.IOException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
@@ -23,11 +24,12 @@ import java.util.Queue;
import java.util.concurrent.LinkedBlockingQueue;
import org.apache.camel.Exchange;
-import org.apache.camel.ExchangePropertyKey;
import org.apache.camel.Processor;
import org.apache.camel.component.github2.GitHub2Constants;
import org.apache.camel.component.github2.GitHub2Endpoint;
import org.kohsuke.github.GHCommit;
+import org.kohsuke.github.GHUser;
+import org.kohsuke.github.GitUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -136,11 +138,6 @@ public class CommitConsumer extends
AbstractGitHub2Consumer {
String sha = commit.getSHA1();
if (!commitHashes.contains(sha)) {
newCommits.push(commit);
- // make room when adding new elements
- while (commitHashes.size() > CAPACITY - 1) {
- commitHashes.remove();
- }
- commitHashes.add(sha);
}
}
@@ -148,16 +145,12 @@ public class CommitConsumer extends
AbstractGitHub2Consumer {
while (!newCommits.isEmpty()) {
GHCommit newCommit = newCommits.pop();
Exchange e = createExchange(true);
- if (newCommit.getAuthor() != null) {
-
e.getMessage().setHeader(GitHub2Constants.GITHUB_COMMIT_AUTHOR,
newCommit.getAuthor().getName());
- }
- if (newCommit.getCommitter() != null) {
-
e.getMessage().setHeader(GitHub2Constants.GITHUB_COMMIT_COMMITTER,
newCommit.getCommitter().getName());
- }
+ setUserHeaders(e, newCommit);
e.getMessage().setHeader(GitHub2Constants.GITHUB_COMMIT_SHA,
newCommit.getSHA1());
e.getMessage().setHeader(GitHub2Constants.GITHUB_COMMIT_URL,
newCommit.getHtmlUrl().toString());
if (getEndpoint().isCommitMessageAsBody()) {
-
e.getMessage().setBody(newCommit.getCommitShortInfo().getMessage());
+ GHCommit.ShortInfo info = shortInfo(newCommit);
+ e.getMessage().setBody(info != null ? info.getMessage() :
null);
} else {
e.getMessage().setBody(newCommit);
}
@@ -171,26 +164,75 @@ public class CommitConsumer extends
AbstractGitHub2Consumer {
}
}
- @Override
- public int processBatch(Queue<Object> exchanges) throws Exception {
- int total = exchanges.size();
- int answer = total;
- if (this.maxMessagesPerPoll > 0 && total > this.maxMessagesPerPoll) {
- LOG.debug("Limiting to maximum messages to poll {} as there were
{} messages in this poll.",
- this.maxMessagesPerPoll, total);
- total = this.maxMessagesPerPoll;
+ /**
+ * Sets the headers with the name of the author and of the committer.
{@link GHCommit#getAuthor()} and
+ * {@link GHCommit#getCommitter()} are the only calls of a poll that do
requests of their own for a commit of the
+ * list: one for the full commit and one for each user that is not cached
yet. A commit whose lookup fails (the user
+ * no longer exists, a server or network error) is routed all the same, as
a lookup that always fails would
+ * otherwise fail every poll and hold back the commits that follow. The
header then has the name that git recorded
+ * in the commit, which is part of the list, and is not set when there is
none.
+ */
+ private void setUserHeaders(Exchange exchange, GHCommit commit) {
+ IOException failure = null;
+ try {
+ GHUser author = commit.getAuthor();
+ if (author != null) {
+
exchange.getMessage().setHeader(GitHub2Constants.GITHUB_COMMIT_AUTHOR,
author.getName());
+ }
+ } catch (IOException e) {
+ failure = e;
+ setGitUserHeader(exchange, GitHub2Constants.GITHUB_COMMIT_AUTHOR,
commit, true);
+ }
+ try {
+ GHUser committer = commit.getCommitter();
+ if (committer != null) {
+
exchange.getMessage().setHeader(GitHub2Constants.GITHUB_COMMIT_COMMITTER,
committer.getName());
+ }
+ } catch (IOException e) {
+ failure = failure != null ? failure : e;
+ setGitUserHeader(exchange,
GitHub2Constants.GITHUB_COMMIT_COMMITTER, commit, false);
+ }
+ if (failure != null) {
+ // one line for a commit, without the stack trace. The commit is
routed and then not read again, so this
+ // is not repeated by the following polls
+ LOG.warn("Cannot look up the author or the committer of commit {}
on GitHub, using the names recorded in the"
+ + " commit instead. Caused by: {}",
+ commit.getSHA1(), failure.toString());
}
+ }
- for (int index = 0; index < total && this.isBatchAllowed(); ++index) {
- Exchange exchange = (Exchange) exchanges.poll();
- exchange.setProperty(ExchangePropertyKey.BATCH_INDEX, index);
- exchange.setProperty(ExchangePropertyKey.BATCH_SIZE, total);
- exchange.setProperty(ExchangePropertyKey.BATCH_COMPLETE, index ==
total - 1);
- this.pendingExchanges = total - index - 1;
- this.lastSha =
exchange.getMessage().getHeader(GitHub2Constants.GITHUB_COMMIT_SHA,
String.class);
- getProcessor().process(exchange);
+ private static void setGitUserHeader(Exchange exchange, String header,
GHCommit commit, boolean author) {
+ GHCommit.ShortInfo info = shortInfo(commit);
+ if (info != null) {
+ GitUser user = author ? info.getAuthor() : info.getCommitter();
+ if (user != null && user.getName() != null) {
+ exchange.getMessage().setHeader(header, user.getName());
+ }
}
+ }
- return answer;
+ /**
+ * The data of the git commit (message, author and committer as git
recorded them). It is part of the list of
+ * commits, so this does no request. Returns null in the case that the
commit came without it and it cannot be
+ * loaded either.
+ */
+ private static GHCommit.ShortInfo shortInfo(GHCommit commit) {
+ try {
+ return commit.getCommitShortInfo();
+ } catch (IOException e) {
+ LOG.debug("Cannot load the data of commit {}: {}",
commit.getSHA1(), e.toString());
+ return null;
+ }
+ }
+
+ @Override
+ protected void onRouting(Exchange exchange) {
+ String sha =
exchange.getMessage().getHeader(GitHub2Constants.GITHUB_COMMIT_SHA,
String.class);
+ // make room when adding new elements
+ while (commitHashes.size() > CAPACITY - 1) {
+ commitHashes.remove();
+ }
+ commitHashes.add(sha);
+ this.lastSha = sha;
}
}
diff --git
a/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/EventsConsumer.java
b/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/EventsConsumer.java
index 84924af6a361..d4a23b3dcaa5 100644
---
a/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/EventsConsumer.java
+++
b/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/EventsConsumer.java
@@ -61,8 +61,6 @@ public class EventsConsumer extends AbstractGitHub2Consumer {
Queue<Object> exchanges = new ArrayDeque<>();
if (!newEvents.isEmpty()) {
newEvents.sort((e1, e2) -> Long.compare(e1.getId(), e2.getId()));
- GHEventInfo latestEvent = newEvents.get(newEvents.size() - 1);
- lastEventId = latestEvent.getId();
for (GHEventInfo event : newEvents) {
Exchange e = createExchange(true);
@@ -75,6 +73,13 @@ public class EventsConsumer extends AbstractGitHub2Consumer {
return processBatch(exchanges);
}
+ @Override
+ protected void onRouting(Exchange exchange) {
+ // the events are routed in the order of their ids
+ GHEventInfo event =
exchange.getMessage().getHeader(GitHub2Constants.GITHUB_EVENT_PAYLOAD,
GHEventInfo.class);
+ lastEventId = event.getId();
+ }
+
/**
* Default {@link GitHub2EventFetchStrategy} to fetch events for the
GitHub repository configured on the endpoint.
*/
diff --git
a/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/PullRequestCommentConsumer.java
b/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/PullRequestCommentConsumer.java
index e893048fcf36..e9b8b5c66e62 100644
---
a/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/PullRequestCommentConsumer.java
+++
b/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/PullRequestCommentConsumer.java
@@ -80,9 +80,14 @@ public class PullRequestCommentConsumer extends
AbstractGitHub2Consumer {
e.getIn().setBody(comment);
e.getIn().setHeader(GitHub2Constants.GITHUB_PULLREQUEST,
comment.getParent().getNumber());
exchanges.add(e);
- lastCommentId = comment.getId();
}
return processBatch(exchanges);
}
+
+ @Override
+ protected void onRouting(Exchange exchange) {
+ // the comments are routed in the order of their ids
+ lastCommentId = exchange.getIn().getBody(GHIssueComment.class).getId();
+ }
}
diff --git
a/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/PullRequestConsumer.java
b/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/PullRequestConsumer.java
index 0e57eda434c8..a98effabccf6 100644
---
a/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/PullRequestConsumer.java
+++
b/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/PullRequestConsumer.java
@@ -64,10 +64,6 @@ public class PullRequestConsumer extends
AbstractGitHub2Consumer {
}
}
- if (!newPullRequests.isEmpty()) {
- lastOpenPullRequest = openPullRequests.get(0).getNumber();
- }
-
Queue<Object> exchanges = new ArrayDeque<>();
while (!newPullRequests.isEmpty()) {
GHPullRequest newPullRequest = newPullRequests.pop();
@@ -84,4 +80,10 @@ public class PullRequestConsumer extends
AbstractGitHub2Consumer {
}
return processBatch(exchanges);
}
+
+ @Override
+ protected void onRouting(Exchange exchange) {
+ // the pull requests are routed from the oldest to the newest
+ lastOpenPullRequest =
exchange.getIn().getBody(GHPullRequest.class).getNumber();
+ }
}
diff --git
a/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/TagConsumer.java
b/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/TagConsumer.java
index 9f96005dcb3c..6116ba44ad64 100644
---
a/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/TagConsumer.java
+++
b/components/camel-github2/src/main/java/org/apache/camel/component/github2/consumer/TagConsumer.java
@@ -55,7 +55,6 @@ public class TagConsumer extends AbstractGitHub2Consumer {
for (GHTag tag : getRepository().listTags()) {
if (!tagNames.contains(tag.getName())) {
newTags.push(tag);
- tagNames.add(tag.getName());
}
}
@@ -68,4 +67,9 @@ public class TagConsumer extends AbstractGitHub2Consumer {
}
return processBatch(exchanges);
}
+
+ @Override
+ protected void onRouting(Exchange exchange) {
+ tagNames.add(exchange.getIn().getBody(GHTag.class).getName());
+ }
}
diff --git
a/components/camel-github2/src/test/java/org/apache/camel/component/github2/consumer/GitHub2ConsumerPositionTest.java
b/components/camel-github2/src/test/java/org/apache/camel/component/github2/consumer/GitHub2ConsumerPositionTest.java
new file mode 100644
index 000000000000..f18ef697c460
--- /dev/null
+++
b/components/camel-github2/src/test/java/org/apache/camel/component/github2/consumer/GitHub2ConsumerPositionTest.java
@@ -0,0 +1,471 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.github2.consumer;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.Consumer;
+import org.apache.camel.Endpoint;
+import org.apache.camel.Message;
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.github2.GitHub2Constants;
+import org.apache.camel.support.DefaultPollingConsumerPollStrategy;
+import org.apache.camel.support.ScheduledBatchPollingConsumer;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.kohsuke.github.GHCommit;
+import org.kohsuke.github.GHCommitQueryBuilder;
+import org.kohsuke.github.GHEvent;
+import org.kohsuke.github.GHEventInfo;
+import org.kohsuke.github.GHFileNotFoundException;
+import org.kohsuke.github.GHIssueComment;
+import org.kohsuke.github.GHIssueState;
+import org.kohsuke.github.GHObject;
+import org.kohsuke.github.GHPullRequest;
+import org.kohsuke.github.GHRepository;
+import org.kohsuke.github.GHTag;
+import org.kohsuke.github.GHUser;
+import org.kohsuke.github.GitCommit;
+import org.kohsuke.github.GitHub;
+import org.kohsuke.github.GitUser;
+import org.kohsuke.github.PagedIterable;
+import org.kohsuke.github.PagedIterator;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * The github2 consumers must not move their position (last event id, last
pull request, last comment id, seen tags,
+ * seen commits) past what they did not hand to the route: when a route is
suspended while a poll is being routed, the
+ * rest of that poll has to be routed after the resume. A commit whose author
or committer cannot be looked up is routed
+ * all the same, with the names that git recorded in the commit, and neither
it nor the other commits of the poll are
+ * lost.
+ */
+class GitHub2ConsumerPositionTest extends CamelTestSupport {
+
+ private static final String NOT_SET = "(not set)";
+ private static final String OPTIONS =
"repoOwner=acme&repoName=demo&oauthToken=not-used&initialDelay=0&delay=10";
+
+ // what the (mocked) repository returns; replaced as a whole, so that a
poll sees all the new items at once
+ private volatile List<GHEventInfo> events = List.of();
+ private volatile List<GHPullRequest> pullRequests = List.of();
+ private volatile List<GHTag> tags = List.of();
+ private volatile List<GHCommit> commits = List.of();
+ private final AtomicInteger polls = new AtomicInteger();
+
+ @BindToRegistry(GitHub2Constants.GITHUB_CLIENT)
+ private final GitHub github = createGitHub();
+
+ // counts the polls that ended with an exception
+ @BindToRegistry("countingPollStrategy")
+ private final CountingPollStrategy pollStrategy = new
CountingPollStrategy();
+
+ private final List<String> routed = new CopyOnWriteArrayList<>();
+ // the author and committer headers of the routed commits, by sha
+ private final Map<String, String> authorHeaders = new
ConcurrentHashMap<>();
+ private final Map<String, String> committerHeaders = new
ConcurrentHashMap<>();
+ // the item whose processor suspends its route (once)
+ private volatile String suspendAt;
+ private final AtomicBoolean suspendRequested = new AtomicBoolean();
+ private final AtomicReference<Exception> suspendFailure = new
AtomicReference<>();
+ private volatile Thread suspender;
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("github2:event?" +
OPTIONS).routeId("event").autoStartup(false)
+ .process(e -> route("event", String.valueOf(
+
e.getMessage().getHeader(GitHub2Constants.GITHUB_EVENT_PAYLOAD,
GHEventInfo.class).getId())));
+ from("github2:pullRequest?" +
OPTIONS).routeId("pullRequest").autoStartup(false)
+ .process(e -> route("pullRequest",
+
String.valueOf(e.getMessage().getHeader(GitHub2Constants.GITHUB_PULLREQUEST))));
+ from("github2:pullRequestComment?" +
OPTIONS).routeId("pullRequestComment").autoStartup(false)
+ .process(e -> route("pullRequestComment",
+
String.valueOf(e.getMessage().getBody(GHIssueComment.class).getId())));
+ from("github2:tag?" +
OPTIONS).routeId("tag").autoStartup(false)
+ .process(e -> route("tag",
e.getMessage().getBody(GHTag.class).getName()));
+
from("github2:commit/main?startingSha=beginning&pollStrategy=#countingPollStrategy&"
+ OPTIONS)
+ .routeId("commit").autoStartup(false)
+ .process(e -> {
+ Message message = e.getMessage();
+ String sha =
message.getHeader(GitHub2Constants.GITHUB_COMMIT_SHA, String.class);
+ authorHeaders.put(sha, headerOrNotSet(message,
GitHub2Constants.GITHUB_COMMIT_AUTHOR));
+ committerHeaders.put(sha, headerOrNotSet(message,
GitHub2Constants.GITHUB_COMMIT_COMMITTER));
+ route("commit", sha);
+ });
+ }
+ };
+ }
+
+ @Test
+ void eventsNotRoutedByASuspendingConsumerAreRoutedAfterTheResume() throws
Exception {
+ context.getRouteController().startRoute("event");
+ suspendAt = "2";
+ // the API returns the most recent event first
+ events = List.of(event(4), event(3), event(2), event(1));
+
+ suspendedDuringThePollAndResumed("event", List.of("1", "2"),
List.of("1", "2", "3", "4"));
+ }
+
+ @Test
+ void pullRequestsNotRoutedByASuspendingConsumerAreRoutedAfterTheResume()
throws Exception {
+ context.getRouteController().startRoute("pullRequest");
+ suspendAt = "2";
+ // the API returns the most recent pull request first
+ pullRequests = List.of(pullRequest(4), pullRequest(3), pullRequest(2),
pullRequest(1));
+
+ suspendedDuringThePollAndResumed("pullRequest", List.of("1", "2"),
List.of("1", "2", "3", "4"));
+ }
+
+ @Test
+ void commentsNotRoutedByASuspendingConsumerAreRoutedAfterTheResume()
throws Exception {
+ context.getRouteController().startRoute("pullRequestComment");
+ suspendAt = "12";
+ pullRequests = List.of(pullRequest(2, 12, 14), pullRequest(1, 11, 13));
+
+ suspendedDuringThePollAndResumed("pullRequestComment", List.of("11",
"12"), List.of("11", "12", "13", "14"));
+ }
+
+ @Test
+ void tagsNotRoutedByASuspendingConsumerAreRoutedAfterTheResume() throws
Exception {
+ context.getRouteController().startRoute("tag");
+ suspendAt = "v2";
+ // the API returns the most recent tag first
+ tags = List.of(tag("v4"), tag("v3"), tag("v2"), tag("v1"));
+
+ suspendedDuringThePollAndResumed("tag", List.of("v1", "v2"),
List.of("v1", "v2", "v3", "v4"));
+ }
+
+ @Test
+ void commitsNotRoutedByASuspendingConsumerAreRoutedAfterTheResume() throws
Exception {
+ context.getRouteController().startRoute("commit");
+ suspendAt = "c2";
+ // the API returns the most recent commit first
+ commits = List.of(commit("c4"), commit("c3"), commit("c2"),
commit("c1"));
+
+ suspendedDuringThePollAndResumed("commit", List.of("c1", "c2"),
List.of("c1", "c2", "c3", "c4"));
+ }
+
+ @Test
+ void aCommitWhoseAuthorLookupFailsOnceIsRoutedWithTheNameFromTheCommit()
throws Exception {
+ GHCommit first = commit("c1", "Git Author 1", "Git Committer 1");
+ GHUser author1 = user("Profile Author 1");
+ GHUser committer1 = user("Profile Committer 1");
+ when(first.getAuthor()).thenReturn(author1);
+ when(first.getCommitter()).thenReturn(committer1);
+ // the lookup of the author fails once (it would work in a later
poll), the one of the committer works
+ GHCommit failsOnce = commit("c2", "Git Author 2", "Git Committer 2");
+ GHUser author2 = user("Profile Author 2");
+ GHUser committer2 = user("Profile Committer 2");
+ AtomicInteger lookups = new AtomicInteger();
+ when(failsOnce.getAuthor()).thenAnswer(invocation -> {
+ if (lookups.incrementAndGet() == 1) {
+ throw new IOException("Simulated: 502 Bad Gateway");
+ }
+ return author2;
+ });
+ when(failsOnce.getCommitter()).thenReturn(committer2);
+
+ context.getRouteController().startRoute("commit");
+ commits = List.of(commit("c3"), failsOnce, first);
+
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(
+ () -> assertEquals(List.of("c1", "c2", "c3"), routed, "the
commit and the ones that follow it are routed"));
+ assertEquals("Git Author 2", authorHeaders.get("c2"), "the name from
the commit, as the lookup failed");
+ assertEquals("Profile Committer 2", committerHeaders.get("c2"), "the
name of the user that was looked up");
+ assertEquals("Profile Author 1", authorHeaders.get("c1"));
+ assertEquals("Profile Committer 1", committerHeaders.get("c1"));
+ assertNothingElseIsRouted(List.of("c1", "c2", "c3"));
+ assertEquals(1, lookups.get(), "the routed commit is not looked up
again");
+ assertEquals(0, pollStrategy.failedPolls.get());
+ }
+
+ @Test
+ void aCommitWhoseUserLookupsAlwaysFailIsRoutedOnceAndDoesNotFailThePolls()
throws Exception {
+ // for example the account was deleted: every lookup is answered with
a 404. git recorded no committer name
+ GHCommit alwaysFails = commit("c2", "Git Author 2", null);
+ when(alwaysFails.getAuthor()).thenThrow(new
GHFileNotFoundException("Simulated: 404 Not Found"));
+ when(alwaysFails.getCommitter()).thenThrow(new
GHFileNotFoundException("Simulated: 404 Not Found"));
+
+ context.getRouteController().startRoute("commit");
+ commits = List.of(commit("c3"), alwaysFails, commit("c1"));
+
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(
+ () -> assertEquals(List.of("c1", "c2", "c3"), routed, "the
commit and the ones that follow it are routed"));
+ assertEquals("Git Author 2", authorHeaders.get("c2"), "the name from
the commit, as the lookup failed");
+ assertEquals(NOT_SET, committerHeaders.get("c2"), "no lookup and no
name in the commit: the header is not set");
+ assertNothingElseIsRouted(List.of("c1", "c2", "c3"));
+ // routed by the first poll and not read again by the following ones
+ verify(alwaysFails, times(1)).getAuthor();
+ verify(alwaysFails, times(1)).getCommitter();
+ assertEquals(0, pollStrategy.failedPolls.get(), "the failed lookups do
not fail a poll");
+ }
+
+ @Test
+ void commitsOfAPollWithAFailedAuthorLookupAreNotLost() throws Exception {
+ // on main the poll ended with this exception after it had remembered
c1, c2 and c3, and none was ever routed
+ GHCommit failsOnce = commit("c2");
+ AtomicBoolean failed = new AtomicBoolean();
+ when(failsOnce.getAuthor()).thenAnswer(invocation -> {
+ if (failed.compareAndSet(false, true)) {
+ throw new IOException("Simulated: connection reset");
+ }
+ return null;
+ });
+
+ context.getRouteController().startRoute("commit");
+ commits = List.of(commit("c3"), failsOnce, commit("c1"));
+
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(
+ () -> assertEquals(List.of("c1", "c2", "c3"), routed, "the
commits of the poll with the failed lookup"));
+ assertTrue(failed.get());
+ assertNothingElseIsRouted(List.of("c1", "c2", "c3"));
+ }
+
+ private void suspendedDuringThePollAndResumed(String routeId, List<String>
beforeTheSuspend, List<String> all)
+ throws Exception {
+ awaitRouteSuspended(routeId);
+ assertEquals(beforeTheSuspend, routed, "the suspend ends the batch
after the exchange that is being routed");
+
+ context.getRouteController().resumeRoute(routeId);
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() ->
assertEquals(all, routed, "routed after the resume"));
+ assertNothingElseIsRouted(all);
+ }
+
+ private void assertNothingElseIsRouted(List<String> expected) {
+ // a few more polls: nothing is routed twice
+ int before = polls.get();
+ await().atMost(10, TimeUnit.SECONDS).until(() -> polls.get() >= before
+ 3);
+ assertEquals(expected, routed);
+ }
+
+ private void route(String routeId, String item) {
+ if (item.equals(suspendAt)) {
+ suspendRouteDuringThePoll(routeId);
+ }
+ routed.add(item);
+ }
+
+ /**
+ * Called by the thread that polls: suspends the route from another
thread, as a route must not suspend itself, and
+ * returns when the suspend has reached the consumer (it no longer routes
the following exchanges of the batch).
+ */
+ private void suspendRouteDuringThePoll(String routeId) {
+ if (!suspendRequested.compareAndSet(false, true)) {
+ return;
+ }
+ ScheduledBatchPollingConsumer consumer =
(ScheduledBatchPollingConsumer) context.getRoute(routeId).getConsumer();
+ suspender = new Thread(() -> {
+ try {
+ context.getRouteController().suspendRoute(routeId);
+ } catch (Exception e) {
+ suspendFailure.set(e);
+ }
+ }, "suspend-route");
+ suspender.start();
+
+ CountDownLatch batchEnded = new CountDownLatch(1);
+ Thread watcher = new Thread(() -> {
+ await().atMost(20, TimeUnit.SECONDS).until(() ->
!consumer.isBatchAllowed());
+ batchEnded.countDown();
+ }, "watch-consumer");
+ watcher.start();
+
+ // an interrupt of the thread that polls must not end this wait
+ boolean interrupted = false;
+ boolean ended = false;
+ long remaining = TimeUnit.SECONDS.toNanos(30);
+ long deadline = System.nanoTime() + remaining;
+ while (!ended && remaining > 0) {
+ try {
+ ended = batchEnded.await(remaining, TimeUnit.NANOSECONDS);
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ remaining = deadline - System.nanoTime();
+ }
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private void awaitRouteSuspended(String routeId) throws
InterruptedException {
+ await().atMost(20, TimeUnit.SECONDS).until(() -> suspender != null);
+ suspender.join(TimeUnit.SECONDS.toMillis(60));
+ assertFalse(suspender.isAlive(), "the route suspend has returned");
+ assertNull(suspendFailure.get());
+ assertEquals(ServiceStatus.Suspended,
context.getRouteController().getRouteStatus(routeId));
+ }
+
+ private GitHub createGitHub() {
+ try {
+ GHRepository repository = mock(GHRepository.class);
+ when(repository.listEvents()).thenAnswer(invocation -> paged(() ->
events));
+
when(repository.getPullRequests(GHIssueState.OPEN)).thenAnswer(invocation -> {
+ polls.incrementAndGet();
+ return new ArrayList<>(pullRequests);
+ });
+ when(repository.listTags()).thenAnswer(invocation -> paged(() ->
tags));
+ GHCommitQueryBuilder commitQuery =
mock(GHCommitQueryBuilder.class);
+ when(commitQuery.from(anyString())).thenReturn(commitQuery);
+ when(commitQuery.list()).thenAnswer(invocation -> paged(() ->
commits));
+ when(repository.queryCommits()).thenReturn(commitQuery);
+
+ GitHub client = mock(GitHub.class);
+ when(client.getRepository("acme/demo")).thenReturn(repository);
+ return client;
+ } catch (IOException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ /**
+ * A page iterable over the current content of a list. Every iteration
counts as a poll.
+ */
+ @SuppressWarnings("unchecked")
+ private <T> PagedIterable<T> paged(Supplier<List<T>> items) {
+ return new PagedIterable<>() {
+ @Override
+ public PagedIterator<T> _iterator(int pageSize) {
+ polls.incrementAndGet();
+ Iterator<T> delegate = items.get().iterator();
+ PagedIterator<T> iterator = mock(PagedIterator.class);
+ when(iterator.hasNext()).thenAnswer(invocation ->
delegate.hasNext());
+ when(iterator.next()).thenAnswer(invocation ->
delegate.next());
+ return iterator;
+ }
+ };
+ }
+
+ private static GHEventInfo event(long id) {
+ GHEventInfo event = mock(GHEventInfo.class);
+ when(event.getId()).thenReturn(id);
+ when(event.getType()).thenReturn(GHEvent.PUSH);
+ return event;
+ }
+
+ private static GHPullRequest pullRequest(int number, long... commentIds)
throws IOException {
+ GHPullRequest pullRequest = mock(GHPullRequest.class);
+ when(pullRequest.getNumber()).thenReturn(number);
+ List<GHIssueComment> comments = new ArrayList<>();
+ for (long id : commentIds) {
+ // a real comment: getId has generated bridge methods, which a
mock does not stub reliably
+ GHIssueComment comment = new GHIssueComment();
+ setField(GHObject.class, comment, "id", id);
+ setField(GHIssueComment.class, comment, "owner", pullRequest);
+ comments.add(comment);
+ }
+ when(pullRequest.getComments()).thenReturn(comments);
+ return pullRequest;
+ }
+
+ private static void setField(Class<?> type, Object target, String name,
Object value) {
+ try {
+ Field field = type.getDeclaredField(name);
+ field.setAccessible(true);
+ field.set(target, value);
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private static GHTag tag(String name) {
+ GHTag tag = mock(GHTag.class);
+ when(tag.getName()).thenReturn(name);
+ return tag;
+ }
+
+ private static String headerOrNotSet(Message message, String name) {
+ String value = message.getHeader(name, String.class);
+ return value != null ? value : NOT_SET;
+ }
+
+ private static GHUser user(String name) throws IOException {
+ GHUser user = mock(GHUser.class);
+ when(user.getName()).thenReturn(name);
+ return user;
+ }
+
+ /**
+ * A commit as the list of commits returns it: with the names that git
recorded for the author and the committer (a
+ * real object, as its getters have generated bridge methods, which a mock
does not stub reliably).
+ */
+ private static GHCommit commit(String sha, String gitAuthor, String
gitCommitter) throws IOException {
+ GHCommit.ShortInfo info = new GHCommit.ShortInfo();
+ setField(GitCommit.class, info, "message", "message of " + sha);
+ setField(GitCommit.class, info, "author", gitUser(gitAuthor));
+ setField(GitCommit.class, info, "committer", gitUser(gitCommitter));
+ GHCommit commit = mock(GHCommit.class);
+ when(commit.getSHA1()).thenReturn(sha);
+
when(commit.getHtmlUrl()).thenReturn(URI.create("https://github.invalid/acme/demo/commit/"
+ sha).toURL());
+ when(commit.getCommitShortInfo()).thenReturn(info);
+ return commit;
+ }
+
+ private static GitUser gitUser(String name) {
+ GitUser user = new GitUser();
+ setField(GitUser.class, user, "name", name);
+ return user;
+ }
+
+ private static final class CountingPollStrategy extends
DefaultPollingConsumerPollStrategy {
+ private final AtomicInteger failedPolls = new AtomicInteger();
+
+ @Override
+ public boolean rollback(Consumer consumer, Endpoint endpoint, int
retryCounter, Exception cause) throws Exception {
+ failedPolls.incrementAndGet();
+ return super.rollback(consumer, endpoint, retryCounter, cause);
+ }
+ }
+
+ private static GHCommit commit(String sha) throws IOException {
+ GHCommit commit = mock(GHCommit.class, RETURNS_DEEP_STUBS);
+ when(commit.getSHA1()).thenReturn(sha);
+ when(commit.getAuthor()).thenReturn(null);
+ when(commit.getCommitter()).thenReturn(null);
+
when(commit.getHtmlUrl()).thenReturn(URI.create("https://github.invalid/acme/demo/commit/"
+ sha).toURL());
+ when(commit.getCommitShortInfo().getMessage()).thenReturn("message of
" + sha);
+ return commit;
+ }
+}