Skip to content

Commit 9c2a196

Browse files
authored
Patch Search #87 (#88)
* 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 ``` * Skip `flake8` checks on patch file * Address linting
1 parent a1c8c54 commit 9c2a196

File tree

4 files changed

+352
-2
lines changed

4 files changed

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