Skip to content

Commit 46984d2

Browse files
committed
Patch Search #87
This PR addresses #87. Atlassian has deprecated the previous `search` endpoint on their `/v2/` api. This PR includes the modified code from the upstream `jira` python package. It is not possible to simply upgrade the version at this time, because the stable release of `st2` is on `python3.8`. The `jira` python package that includes this update to the `search` function requires `python3.10` or higher. [Atlassian Changelog](https://developer.atlassian.com/changelog/#CHANGE-2046) ### Pre-patch ```yaml id: 68c4d36858dbb201f539580b action.ref: jira.search_issues context.user: st2admin parameters: log_level: DEBUG query: project = SCOPS ORDER BY created DESC status: failed (2s elapsed) start_timestamp: Sat, 13 Sep 2025 02:14:00 UTC end_timestamp: Sat, 13 Sep 2025 02:14:02 UTC log: - status: requested timestamp: '2025-09-13T02:14:00.360000Z' - status: scheduled timestamp: '2025-09-13T02:14:00.509000Z' - status: running timestamp: '2025-09-13T02:14:00.544000Z' - status: failed timestamp: '2025-09-13T02:14:02.406000Z' result: exit_code: 1 result: None stderr: "Traceback (most recent call last): File "/opt/stackstorm/st2/lib/python3.8/site-packages/python_runner/python_action_wrapper.py", line 395, in <module> obj.run() File "/opt/stackstorm/st2/lib/python3.8/site-packages/python_runner/python_action_wrapper.py", line 214, in run output = action.run(**self._parameters) File "/opt/stackstorm/packs.dev/jira/actions/search_issues.py", line 14, in run issues = self._client.search_issues(query, startAt=start_at, File "/opt/stackstorm/virtualenvs/jira/lib/python3.8/site-packages/jira/client.py", line 3557, in search_issues issues = self._fetch_pages( File "/opt/stackstorm/virtualenvs/jira/lib/python3.8/site-packages/jira/client.py", line 817, in _fetch_pages resource = self._get_json( File "/opt/stackstorm/virtualenvs/jira/lib/python3.8/site-packages/jira/client.py", line 4358, in _get_json else self._session.get(url, params=params) File "/opt/stackstorm/virtualenvs/jira/lib/python3.8/site-packages/requests/sessions.py", line 602, in get return self.request("GET", url, **kwargs) File "/opt/stackstorm/virtualenvs/jira/lib/python3.8/site-packages/jira/resilientsession.py", line 247, in request elif raise_on_error(response, **processed_kwargs): File "/opt/stackstorm/virtualenvs/jira/lib/python3.8/site-packages/jira/resilientsession.py", line 72, in raise_on_error raise JIRAError( jira.exceptions.JIRAError: JiraError HTTP 410 url: https://<redacted>.atlassian.net/rest/api/2/search?jql=project+%3D+SCOPS+AND+statusCategory+%21%3D+Done+ORDER+BY+created+DESC&startAt=0&validateQuery=True&fields=%2Aall&maxResults=50 \ttext: The requested API has been removed. Please migrate to the /rest/api/3/search/jql API. A full migration guideline is available at https://developer.atlassian.com/changelog/#CHANGE-2046 " stdout: '' ``` ### Post-patch ```yaml id: 68c4e40958dbb201f5395824 action.ref: jira.search_issues context.user: st2admin parameters: log_level: DEBUG query: reporter = currentUser() ORDER BY created DESC status: succeeded (3s elapsed) start_timestamp: Sat, 13 Sep 2025 03:24:57 UTC end_timestamp: Sat, 13 Sep 2025 03:25:00 UTC log: - status: requested timestamp: '2025-09-13T03:24:57.308000Z' - status: scheduled timestamp: '2025-09-13T03:24:57.369000Z' - status: running timestamp: '2025-09-13T03:24:57.392000Z' - status: succeeded timestamp: '2025-09-13T03:25:00.296000Z' result: exit_code: 0 result: - assignee: null created_at: 2025-08-08T23:39:38.473-0400 description: 'Test ' id: '1374684' key: SCOPS-11111 labels: [] priority: Unprioritized reporter: Justin Palmer resolution: null resolved_at: null status: To do summary: 'Test ' updated_at: 2025-08-09T03:49:29.881-0400 url: https://<redacted>.atlassian.net/browse/SCOPS-11111 ```
1 parent a1c8c54 commit 46984d2

File tree

4 files changed

+350
-2
lines changed

4 files changed

+350
-2
lines changed

CHANGES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Change Log
22

