Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions suite-api/apps/api/jira_cloud_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class IssueCreateResponse(BaseModel):

class SearchRequest(BaseModel):
jql: str = Field(..., description="JQL query string")
startAt: int = Field(0, ge=0, description="Pagination offset")
nextPageToken: Optional[str] = Field(None, description="Token of the page to fetch")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Removing startAt without rejecting it means Pydantic silently drops it, so old clients that send it get page 1 with HTTP 200 on every call. Reject unknown keys on SearchRequest so stale clients fail loudly, for example with model_config = {"extra": "forbid"}.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At suite-api/apps/api/jira_cloud_router.py, line 76:

<comment>Removing `startAt` without rejecting it means Pydantic silently drops it, so old clients that send it get page 1 with HTTP 200 on every call. Reject unknown keys on `SearchRequest` so stale clients fail loudly, for example with `model_config = {"extra": "forbid"}`.</comment>

<file context>
@@ -73,7 +73,7 @@ class IssueCreateResponse(BaseModel):
 class SearchRequest(BaseModel):
     jql: str = Field(..., description="JQL query string")
-    startAt: int = Field(0, ge=0, description="Pagination offset")
+    nextPageToken: Optional[str] = Field(None, description="Token of the page to fetch")
     maxResults: int = Field(50, ge=1, le=1000, description="Max results to return")
     fields: Optional[List[str]] = Field(None, description="Field selector — None = all")
</file context>
Suggested change
nextPageToken: Optional[str] = Field(None, description="Token of the page to fetch")
model_config = {"extra": "forbid"}
nextPageToken: Optional[str] = Field(None, description="Token of the page to fetch")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject legacy offsets instead of silently resetting pagination

When an existing /api/v1/jira-cloud/rest/api/3/search client sends startAt > 0, BaseModel's default extra='ignore' behavior drops that now-unknown property, so req.nextPageToken remains None and the handler fetches page one. Because the v1 route is unchanged, this turns an upgrade into silent duplicate or missing search results rather than a validation failure; retain a compatibility adapter or explicitly forbid the legacy field.

Useful? React with 👍 / 👎.

maxResults: int = Field(50, ge=1, le=1000, description="Max results to return")
fields: Optional[List[str]] = Field(None, description="Field selector — None = all")

Expand Down Expand Up @@ -195,7 +195,7 @@ def search_issues(req: SearchRequest) -> Dict[str, Any]:
try:
return engine.search(
jql=req.jql,
start_at=req.startAt,
next_page_token=req.nextPageToken,
max_results=req.maxResults,
fields=req.fields,
)
Expand Down
3 changes: 2 additions & 1 deletion suite-core/connectors/bidirectional_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ async def pull(
params = {
"jql": jql,
"maxResults": 100,
"fields": "*navigable",
"expand": "changelog",
}

Expand All @@ -428,7 +429,7 @@ async def pull(
}

response = await client.get(
f"{base_url}/rest/api/3/search",
f"{base_url}/rest/api/3/search/jql",
params=params,
headers=headers,
)
Expand Down
23 changes: 13 additions & 10 deletions suite-core/connectors/sdlc_connectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,20 +452,23 @@ async def pull(self, since: Optional[datetime] = None) -> List[Dict[str, Any]]:

try:
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client:
start_at = 0
next_page_token = None
max_results = self._schedule.max_page_size

while True:
search_url = f"{url}/rest/api/3/search"
search_url = f"{url}/rest/api/3/search/jql"
params = {
"jql": jql,
"maxResults": max_results,
"fields": "*navigable",
"expand": "changelog",
}
if next_page_token:
params["nextPageToken"] = next_page_token
response = await client.get(
search_url,
headers=headers,
params={
"jql": jql,
"startAt": start_at,
"maxResults": max_results,
"expand": "changelog",
},
params=params,
)
response.raise_for_status()

Expand All @@ -485,9 +488,9 @@ async def pull(self, since: Optional[datetime] = None) -> List[Dict[str, Any]]:
"raw": issue,
})

if len(issues) < max_results:
next_page_token = data.get("nextPageToken")
if data.get("isLast", True) or not next_page_token:
break
start_at += max_results

