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 2fefcb707405 CAMEL-25806: camel-jira - the newIssues consumer no 
longer loses the issues beyond maxResults of a poll (#28198)
2fefcb707405 is described below

commit 2fefcb70740504989ff0b6d9ae626b9d58e1a0de
Author: allthingssecurity <[email protected]>
AuthorDate: Thu Oct 22 00:59:13 2026 +0530

    CAMEL-25806: camel-jira - the newIssues consumer no longer loses the issues 
beyond maxResults of a poll (#28198)
    
    * CAMEL-25806: camel-jira - the newIssues consumer no longer loses the 
issues beyond maxResults of a poll
    
    A poll of the newIssues consumer searched for the issues created after the
    latest issue it had routed, newest first, and kept only the first maxResults
    (default 50). The latest issue then became the newest one, so the older
    issues of that poll were never returned again: with more than maxResults
    issues created between two polls (a burst, or the first poll after Jira was
    unreachable for a while) the older ones were silently lost.
    
    The poll now reads every page of that search; maxResults is its page size.
    
    * CAMEL-25806: camel-jira - address review: page with the next page token 
on Jira Cloud
    
    jira-rest-java-client 6.0.4 gives an AsynchronousCloudSearchRestClient for
    Jira Cloud, whose searchJql throws UnsupportedOperationException for a 
startAt
    other than 0. The newIssues consumer, which now reads every page, therefore
    failed at every poll with more than 50 new issues on Jira Cloud, and the 
other
    consumers failed the same way with maxResults above 50. On Jira Cloud the 
pages
    are now read with enhancedSearchJql and the next page token of the previous
    page, until there is none; Jira Server / Data Center still pages by startAt.
    
    The component doc and the upgrade guide now say that there is no upper bound
    on the pages a poll reads.
    
    * CAMEL-25806: camel-jira - address review: wrap the maxResults DSL javadoc 
as the generator does
    
    The endpoint DSL javadoc of the maxResults description was wrapped by hand
    one column too wide; CI regenerates it with lines of at most 80 columns.
    
    * CAMEL-25806: camel-jira - address review: say in the javadoc that 
getIssues serves every Jira consumer
    
    The Cloud paging by next page token in AbstractJiraConsumer.getIssues
    applies to newIssues, newComments and watchUpdates alike.
    
    * CAMEL-25806: camel-jira - address review: issues that leave the result 
set while paging, Cloud note, at most 50
    
    On Jira Server / Data Center the pages are read by start index. When
    issues leave the result set while paging (deleted, moved, or edited out
    of the JQL), the later ones move back and the first issue of the next
    page was never read. When the total of a page is lower than the one of
    the previous page, the search is read again from that many issues
    before; the set drops the repeats.
    
    The component doc now says that the search of Jira Cloud is eventually
    consistent, so an issue that becomes searchable after a newer one was
    consumed is not consumed, and the maxResults option says that the page
    size is at most 50.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../org/apache/camel/catalog/components/jira.json  |   2 +-
 .../apache/camel/catalog/docs/jira-component.adoc  |   8 +
 .../org/apache/camel/component/jira/jira.json      |   2 +-
 .../camel-jira/src/main/docs/jira-component.adoc   |   8 +
 .../apache/camel/component/jira/JiraEndpoint.java  |   3 +-
 .../jira/consumer/AbstractJiraConsumer.java        |  48 +++++-
 .../component/jira/consumer/NewIssuesConsumer.java |  13 +-
 ...wIssuesConsumerMoreThanMaxResultsCloudTest.java |  65 ++++++++
 .../NewIssuesConsumerMoreThanMaxResultsTest.java   | 170 +++++++++++++++++++++
 .../NewIssuesConsumerShrinkingResultTest.java      |  65 ++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  11 ++
 .../endpoint/dsl/JiraEndpointBuilderFactory.java   |   8 +-
 12 files changed, 385 insertions(+), 18 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/jira.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/jira.json
index 2b0b518a69fe..af18afb6b83b 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/jira.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/jira.json
@@ -65,7 +65,7 @@
     "delay": { "index": 1, "kind": "parameter", "displayName": "Delay", 
"group": "common", "label": "", "required": false, "type": "integer", 
"javaType": "java.lang.Integer", "deprecated": false, "autowired": false, 
"secret": false, "defaultValue": 6000, "configurationClass": 
"org.apache.camel.component.jira.JiraConfiguration", "configurationField": 
"configuration", "description": "Time in milliseconds to elapse for the next 
poll." },
     "jiraUrl": { "index": 2, "kind": "parameter", "displayName": "Jira Url", 
"group": "common", "label": "", "required": true, "type": "string", "javaType": 
"java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": 
false, "secret": false, "configurationClass": 
"org.apache.camel.component.jira.JiraConfiguration", "configurationField": 
"configuration", "description": "The Jira server url, for example 
http:\/\/my_jira.com:8081." },
     "jql": { "index": 3, "kind": "parameter", "displayName": "Jql", "group": 
"consumer", "label": "consumer", "required": false, "type": "string", 
"javaType": "java.lang.String", "deprecated": false, "autowired": false, 
"secret": false, "description": "JQL is the query language from JIRA which 
allows you to retrieve the data you want. For example jql=project=MyProject 
Where MyProject is the product key in Jira. It is important to use the RAW() 
and set the JQL inside it to prevent camel p [...]
-    "maxResults": { "index": 4, "kind": "parameter", "displayName": "Max 
Results", "group": "consumer", "label": "consumer", "required": false, "type": 
"integer", "javaType": "java.lang.Integer", "deprecated": false, "autowired": 
false, "secret": false, "defaultValue": 50, "description": "Max number of 
issues to search for" },
+    "maxResults": { "index": 4, "kind": "parameter", "displayName": "Max 
Results", "group": "consumer", "label": "consumer", "required": false, "type": 
"integer", "javaType": "java.lang.Integer", "deprecated": false, "autowired": 
false, "secret": false, "defaultValue": 50, "description": "Max number of 
issues to search for. The newIssues consumer reads every issue created since 
its previous poll, page by page, with this as the page size (at most 50)." },
     "sendEmptyMessageWhenIdle": { "index": 5, "kind": "parameter", 
"displayName": "Send Empty Message When Idle", "group": "consumer", "label": 
"consumer", "required": false, "type": "boolean", "javaType": "boolean", 
"deprecated": false, "autowired": false, "secret": false, "defaultValue": 
false, "description": "If the polling consumer did not poll any files, you can 
enable this option to send an empty message (no body) instead." },
     "sendOnlyUpdatedField": { "index": 6, "kind": "parameter", "displayName": 
"Send Only Updated Field", "group": "consumer", "label": "consumer", 
"required": false, "type": "boolean", "javaType": "boolean", "deprecated": 
false, "autowired": false, "secret": false, "defaultValue": true, 
"description": "Indicator for sending only changed fields in exchange body or 
issue object. By default consumer sends only changed fields." },
     "watchedFields": { "index": 7, "kind": "parameter", "displayName": 
"Watched Fields", "group": "consumer", "label": "consumer", "required": false, 
"type": "string", "javaType": "java.lang.String", "deprecated": false, 
"autowired": false, "secret": false, "defaultValue": "Status,Priority", 
"description": "Comma separated list of fields to watch for changes. 
Status,Priority are the defaults." },
diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/jira-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/jira-component.adoc
index 62980d533155..861c4d0f3d21 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/jira-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/jira-component.adoc
@@ -141,6 +141,14 @@ automatically set the JQL as:
 This is in order to optimize startup processing, rather than having to index 
every single
 issue in the project.
 
+Each poll reads every page of that search, so the issues created since the 
previous poll are consumed also when
+there are more than `maxResults` of them (for example after Jira was 
unreachable for a while); `maxResults` is then
+the page size of the search (at most 50). There is no upper bound: after a 
long outage or a large import, one poll
+reads all the new issues, one search request per page, keeps them in memory 
and routes them as one batch. On Jira
+Cloud the pages are read with the next page token of the search, as Jira Cloud 
cannot page by start index. The search
+of Jira Cloud is eventually consistent, so a new issue can become searchable 
only after a newer one: when a poll has
+already consumed the newer one, the late issue is not consumed.
+
 Another note is that, similarly, the newComments consumer will have to
 index every single issue *and* comment on the project. Therefore, for
 large projects, it's *vital* to optimize the JQL expression as much as
diff --git 
a/components/camel-jira/src/generated/resources/META-INF/org/apache/camel/component/jira/jira.json
 
b/components/camel-jira/src/generated/resources/META-INF/org/apache/camel/component/jira/jira.json
index 2b0b518a69fe..af18afb6b83b 100644
--- 
a/components/camel-jira/src/generated/resources/META-INF/org/apache/camel/component/jira/jira.json
+++ 
b/components/camel-jira/src/generated/resources/META-INF/org/apache/camel/component/jira/jira.json
@@ -65,7 +65,7 @@
     "delay": { "index": 1, "kind": "parameter", "displayName": "Delay", 
"group": "common", "label": "", "required": false, "type": "integer", 
"javaType": "java.lang.Integer", "deprecated": false, "autowired": false, 
"secret": false, "defaultValue": 6000, "configurationClass": 
"org.apache.camel.component.jira.JiraConfiguration", "configurationField": 
"configuration", "description": "Time in milliseconds to elapse for the next 
poll." },
     "jiraUrl": { "index": 2, "kind": "parameter", "displayName": "Jira Url", 
"group": "common", "label": "", "required": true, "type": "string", "javaType": 
"java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": 
false, "secret": false, "configurationClass": 
"org.apache.camel.component.jira.JiraConfiguration", "configurationField": 
"configuration", "description": "The Jira server url, for example 
http:\/\/my_jira.com:8081." },
     "jql": { "index": 3, "kind": "parameter", "displayName": "Jql", "group": 
"consumer", "label": "consumer", "required": false, "type": "string", 
"javaType": "java.lang.String", "deprecated": false, "autowired": false, 
"secret": false, "description": "JQL is the query language from JIRA which 
allows you to retrieve the data you want. For example jql=project=MyProject 
Where MyProject is the product key in Jira. It is important to use the RAW() 
and set the JQL inside it to prevent camel p [...]
-    "maxResults": { "index": 4, "kind": "parameter", "displayName": "Max 
Results", "group": "consumer", "label": "consumer", "required": false, "type": 
"integer", "javaType": "java.lang.Integer", "deprecated": false, "autowired": 
false, "secret": false, "defaultValue": 50, "description": "Max number of 
issues to search for" },
+    "maxResults": { "index": 4, "kind": "parameter", "displayName": "Max 
Results", "group": "consumer", "label": "consumer", "required": false, "type": 
"integer", "javaType": "java.lang.Integer", "deprecated": false, "autowired": 
false, "secret": false, "defaultValue": 50, "description": "Max number of 
issues to search for. The newIssues consumer reads every issue created since 
its previous poll, page by page, with this as the page size (at most 50)." },
     "sendEmptyMessageWhenIdle": { "index": 5, "kind": "parameter", 
"displayName": "Send Empty Message When Idle", "group": "consumer", "label": 
"consumer", "required": false, "type": "boolean", "javaType": "boolean", 
"deprecated": false, "autowired": false, "secret": false, "defaultValue": 
false, "description": "If the polling consumer did not poll any files, you can 
enable this option to send an empty message (no body) instead." },
     "sendOnlyUpdatedField": { "index": 6, "kind": "parameter", "displayName": 
"Send Only Updated Field", "group": "consumer", "label": "consumer", 
"required": false, "type": "boolean", "javaType": "boolean", "deprecated": 
false, "autowired": false, "secret": false, "defaultValue": true, 
"description": "Indicator for sending only changed fields in exchange body or 
issue object. By default consumer sends only changed fields." },
     "watchedFields": { "index": 7, "kind": "parameter", "displayName": 
"Watched Fields", "group": "consumer", "label": "consumer", "required": false, 
"type": "string", "javaType": "java.lang.String", "deprecated": false, 
"autowired": false, "secret": false, "defaultValue": "Status,Priority", 
"description": "Comma separated list of fields to watch for changes. 
Status,Priority are the defaults." },
diff --git a/components/camel-jira/src/main/docs/jira-component.adoc 
b/components/camel-jira/src/main/docs/jira-component.adoc
index 62980d533155..861c4d0f3d21 100644
--- a/components/camel-jira/src/main/docs/jira-component.adoc
+++ b/components/camel-jira/src/main/docs/jira-component.adoc
@@ -141,6 +141,14 @@ automatically set the JQL as:
 This is in order to optimize startup processing, rather than having to index 
every single
 issue in the project.
 
+Each poll reads every page of that search, so the issues created since the 
previous poll are consumed also when
+there are more than `maxResults` of them (for example after Jira was 
unreachable for a while); `maxResults` is then
+the page size of the search (at most 50). There is no upper bound: after a 
long outage or a large import, one poll
+reads all the new issues, one search request per page, keeps them in memory 
and routes them as one batch. On Jira
+Cloud the pages are read with the next page token of the search, as Jira Cloud 
cannot page by start index. The search
+of Jira Cloud is eventually consistent, so a new issue can become searchable 
only after a newer one: when a poll has
+already consumed the newer one, the late issue is not consumed.
+
 Another note is that, similarly, the newComments consumer will have to
 index every single issue *and* comment on the project. Therefore, for
 large projects, it's *vital* to optimize the JQL expression as much as
diff --git 
a/components/camel-jira/src/main/java/org/apache/camel/component/jira/JiraEndpoint.java
 
b/components/camel-jira/src/main/java/org/apache/camel/component/jira/JiraEndpoint.java
index aeb9797330d7..64ab0d802a75 100644
--- 
a/components/camel-jira/src/main/java/org/apache/camel/component/jira/JiraEndpoint.java
+++ 
b/components/camel-jira/src/main/java/org/apache/camel/component/jira/JiraEndpoint.java
@@ -267,7 +267,8 @@ public class JiraEndpoint extends ScheduledPollEndpoint 
implements EndpointServi
     }
 
     /**
-     * Max number of issues to search for
+     * Max number of issues to search for. The newIssues consumer reads every 
issue created since its previous poll,
+     * page by page, with this as the page size (at most 50).
      */
     public void setMaxResults(Integer maxResults) {
         this.maxResults = maxResults;
diff --git 
a/components/camel-jira/src/main/java/org/apache/camel/component/jira/consumer/AbstractJiraConsumer.java
 
b/components/camel-jira/src/main/java/org/apache/camel/component/jira/consumer/AbstractJiraConsumer.java
index a249a02c9203..017d4f67fe78 100644
--- 
a/components/camel-jira/src/main/java/org/apache/camel/component/jira/consumer/AbstractJiraConsumer.java
+++ 
b/components/camel-jira/src/main/java/org/apache/camel/component/jira/consumer/AbstractJiraConsumer.java
@@ -114,6 +114,10 @@ public abstract class AbstractJiraConsumer extends 
ScheduledBatchPollingConsumer
         return getIssues(jql, SEARCH_START_AT, SEARCH_MAX_PER_QUERY, 
maxResults);
     }
 
+    /**
+     * Runs the search of every Jira consumer (newIssues, newComments, 
watchUpdates): on Jira Cloud the pages are read
+     * with the next page token (see getCloudIssues), on Jira Server / Data 
Center by start index.
+     */
     // Ignore maxResults if it's <= 0.
     protected Queue<Issue> getIssues(String jql, int start, int maxPerQuery, 
int maxResults) {
         LOG.debug("Start indexing current JIRA issues...");
@@ -122,8 +126,13 @@ public abstract class AbstractJiraConsumer extends 
ScheduledBatchPollingConsumer
             maxPerQuery = maxResults;
         }
 
+        if (endpoint.getClient().getSearchClient() instanceof 
AsynchronousCloudSearchRestClient cloudSearchRestClient) {
+            return getCloudIssues(cloudSearchRestClient, jql, maxPerQuery, 
maxResults);
+        }
+
         // Avoid duplicates
         Set<Issue> issues = new LinkedHashSet<>();
+        int previousTotal = -1;
         while (true) {
             SearchRestClient searchRestClient = 
endpoint.getClient().getSearchClient();
             // *navigable should be the default value, but it does not seem to 
be true with 6.0.2 client
@@ -136,15 +145,17 @@ public abstract class AbstractJiraConsumer extends 
ScheduledBatchPollingConsumer
             // Note: the total # the query would return *without* pagination, 
effectively telling us
             // we've reached the end. Also exit early if we're limiting the # 
of results or
             // if total # of returned issues is lower than the actual page 
size.
-            int total;
-            if (searchRestClient instanceof AsynchronousCloudSearchRestClient) 
{
-                // calling searchResult.getTotal() on 
AsynchronousCloudSearchRestClient throws an exception:
-                // Total is not available in the Cloud version of the new 
Search API response.
-                // Please use `SearchRestClient.totalCount` instead to fetch 
the estimated count of the issues for a given JQL
-                total = searchRestClient.totalCount(jql).claim().getCount();
-            } else {
-                total = searchResult.getTotal();
+            int total = searchResult.getTotal();
+            if (previousTotal >= 0 && total < previousTotal && start < 
previousTotal) {
+                // issues left the result set while paging (deleted, moved, or 
edited out of the JQL), so the later
+                // ones moved back and the first ones of this page may have 
been on the previous page: read again from
+                // that many issues before, the set drops the repeats. Not 
needed when the previous page was the last
+                // one. An issue created at the same time as one leaves keeps 
the total and is not seen here.
+                start = Math.max(0, start - (previousTotal - total));
+                previousTotal = total;
+                continue;
             }
+            previousTotal = total;
             if (maxPerQuery >= total || start >= total || maxResults > 0 && 
issues.size() >= maxResults) {
                 break;
             }
@@ -155,6 +166,27 @@ public abstract class AbstractJiraConsumer extends 
ScheduledBatchPollingConsumer
         return new LinkedList<>(issues);
     }
 
+    /**
+     * Jira Cloud cannot page by startAt: its search client throws 
UnsupportedOperationException for a startAt other
+     * than 0. The pages are read with the next page token of the previous 
page instead, from the first issue, until the
+     * last page (no next page token) or maxResults (ignored if it's <= 0).
+     */
+    private Queue<Issue> getCloudIssues(
+            AsynchronousCloudSearchRestClient searchRestClient, String jql, 
int maxPerQuery, int maxResults) {
+        Set<Issue> issues = new LinkedHashSet<>();
+        String nextPageToken = null;
+        do {
+            SearchResult searchResult
+                    = searchRestClient.enhancedSearchJql(jql, maxPerQuery, 
nextPageToken, Set.of("*navigable"), null).claim();
+            for (Issue issue : searchResult.getIssues()) {
+                issues.add(issue);
+            }
+            nextPageToken = searchResult.getNextPageToken();
+        } while (nextPageToken != null && !(maxResults > 0 && issues.size() >= 
maxResults));
+        LOG.debug("End indexing current JIRA issues. {} issues indexed.", 
issues.size());
+        return new LinkedList<>(issues);
+    }
+
     protected JiraRestClient client() {
         return endpoint.getClient();
     }
diff --git 
a/components/camel-jira/src/main/java/org/apache/camel/component/jira/consumer/NewIssuesConsumer.java
 
b/components/camel-jira/src/main/java/org/apache/camel/component/jira/consumer/NewIssuesConsumer.java
index 87b4095df965..b091f1b43e1a 100644
--- 
a/components/camel-jira/src/main/java/org/apache/camel/component/jira/consumer/NewIssuesConsumer.java
+++ 
b/components/camel-jira/src/main/java/org/apache/camel/component/jira/consumer/NewIssuesConsumer.java
@@ -129,16 +129,19 @@ public class NewIssuesConsumer extends 
AbstractJiraConsumer {
      * @return queue of new issues
      */
     private Queue<Issue> getNewIssues() {
-        String jqlFilter;
+        Queue<Issue> issues;
         // If we have processed an issue before, use its timestamp to get only 
the newer ones
         if (latestIssue != null) {
-            jqlFilter = String.format(NEW_ISSUES_JQL_FORMAT, 
getServerTimestamp(), getEndpoint().getJql());
+            String jqlFilter = String.format(NEW_ISSUES_JQL_FORMAT, 
getServerTimestamp(), getEndpoint().getJql());
+            // all of them: the newest one becomes the latest issue, so a new 
issue left out by maxResults would never
+            // be returned again; maxResults is the page size here
+            int maxResults = getEndpoint().getMaxResults();
+            int pageSize = maxResults > 0 ? Math.min(maxResults, 
SEARCH_MAX_PER_QUERY) : SEARCH_MAX_PER_QUERY;
+            issues = getIssues(jqlFilter, SEARCH_START_AT, pageSize, 
Integer.MAX_VALUE);
         } else {
-            jqlFilter = getEndpoint().getJql();
+            issues = getIssues(getEndpoint().getJql());
         }
 
-        Queue<Issue> issues = getIssues(jqlFilter);
-
         if (!issues.isEmpty()) {
             if (latestIssue != null) {
                 // remove all issues that are older than the latestIssue 
(including), because those were processed by previous polls
diff --git 
a/components/camel-jira/src/test/java/org/apache/camel/component/jira/consumer/NewIssuesConsumerMoreThanMaxResultsCloudTest.java
 
b/components/camel-jira/src/test/java/org/apache/camel/component/jira/consumer/NewIssuesConsumerMoreThanMaxResultsCloudTest.java
new file mode 100644
index 000000000000..642865615844
--- /dev/null
+++ 
b/components/camel-jira/src/test/java/org/apache/camel/component/jira/consumer/NewIssuesConsumerMoreThanMaxResultsCloudTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.jira.consumer;
+
+import com.atlassian.jira.rest.client.api.SearchRestClient;
+import com.atlassian.jira.rest.client.api.domain.SearchResult;
+import com.atlassian.jira.rest.client.api.domain.TotalCount;
+import 
com.atlassian.jira.rest.client.internal.async.AsynchronousCloudSearchRestClient;
+import io.atlassian.util.concurrent.Promise;
+import io.atlassian.util.concurrent.Promises;
+import org.mockito.Mock;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.lenient;
+
+/**
+ * The same as {@link NewIssuesConsumerMoreThanMaxResultsTest} on Jira Cloud, 
whose search client cannot page by
+ * startAt: like the real client of jira-rest-java-client 6.0.4, it throws 
UnsupportedOperationException for a startAt
+ * other than 0, and pages with the next page token instead.
+ */
+public class NewIssuesConsumerMoreThanMaxResultsCloudTest extends 
NewIssuesConsumerMoreThanMaxResultsTest {
+
+    @Mock
+    private AsynchronousCloudSearchRestClient cloudSearchRestClient;
+
+    @Override
+    protected SearchRestClient searchClient() {
+        // lenient: which of these the consumer calls is what the test is about
+        lenient().when(cloudSearchRestClient.searchJql(any(), any(), any(), 
any())).then(invocation -> {
+            Integer startAt = invocation.getArgument(2);
+            if (startAt != null && startAt != 0) {
+                throw new UnsupportedOperationException(
+                        "This search API is not supported anymore. Please use 
this enhancedSearchJql");
+            }
+            return enhancedSearch(invocation.getArgument(0), 
invocation.getArgument(1), null);
+        });
+        lenient().when(cloudSearchRestClient.enhancedSearchJql(any(), any(), 
any(), any(), any()))
+                .then(invocation -> enhancedSearch(invocation.getArgument(0), 
invocation.getArgument(1),
+                        invocation.getArgument(2)));
+        lenient().when(cloudSearchRestClient.totalCount(any()))
+                .then(invocation -> Promises.promise(new 
TotalCount(total(invocation.getArgument(0)))));
+        return cloudSearchRestClient;
+    }
+
+    private Promise<SearchResult> enhancedSearch(String jql, int maxPerQuery, 
String nextPageToken) {
+        int startAt = nextPageToken == null ? 0 : 
Integer.parseInt(nextPageToken);
+        int total = total(jql);
+        String next = startAt + maxPerQuery < total ? String.valueOf(startAt + 
maxPerQuery) : null;
+        return Promises.promise(new SearchResult(page(jql, startAt, 
maxPerQuery), next));
+    }
+}
diff --git 
a/components/camel-jira/src/test/java/org/apache/camel/component/jira/consumer/NewIssuesConsumerMoreThanMaxResultsTest.java
 
b/components/camel-jira/src/test/java/org/apache/camel/component/jira/consumer/NewIssuesConsumerMoreThanMaxResultsTest.java
new file mode 100644
index 000000000000..4f51d6f65e05
--- /dev/null
+++ 
b/components/camel-jira/src/test/java/org/apache/camel/component/jira/consumer/NewIssuesConsumerMoreThanMaxResultsTest.java
@@ -0,0 +1,170 @@
+/*
+ * 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.jira.consumer;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import com.atlassian.jira.rest.client.api.JiraRestClient;
+import com.atlassian.jira.rest.client.api.JiraRestClientFactory;
+import com.atlassian.jira.rest.client.api.SearchRestClient;
+import com.atlassian.jira.rest.client.api.UserRestClient;
+import com.atlassian.jira.rest.client.api.domain.Issue;
+import com.atlassian.jira.rest.client.api.domain.SearchResult;
+import com.atlassian.jira.rest.client.api.domain.User;
+import io.atlassian.util.concurrent.Promises;
+import org.apache.camel.CamelContext;
+import org.apache.camel.EndpointInject;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.jira.JiraComponent;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.joda.time.DateTime;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.apache.camel.component.jira.JiraConstants.JIRA;
+import static 
org.apache.camel.component.jira.JiraConstants.JIRA_REST_CLIENT_FACTORY;
+import static 
org.apache.camel.component.jira.JiraTestConstants.JIRA_CREDENTIALS;
+import static org.apache.camel.component.jira.JiraTestConstants.PROJECT;
+import static 
org.apache.camel.component.jira.Utils.createIssueWithCreationDate;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * More new issues than maxResults between two polls (a burst, or the polls 
after Jira was unreachable for a while):
+ * every one of them must be consumed, not only the newest maxResults.
+ */
+@ExtendWith(MockitoExtension.class)
+public class NewIssuesConsumerMoreThanMaxResultsTest extends CamelTestSupport {
+
+    protected static final int NEW_ISSUES = 60;
+
+    private final DateTime start = DateTime.now().minusHours(2);
+    private final Issue latestAtStart = createIssueWithCreationDate(1000, 
start);
+    // newest first, as the JQL of the consumer orders them
+    protected final List<Issue> newIssues = new ArrayList<>();
+    protected final AtomicBoolean served = new AtomicBoolean();
+
+    @Mock
+    private JiraRestClient jiraClient;
+
+    @Mock
+    private JiraRestClientFactory jiraRestClientFactory;
+
+    @Mock
+    private SearchRestClient searchRestClient;
+
+    @Mock
+    private UserRestClient userRestClient;
+
+    @EndpointInject("mock:result")
+    private MockEndpoint mockResult;
+
+    @Override
+    protected void bindToRegistry(Registry registry) {
+        registry.bind(JIRA_REST_CLIENT_FACTORY, jiraRestClientFactory);
+    }
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        for (int i = NEW_ISSUES; i >= 1; i--) {
+            newIssues.add(createIssueWithCreationDate(1000 + i, 
start.plusMinutes(i)));
+        }
+        User user = new User(
+                null, "admin", null, null, true, null,
+                Map.of("48x48", URI.create("")), 
DateTime.now().getZone().getID());
+        SearchRestClient searchClient = searchClient();
+        when(jiraClient.getSearchClient()).thenReturn(searchClient);
+        when(jiraClient.getUserClient()).thenReturn(userRestClient);
+        when(jiraRestClientFactory.createWithBasicHttpAuthentication(any(), 
any(), any())).thenReturn(jiraClient);
+        
when(userRestClient.getUser(any(URI.class))).thenReturn(Promises.promise(user));
+
+        CamelContext camelContext = super.createCamelContext();
+        camelContext.disableJMX();
+        camelContext.addComponent(JIRA, new JiraComponent(camelContext));
+        return camelContext;
+    }
+
+    /**
+     * The search client of Jira Server / Data Center, which pages by startAt.
+     */
+    protected SearchRestClient searchClient() {
+        when(searchRestClient.searchJql(any(), any(), any(), 
any())).then(invocation -> {
+            String jql = invocation.getArgument(0);
+            int maxPerQuery = invocation.getArgument(1);
+            int startAt = invocation.getArgument(2);
+            int total = total(jql);
+            return Promises.promise(new SearchResult(startAt, maxPerQuery, 
total, page(jql, startAt, maxPerQuery)));
+        });
+        return searchRestClient;
+    }
+
+    /**
+     * The number of issues the given search finds now.
+     */
+    protected int total(String jql) {
+        if (!jql.startsWith("created >")) {
+            return 1;
+        }
+        return served.get() ? 0 : NEW_ISSUES;
+    }
+
+    /**
+     * The issues of the given page of the search: the latest issue at 
startup; then the issues created after it, all of
+     * them at the first poll, which pages through them, and none at the next 
polls, as they are all older than the
+     * newest one.
+     */
+    protected List<Issue> page(String jql, int startAt, int maxPerQuery) {
+        if (!jql.startsWith("created >")) {
+            return List.of(latestAtStart);
+        }
+        List<Issue> answer = new ArrayList<>();
+        int total = total(jql);
+        for (int i = startAt; i < Math.min(startAt + maxPerQuery, total); i++) 
{
+            answer.add(newIssues.get(i));
+        }
+        if (startAt + maxPerQuery >= total) {
+            served.set(true);
+        }
+        return answer;
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("jira://newIssues?jiraUrl=" + JIRA_CREDENTIALS + 
"&jql=project=" + PROJECT + "&delay=500")
+                        .to(mockResult);
+            }
+        };
+    }
+
+    @Test
+    public void everyNewIssueIsConsumed() throws Exception {
+        mockResult.expectedMessageCount(NEW_ISSUES);
+        mockResult.expectedBodiesReceivedInAnyOrder(newIssues.toArray());
+        mockResult.assertIsSatisfied();
+    }
+}
diff --git 
a/components/camel-jira/src/test/java/org/apache/camel/component/jira/consumer/NewIssuesConsumerShrinkingResultTest.java
 
b/components/camel-jira/src/test/java/org/apache/camel/component/jira/consumer/NewIssuesConsumerShrinkingResultTest.java
new file mode 100644
index 000000000000..bff5b0eea44c
--- /dev/null
+++ 
b/components/camel-jira/src/test/java/org/apache/camel/component/jira/consumer/NewIssuesConsumerShrinkingResultTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.jira.consumer;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import com.atlassian.jira.rest.client.api.domain.Issue;
+
+/**
+ * An issue leaves the result set between two pages of a poll on Jira Server / 
Data Center (deleted, moved, or edited
+ * out of the JQL): the later issues move back by one, and the first issue of 
the next page must still be consumed.
+ */
+public class NewIssuesConsumerShrinkingResultTest extends 
NewIssuesConsumerMoreThanMaxResultsTest {
+
+    private final Set<Issue> returned = new HashSet<>();
+    private boolean removed;
+
+    @Override
+    protected int total(String jql) {
+        if (!jql.startsWith("created >")) {
+            return 1;
+        }
+        if (served.get()) {
+            return 0;
+        }
+        return removed ? NEW_ISSUES - 1 : NEW_ISSUES;
+    }
+
+    @Override
+    protected List<Issue> page(String jql, int startAt, int maxPerQuery) {
+        if (!jql.startsWith("created >")) {
+            return super.page(jql, startAt, maxPerQuery);
+        }
+        List<Issue> current = removed ? newIssues.subList(1, NEW_ISSUES) : 
newIssues;
+        int total = served.get() ? 0 : current.size();
+        List<Issue> answer = new ArrayList<>();
+        for (int i = startAt; i < Math.min(startAt + maxPerQuery, total); i++) 
{
+            answer.add(current.get(i));
+        }
+        // the newest issue, read on the first page, leaves the result set 
before the next page is read
+        removed = true;
+        returned.addAll(answer);
+        if (returned.containsAll(current)) {
+            served.set(true);
+        }
+        return answer;
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index d90829087638..697208b5c676 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -1074,6 +1074,17 @@ too early (`messageExpiry=3600` gave 3.6 ms instead of 
one hour). The value is n
 created before keep their expiry: change it on the Iggy server. If you set 
`messageExpiry` in microseconds to work
 around this, set it in seconds now, or newly created topics keep their 
messages a million times longer than intended.
 
+=== camel-jira - the newIssues consumer reads every new issue
+
+A poll of the `newIssues` consumer read only the newest `maxResults` (default 
50) of the issues created since the
+previous poll, and the older ones were never consumed. A poll now reads every 
page of the search and can route more
+than `maxResults` issues; `maxResults` is the page size of that search (at 
most 50). There is no upper bound, so the
+first poll after a long outage or a large import can send many search requests 
and route many issues in one batch.
+
+On Jira Cloud the Jira consumers now read the pages of a search with the next 
page token, as Jira Cloud cannot page by
+start index: with `maxResults` above 50, a search with more than 50 results 
failed on the second page with
+`UnsupportedOperationException`.
+
 === camel-hashicorp-vault - properties without engine use the `secret` engine
 
 A `hashicorp:` property without an engine, such as `{{hashicorp:mysecret}}` or 
`{{hashicorp:mysecret#field}}`, now
diff --git 
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/JiraEndpointBuilderFactory.java
 
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/JiraEndpointBuilderFactory.java
index 39b08cf8afce..f4c2c51057ec 100644
--- 
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/JiraEndpointBuilderFactory.java
+++ 
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/JiraEndpointBuilderFactory.java
@@ -108,7 +108,9 @@ public interface JiraEndpointBuilderFactory {
             return this;
         }
         /**
-         * Max number of issues to search for.
+         * Max number of issues to search for. The newIssues consumer reads
+         * every issue created since its previous poll, page by page, with this
+         * as the page size (at most 50).
          * 
          * The option is a: <code>java.lang.Integer</code> type.
          * 
@@ -123,7 +125,9 @@ public interface JiraEndpointBuilderFactory {
             return this;
         }
         /**
-         * Max number of issues to search for.
+         * Max number of issues to search for. The newIssues consumer reads
+         * every issue created since its previous poll, page by page, with this
+         * as the page size (at most 50).
          * 
          * The option will be converted to a <code>java.lang.Integer</code>
          * type.

Reply via email to