From a7222390a10126b9e0830df92ff59041b7965a08 Mon Sep 17 00:00:00 2001 From: Agustin Torres Date: Mon, 26 Oct 2026 04:33:05 +0000 Subject: [PATCH] Adapt to the atlassian.net API change effective 2025-05-01 Moved all Jira Cloud JQL searches from the removed /rest/api/3/search to /rest/api/3/search/jql. Pagination now uses nextPageToken/isLast instead of startAt/total, and requests ask for fields=*navigable because the new endpoint returns only ids by default. I updated the jira-cloud router test and its stub to match; I didn't run any tests. Notice jira-cloud-change-2046-search-jql about atlassian.net's API describes the change. It was read from a file and is not signed by the provider. --- suite-api/apps/api/jira_cloud_router.py | 4 +-- suite-core/connectors/bidirectional_sync.py | 3 +- suite-core/connectors/sdlc_connectors.py | 23 ++++++------ suite-core/core/connectors.py | 40 +++++++++++---------- suite-core/core/jira_cloud_engine.py | 11 +++--- suite-core/core/jira_sync.py | 2 +- tests/test_jira_cloud_router.py | 11 ++---- 7 files changed, 48 insertions(+), 46 deletions(-) diff --git a/suite-api/apps/api/jira_cloud_router.py b/suite-api/apps/api/jira_cloud_router.py index e25ec23f8..86e60b36d 100644 --- a/suite-api/apps/api/jira_cloud_router.py +++ b/suite-api/apps/api/jira_cloud_router.py @@ -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") @@ -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, ) diff --git a/suite-core/connectors/bidirectional_sync.py b/suite-core/connectors/bidirectional_sync.py index 304d48038..12ae86c8d 100644 --- a/suite-core/connectors/bidirectional_sync.py +++ b/suite-core/connectors/bidirectional_sync.py @@ -419,6 +419,7 @@ async def pull( params = { "jql": jql, "maxResults": 100, + "fields": "*navigable", "expand": "changelog", } @@ -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, ) diff --git a/suite-core/connectors/sdlc_connectors.py b/suite-core/connectors/sdlc_connectors.py index d5e625b10..661f1b515 100644 --- a/suite-core/connectors/sdlc_connectors.py +++ b/suite-core/connectors/sdlc_connectors.py @@ -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() @@ -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 diff --git a/suite-core/core/connectors.py b/suite-core/core/connectors.py index 5e43b58e1..2aff76e50 100644 --- a/suite-core/core/connectors.py +++ b/suite-core/core/connectors.py @@ -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).""" @@ -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( @@ -797,8 +798,9 @@ def search_issues( "fetched", { "endpoint": endpoint, - "total": data.get("total", 0), + "total": len(data.get("issues", [])), "issues": data.get("issues", []), + "next_page_token": data.get("nextPageToken"), "data": data, "operation": "search_issues", }, @@ -874,7 +876,7 @@ 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( @@ -882,19 +884,20 @@ def bulk_search( ) 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( @@ -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), "fetched": len(all_issues), "issues": all_issues, "operation": "bulk_search", - "pages_fetched": (start_at // page_size) + (1 if start_at % page_size else 0), + "pages_fetched": pages_fetched, }, ) diff --git a/suite-core/core/jira_cloud_engine.py b/suite-core/core/jira_cloud_engine.py index 76ada20f1..226fa0fc4 100644 --- a/suite-core/core/jira_cloud_engine.py +++ b/suite-core/core/jira_cloud_engine.py @@ -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"], } - 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 {} def get_transitions(self, issue_key: str) -> Dict[str, Any]: return self._request("GET", f"issue/{issue_key}/transitions") or {"transitions": []} diff --git a/suite-core/core/jira_sync.py b/suite-core/core/jira_sync.py index 112892dd9..4df1b170d 100644 --- a/suite-core/core/jira_sync.py +++ b/suite-core/core/jira_sync.py @@ -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(), headers=self._headers(), diff --git a/tests/test_jira_cloud_router.py b/tests/test_jira_cloud_router.py index 8bdc7dc29..d5157047b 100644 --- a/tests/test_jira_cloud_router.py +++ b/tests/test_jira_cloud_router.py @@ -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", _StubResponse( 200, { - "expand": "schema,names", - "startAt": 0, - "maxResults": 50, - "total": 1, + "isLast": True, "issues": [ {"id": "10001", "key": "SEC-42", "fields": {"summary": "CVE-2025-9999"}}, ], @@ -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 assert sent["maxResults"] == 50 assert sent["fields"] == ["summary", "status"]