3+
## 3.2.2
4+
- Addresses [#87](https://github.com/StackStorm-Exchange/stackstorm-jira/issues/87) search failure due to [deprecation of /v2/search endpoint](https://developer.atlassian.com/changelog/#CHANGE-2046)
5+
36
## 3.2.1
47
- Fixed the deafult attribute invocation for jira field ``description`` to verify that the attribute exists first. If ``description`` attribute does not exist then return ``null``.
58

actions/lib/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from jira import JIRA
1+
from patched_search import JIRA
22
import base64
33

44
# from st2common.runners.base_action import Action

actions/lib/patched_search.py

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import warnings
5+
from typing import Any, Generic, Iterable, overload
6+
7+
from jira import JIRA
8+
from jira.client import ResourceType, cloud_api
9+
from jira.resources import Issue
10+
11+
JIRA_BASE_URL = JIRA.JIRA_BASE_URL
12+
13+
14+
class ResultList(list, Generic[ResourceType]):
15+
def __init__(
16+
self,
17+
iterable: Iterable | None = None,
18+
_startAt: int = 0,
19+
_maxResults: int = 0,
20+
_total: int | None = None,
21+
_isLast: bool | None = None,
22+
_nextPageToken: str | None = None,
23+
) -> None:
24+
"""Results List.
25+
26+
Args:
27+
iterable (Iterable): [description]. Defaults to None.
28+
_startAt (int): Start page. Defaults to 0.
29+
_maxResults (int): Max results per page. Defaults to 0.
30+
_total (Optional[int]): Total results from query. Defaults to 0.
31+
_isLast (Optional[bool]): True to mark this page is the last page? (Default: ``None``).
32+
_nextPageToken (Optional[str]): Token for fetching the next page of results. Defaults to None.
33+
see `The official API docs <https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/#expansion:~:text=for%20all%20operations.-,isLast,-indicates%20whether%20the>`_
34+
"""
35+
if iterable is not None:
36+
list.__init__(self, iterable)
37+
else:
38+
list.__init__(self)
39+
40+
self.startAt = _startAt
41+
self.maxResults = _maxResults
42+
# Optional parameters:
43+
self.isLast = _isLast
44+
self.total = _total if _total is not None else len(self)
45+
46+
self.iterable: list[ResourceType] = list(iterable) if iterable else []
47+
self.current = self.startAt
48+
self.nextPageToken = _nextPageToken
49+
50+
def __next__(self) -> ResourceType: # type:ignore[misc]
51+
self.current += 1
52+
if self.current > self.total:
53+
raise StopIteration
54+
else:
55+
return self.iterable[self.current - 1]
56+
57+
def __iter__(self) -> Iterator[ResourceType]:
58+
return super().__iter__()
59+
60+
# fmt: off
61+
# The mypy error we ignore is about returning a contravariant type.
62+
# As this class is a List of a generic 'Resource' class
63+
# this is the right way to specify that the output is the same as which
64+
# the class was initialized with.
65+
@overload
66+
def __getitem__(self, i: SupportsIndex) -> ResourceType: ... # type:ignore[misc] # noqa: E704
67+
@overload
68+
def __getitem__(self, s: slice) -> list[ResourceType]: ... # type:ignore[misc] # noqa: E704
69+
def __getitem__(self, slice_or_index): # noqa: E301,E261
70+
return list.__getitem__(self, slice_or_index)
71+
# fmt: on
72+
73+
74+
def patched_search_issues(
75+
self,
76+
jql_str: str,
77+
startAt: int = 0,
78+
maxResults: int = 50,
79+
validate_query: bool = True,
80+
fields: str | list[str] | None = "*all",
81+
expand: str | None = None,
82+
properties: str | None = None,
83+
*,
84+
json_result: bool = False,
85+
use_post: bool = False,
86+
) -> dict[str, Any] | ResultList[Issue]:
87+
"""Get a :class:`~jira.client.ResultList` of issue Resources matching a JQL search string.
88+
89+
Args:
90+
jql_str (str): The JQL search string.
91+
startAt (int): Index of the first issue to return. (Default: ``0``)
92+
maxResults (int): Maximum number of issues to return.
93+
Total number of results is available in the ``total`` attribute of the returned :class:`ResultList`.
94+
If maxResults evaluates to False, it will try to get all issues in batches. (Default: ``50``)
95+
validate_query (bool): True to validate the query. (Default: ``True``)
96+
fields (Optional[Union[str, List[str]]]): comma-separated string or list of issue fields to include in the results.
97+
Default is to include all fields.
98+
expand (Optional[str]): extra information to fetch inside each resource
99+
properties (Optional[str]): extra properties to fetch inside each result
100+
json_result (bool): True to return a JSON response. When set to False a :class:`ResultList` will be returned. (Default: ``False``)
101+
use_post (bool): True to use POST endpoint to fetch issues.
102+
103+
Returns:
104+
Union[Dict,ResultList]: Dict if ``json_result=True``
105+
"""
106+
if isinstance(fields, str):
107+
fields = fields.split(",")
108+
elif fields is None:
109+
fields = ["*all"]
110+
111+
if self._is_cloud:
112+
if startAt == 0:
113+
return self.enhanced_search_issues(
114+
jql_str=jql_str,
115+
maxResults=maxResults,
116+
fields=fields,
117+
expand=expand,
118+
properties=properties,
119+
json_result=json_result,
120+
use_post=use_post,
121+
)
122+
else:
123+
raise JIRAError(
124+
"The `search` API is deprecated in Jira Cloud. Use `enhanced_search_issues` method instead."
125+
)
126+
127+
# this will translate JQL field names to REST API Name
128+
# most people do know the JQL names so this will help them use the API easier
129+
untranslate = {} # use to add friendly aliases when we get the results back
130+
if self._fields_cache:
131+
for i, field in enumerate(fields):
132+
if field in self._fields_cache:
133+
untranslate[self._fields_cache[field]] = fields[i]
134+
fields[i] = self._fields_cache[field]
135+
136+
search_params = {
137+
"jql": jql_str,
138+
"startAt": startAt,
139+
"validateQuery": validate_query,
140+
"fields": fields,
141+
"expand": expand,
142+
"properties": properties,
143+
}
144+
# for the POST version of this endpoint Jira
145+
# complains about unrecognized field "properties"
146+
if use_post:
147+
search_params.pop("properties")
148+
if json_result:
149+
search_params["maxResults"] = maxResults
150+
if not maxResults:
151+
warnings.warn(
152+
"All issues cannot be fetched at once, when json_result parameter is set",
153+
Warning,
154+
)
155+
r_json: dict[str, Any] = self._get_json(
156+
"search", params=search_params, use_post=use_post
157+
)
158+
return r_json
159+
160+
issues = self._fetch_pages(
161+
Issue,
162+
"issues",
163+
"search",
164+
startAt,
165+
maxResults,
166+
search_params,
167+
use_post=use_post,
168+
)
169+
170+
if untranslate:
171+
iss: Issue
172+
for iss in issues:
173+
for k, v in untranslate.items():
174+
if iss.raw:
175+
if k in iss.raw.get("fields", {}):
176+
iss.raw["fields"][v] = iss.raw["fields"][k]
177+
178+
return issues
179+
180+
181+
@cloud_api
182+
def enhanced_search_issues(
183+
self,
184+
jql_str: str,
185+
nextPageToken: str | None = None,
186+
maxResults: int = 50,
187+
fields: str | list[str] | None = "*all",
188+
expand: str | None = None,
189+
reconcileIssues: list[int] | None = None,
190+
properties: str | None = None,
191+
*,
192+
json_result: bool = False,
193+
use_post: bool = False,
194+
) -> dict[str, Any] | ResultList[Issue]:
195+
"""Get a :class:`~jira.client.ResultList` of issue Resources matching a JQL search string.
196+
197+
Args:
198+
jql_str (str): The JQL search string.
199+
nextPageToken (Optional[str]): Token for paginated results.
200+
maxResults (int): Maximum number of issues to return.
201+
Total number of results is available in the ``total`` attribute of the returned :class:`ResultList`.
202+
If maxResults evaluates to False, it will try to get all issues in batches. (Default: ``50``)
203+
fields (Optional[Union[str, List[str]]]): comma-separated string or list of issue fields to include in the results.
204+
Default is to include all fields If you don't require fields, set it to empty string ``''``.
205+
expand (Optional[str]): extra information to fetch inside each resource.
206+
reconcileIssues (Optional[List[int]]): List of issue IDs to reconcile.
207+
properties (Optional[str]): extra properties to fetch inside each result
208+
json_result (bool): True to return a JSON response. When set to False a :class:`ResultList` will be returned. (Default: ``False``)
209+
use_post (bool): True to use POST endpoint to fetch issues.
210+
211+
Returns:
212+
Union[Dict, ResultList]: JSON Dict if ``json_result=True``, otherwise a `ResultList`.
213+
"""
214+
if isinstance(fields, str):
215+
fields = fields.split(",")
216+
elif fields is None:
217+
fields = ["*all"]
218+
219+
untranslate = {} # use to add friendly aliases when we get the results back
220+
if fields:
221+
# this will translate JQL field names to REST API Name
222+
# most people do know the JQL names so this will help them use the API easier
223+
if self._fields_cache:
224+
for i, field in enumerate(fields):
225+
if field in self._fields_cache:
226+
untranslate[self._fields_cache[field]] = fields[i]
227+
fields[i] = self._fields_cache[field]
228+
229+
search_params: dict[str, Any] = {
230+
"jql": jql_str,
231+
"fields": fields,
232+
"expand": expand,
233+
"properties": properties,
234+
"reconcileIssues": reconcileIssues or [],
235+
}
236+
if nextPageToken:
237+
search_params["nextPageToken"] = nextPageToken
238+
239+
if json_result:
240+
if not maxResults:
241+
warnings.warn(
242+
"All issues cannot be fetched at once, when json_result parameter is set",
243+
Warning,
244+
)
245+
else:
246+
search_params["maxResults"] = maxResults
247+
r_json: dict[str, Any] = self._get_json(
248+
"search/jql", params=search_params, use_post=use_post
249+
)
250+
return r_json
251+
252+
issues = self._fetch_pages_searchToken(
253+
item_type=Issue,
254+
items_key="issues",
255+
request_path="search/jql",
256+
maxResults=maxResults,
257+
params=search_params,
258+
use_post=use_post,
259+
)
260+
261+
if untranslate:
262+
iss: Issue
263+
for iss in issues:
264+
for k, v in untranslate.items():
265+
if iss.raw:
266+
if k in iss.raw.get("fields", {}):
267+
iss.raw["fields"][v] = iss.raw["fields"][k]
268+
269+
return issues
270+
271+
272+
@cloud_api
273+
def _fetch_pages_searchToken(
274+
self,
275+
item_type: type[ResourceType],
276+
items_key: str | None,
277+
request_path: str,
278+
maxResults: int = 50,
279+
params: dict[str, Any] | None = None,
280+
base: str = JIRA_BASE_URL,
281+
use_post: bool = False,
282+
) -> ResultList[ResourceType]:
283+
"""Fetch from a paginated API endpoint using `nextPageToken`.
284+
285+
Args:
286+
item_type (Type[Resource]): Type of single item. Returns a `ResultList` of such items.
287+
items_key (Optional[str]): Path to the items in JSON returned from the server.
288+
request_path (str): Path in the request URL.
289+
maxResults (int): Maximum number of items to return per page. (Default: 50)
290+
params (Dict[str, Any]): Parameters to be sent with the request.
291+
base (str): Base URL for the requests.
292+
use_post (bool): Whether to use POST instead of GET.
293+
294+
Returns:
295+
ResultList: List of fetched items.
296+
"""
297+
DEFAULT_BATCH = 100 # Max batch size per request
298+
fetch_all = maxResults in (0, False) # If False/0, fetch everything
299+
300+
page_params = (params or {}).copy() # Ensure params isn't modified
301+
page_params["maxResults"] = DEFAULT_BATCH if fetch_all else maxResults
302+
303+
# Use caller-provided nextPageToken if present
304+
nextPageToken: str | None = page_params.get("nextPageToken")
305+
items: list[ResourceType] = []
306+
307+
while True:
308+
# Ensure nextPageToken is set in params if it exists
309+
if nextPageToken:
310+
page_params["nextPageToken"] = nextPageToken
311+
else:
312+
page_params.pop("nextPageToken", None)
313+
314+
response = self._get_json(
315+
request_path, params=page_params, base=base, use_post=use_post
316+
)
317+
items.extend(self._get_items_from_page(item_type, items_key, response))
318+
nextPageToken = response.get("nextPageToken")
319+
if not fetch_all or not nextPageToken:
320+
break
321+
322+
return ResultList(items, _nextPageToken=nextPageToken)
323+
324+
325+
def _get_items_from_page(
326+
self,
327+
item_type: type[ResourceType],
328+
items_key: str | None,
329+
resource: dict[str, Any],
330+
) -> list[ResourceType]:
331+
try:
332+
return [
333+
# We need to ignore the type here, as 'Resource' is an option
334+
item_type(self._options, self._session, raw_issue_json) # type: ignore
335+
for raw_issue_json in (resource[items_key] if items_key else resource)
336+
]
337+
except KeyError as e:
338+
# improving the error text so we know why it happened
339+
raise KeyError(str(e) + " : " + json.dumps(resource))
340+
341+
342+
JIRA._fetch_pages_searchToken = _fetch_pages_searchToken
343+
JIRA._get_items_from_page = _get_items_from_page
344+
JIRA.enhanced_search_issues = enhanced_search_issues
345+
JIRA.search_issues = patched_search_issues

pack.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ keywords:
66
- issues
77
- ticket management
88
- project management
9-
version: 3.2.1
9+
version: 3.2.2
1010
python_versions:
1111
- "3"
1212
author: StackStorm, Inc.

0 commit comments

Comments
 (0)