logger.info(f"Jira: pulled {len(findings)} issues from {project}")
return findings
Expand Down
40 changes: 21 additions & 19 deletions suite-core/core/connectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -748,7 +748,7 @@ def search_issues(
self,
jql: str,
max_results: int = 50,
start_at: int = 0,
next_page_token: Optional[str] = None,
fields: Optional[List[str]] = None,
) -> ConnectorOutcome:
"""Search Jira issues using JQL (Agent READ operation)."""
Expand All @@ -757,14 +757,15 @@ def search_issues(
"skipped", {"reason": "jira connector not fully configured"}
)

endpoint = urljoin(self.base_url + "/", "rest/api/3/search")
endpoint = urljoin(self.base_url + "/", "rest/api/3/search/jql")
payload: Dict[str, Any] = {
"jql": jql,
"maxResults": max_results,
"startAt": start_at,
# fields defaults to id only on search/jql
"fields": fields or ["*navigable"],
}
if fields:
payload["fields"] = fields
if next_page_token:
payload["nextPageToken"] = next_page_token

try:
response = self._request(
Expand Down Expand Up @@ -797,8 +798,9 @@ def search_issues(
"fetched",
{
"endpoint": endpoint,
"total": data.get("total", 0),
"total": len(data.get("issues", [])),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: search_issues reports the page size under total, which reads as the match count. Rename the key to fetched to match bulk_search.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At suite-core/core/connectors.py, line 801:

<comment>`search_issues` reports the page size under `total`, which reads as the match count. Rename the key to `fetched` to match `bulk_search`.</comment>

<file context>
@@ -797,8 +798,9 @@ def search_issues(
             {
                 "endpoint": endpoint,
-                "total": data.get("total", 0),
+                "total": len(data.get("issues", [])),
                 "issues": data.get("issues", []),
+                "next_page_token": data.get("nextPageToken"),
</file context>
Suggested change
"total": len(data.get("issues", [])),
"fetched": len(data.get("issues", [])),

"issues": data.get("issues", []),
"next_page_token": data.get("nextPageToken"),
"data": data,
"operation": "search_issues",
},
Expand Down Expand Up @@ -874,27 +876,28 @@ def bulk_search(
"""Paginated JQL bulk query — fetches all matching issues up to max_results.

Returns all issues in a single ConnectorOutcome with automatic pagination.
Uses POST /rest/api/3/search with startAt advancement.
Uses POST /rest/api/3/search/jql with nextPageToken advancement.
"""
if not self.configured:
return ConnectorOutcome(
"skipped", {"reason": "jira connector not fully configured"}
)

all_issues: List[Dict[str, Any]] = []
start_at = 0
total = None
next_page_token: Optional[str] = None
pages_fetched = 0

while len(all_issues) < max_results:
batch_size = min(page_size, max_results - len(all_issues))
endpoint = urljoin(self.base_url + "/", "rest/api/3/search")
endpoint = urljoin(self.base_url + "/", "rest/api/3/search/jql")
payload: Dict[str, Any] = {
"jql": jql,
"maxResults": batch_size,
"startAt": start_at,
# fields defaults to id only on search/jql
"fields": fields or ["*navigable"],
}
if fields:
payload["fields"] = fields
if next_page_token:
payload["nextPageToken"] = next_page_token

try:
response = self._request(
Expand Down Expand Up @@ -925,22 +928,21 @@ def bulk_search(
break

issues = data.get("issues", [])
if total is None:
total = data.get("total", 0)
all_issues.extend(issues)
start_at += len(issues)
pages_fetched += 1
next_page_token = data.get("nextPageToken")

if not issues or start_at >= (total or 0):
if not issues or data.get("isLast", True) or not next_page_token:
break

return ConnectorOutcome(
"fetched",
{
"total": total or len(all_issues),
"total": len(all_issues),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: bulk_search now reports total equal to fetched, so a result capped at max_results looks complete. Return the final next_page_token as well so callers can tell more results exist and resume.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At suite-core/core/connectors.py, line 941:

<comment>`bulk_search` now reports `total` equal to `fetched`, so a result capped at `max_results` looks complete. Return the final `next_page_token` as well so callers can tell more results exist and resume.</comment>

<file context>
@@ -925,22 +928,21 @@ def bulk_search(
             "fetched",
             {
-                "total": total or len(all_issues),
+                "total": len(all_issues),
                 "fetched": len(all_issues),
                 "issues": all_issues,
</file context>
Suggested change
"total": len(all_issues),
"total": len(all_issues),
"next_page_token": next_page_token,

"fetched": len(all_issues),
Comment on lines +941 to 942

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Surface truncation when bulk search reaches max_results

When matching issues exceed max_results, the loop exits on its length guard with a nonempty next_page_token, but this result makes total identical to fetched and omits that token. The bulk_search action can no longer determine that the list is truncated or resume it (the old query-wide total exposed this), so return a continuation/has_more signal or obtain the query count.

Useful? React with 👍 / 👎.

"issues": all_issues,
"operation": "bulk_search",
"pages_fetched": (start_at // page_size) + (1 if start_at % page_size else 0),
"pages_fetched": pages_fetched,
},
)

Expand Down
11 changes: 6 additions & 5 deletions suite-core/core/jira_cloud_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,18 +243,19 @@ def get_issue(
def search(
self,
jql: str,
start_at: int = 0,
next_page_token: Optional[str] = None,
max_results: int = 50,
fields: Optional[List[str]] = None,
) -> Dict[str, Any]:
body: Dict[str, Any] = {
"jql": jql,
"startAt": start_at,
"maxResults": max_results,
# fields defaults to id only on search/jql
"fields": fields if fields is not None else ["*navigable"],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: An empty fields list is sent as-is, so search/jql returns only id instead of the *navigable default. Use a truthiness check so empty and None get the same default, matching connectors.py.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At suite-core/core/jira_cloud_engine.py, line 254:

<comment>An empty `fields` list is sent as-is, so `search/jql` returns only `id` instead of the `*navigable` default. Use a truthiness check so empty and None get the same default, matching `connectors.py`.</comment>

<file context>
@@ -243,18 +243,19 @@ def get_issue(
-            "startAt": start_at,
             "maxResults": max_results,
+            # fields defaults to id only on search/jql
+            "fields": fields if fields is not None else ["*navigable"],
         }
-        if fields is not None:
</file context>
Suggested change
"fields": fields if fields is not None else ["*navigable"],
"fields": fields or ["*navigable"],

}
if fields is not None:
body["fields"] = fields
return self._request("POST", "search", json_body=body) or {}
if next_page_token:
body["nextPageToken"] = next_page_token
return self._request("POST", "search/jql", json_body=body) or {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The new enhanced-search endpoint rejects unbounded JQL with 400, but this API still accepts and forwards unrestricted jql strings. Validate or require a bounded query before calling the new endpoint, and apply the same contract to the Jira connector paths.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At suite-core/core/jira_cloud_engine.py, line 258:

<comment>The new enhanced-search endpoint rejects unbounded JQL with 400, but this API still accepts and forwards unrestricted `jql` strings. Validate or require a bounded query before calling the new endpoint, and apply the same contract to the Jira connector paths.</comment>

<file context>
@@ -243,18 +243,19 @@ def get_issue(
-        return self._request("POST", "search", json_body=body) or {}
+        if next_page_token:
+            body["nextPageToken"] = next_page_token
+        return self._request("POST", "search/jql", json_body=body) or {}
 
     def get_transitions(self, issue_key: str) -> Dict[str, Any]:
</file context>


def get_transitions(self, issue_key: str) -> Dict[str, Any]:
return self._request("GET", f"issue/{issue_key}/transitions") or {"transitions": []}
Expand Down
2 changes: 1 addition & 1 deletion suite-core/core/jira_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ def transition_issue(self, issue_key: str, transition_id: str) -> None:

def search_issues(self, jql: str, max_results: int = 50) -> List[Dict[str, Any]]:
resp = self._session.post(
self._url("search"),
self._url("search/jql"),
json={"jql": jql, "maxResults": max_results, "fields": ["*all"]},
Comment on lines +554 to 555

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The URL moves to search/jql, but the request body still asks for *all. The PR description and every other caller of this endpoint use *navigable, so this path is inconsistent with the migration and may be rejected or behave differently on the new endpoint. Change the fields to *navigable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At suite-core/core/jira_sync.py, line 554:

<comment>The URL moves to `search/jql`, but the request body still asks for `*all`. The PR description and every other caller of this endpoint use `*navigable`, so this path is inconsistent with the migration and may be rejected or behave differently on the new endpoint. Change the fields to `*navigable`.</comment>

<file context>
@@ -551,7 +551,7 @@ def transition_issue(self, issue_key: str, transition_id: str) -> None:
     def search_issues(self, jql: str, max_results: int = 50) -> List[Dict[str, Any]]:
         resp = self._session.post(
-            self._url("search"),
+            self._url("search/jql"),
             json={"jql": jql, "maxResults": max_results, "fields": ["*all"]},
             auth=self._auth(),
</file context>
Suggested change
self._url("search/jql"),
json={"jql": jql, "maxResults": max_results, "fields": ["*all"]},
self._url("search/jql"),
json={"jql": jql, "maxResults": max_results, "fields": ["*navigable"]},

auth=self._auth(),
headers=self._headers(),
Expand Down
11 changes: 3 additions & 8 deletions tests/test_jira_cloud_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,14 +256,11 @@ def test_get_issue_passes_fields_and_expand(configured_engine: JiraCloudEngine,
def test_search_jql(configured_engine: JiraCloudEngine, stub: StubHTTPXClient) -> None:
stub.set(
"POST",
"search",
"search/jql",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Because this request provides fields, the test never verifies the new *navigable default. Add a no-fields search case and assert that the upstream body contains fields == ["*navigable"].

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At tests/test_jira_cloud_router.py, line 259:

<comment>Because this request provides `fields`, the test never verifies the new `*navigable` default. Add a no-fields search case and assert that the upstream body contains `fields == ["*navigable"]`.</comment>

<file context>
@@ -256,14 +256,11 @@ def test_get_issue_passes_fields_and_expand(configured_engine: JiraCloudEngine,
     stub.set(
         "POST",
-        "search",
+        "search/jql",
         _StubResponse(
             200,
</file context>

_StubResponse(
200,
{
"expand": "schema,names",
"startAt": 0,
"maxResults": 50,
"total": 1,
"isLast": True,
"issues": [
{"id": "10001", "key": "SEC-42", "fields": {"summary": "CVE-2025-9999"}},
],
Expand All @@ -275,19 +272,17 @@ def test_search_jql(configured_engine: JiraCloudEngine, stub: StubHTTPXClient) -
"/api/v1/jira-cloud/rest/api/3/search",
json={
"jql": "project = SEC AND status = 'In Progress'",
"startAt": 0,
"maxResults": 50,
"fields": ["summary", "status"],
},
)
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
assert body["issues"][0]["key"] == "SEC-42"
# Body forwarded with camelCase
sent = stub.calls[0]["json"]
assert sent["jql"] == "project = SEC AND status = 'In Progress'"
assert sent["startAt"] == 0
assert "startAt" not in sent

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This case never supplies nextPageToken, so it would still pass if page-2 tokens were dropped before the upstream request. Add a token-bearing request and assert sent["nextPageToken"] to cover the new pagination contract.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At tests/test_jira_cloud_router.py, line 285:

<comment>This case never supplies `nextPageToken`, so it would still pass if page-2 tokens were dropped before the upstream request. Add a token-bearing request and assert `sent["nextPageToken"]` to cover the new pagination contract.</comment>

<file context>
@@ -275,19 +272,17 @@ def test_search_jql(configured_engine: JiraCloudEngine, stub: StubHTTPXClient) -
     sent = stub.calls[0]["json"]
     assert sent["jql"] == "project = SEC AND status = 'In Progress'"
-    assert sent["startAt"] == 0
+    assert "startAt" not in sent
     assert sent["maxResults"] == 50
     assert sent["fields"] == ["summary", "status"]
</file context>

assert sent["maxResults"] == 50
assert sent["fields"] == ["summary", "status"]

Expand Down