python-jira-plus 2.0.7__py3-none-any.whl → 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_jira_plus/__init__.py +4 -2
- python_jira_plus/describe_allowed_value.py +3 -3
- python_jira_plus/{jira_plus.py → jira_base.py} +43 -194
- python_jira_plus/jira_cloud.py +111 -0
- python_jira_plus/jira_on_premise.py +98 -0
- {python_jira_plus-2.0.7.dist-info → python_jira_plus-3.0.0.dist-info}/METADATA +9 -7
- python_jira_plus-3.0.0.dist-info/RECORD +10 -0
- python_jira_plus-2.0.7.dist-info/RECORD +0 -8
- {python_jira_plus-2.0.7.dist-info → python_jira_plus-3.0.0.dist-info}/WHEEL +0 -0
- {python_jira_plus-2.0.7.dist-info → python_jira_plus-3.0.0.dist-info}/licenses/LICENSE +0 -0
- {python_jira_plus-2.0.7.dist-info → python_jira_plus-3.0.0.dist-info}/top_level.txt +0 -0
python_jira_plus/__init__.py
CHANGED
|
@@ -8,7 +8,9 @@ pagination, metadata validation, and more.
|
|
|
8
8
|
from dotenv import load_dotenv
|
|
9
9
|
|
|
10
10
|
from python_jira_plus.describe_allowed_value import describe_allowed_value
|
|
11
|
-
from python_jira_plus.
|
|
11
|
+
from python_jira_plus.jira_base import JiraBase, UrlScheme
|
|
12
|
+
from python_jira_plus.jira_cloud import JiraCloud
|
|
13
|
+
from python_jira_plus.jira_on_premise import JiraOnPremise
|
|
12
14
|
|
|
13
15
|
load_dotenv()
|
|
14
16
|
|
|
@@ -27,4 +29,4 @@ BASIC_FIELDS = [
|
|
|
27
29
|
"resolution",
|
|
28
30
|
"parent",
|
|
29
31
|
]
|
|
30
|
-
__all__ = ["
|
|
32
|
+
__all__ = ["JiraBase", "JiraCloud", "JiraOnPremise", "UrlScheme", "BASIC_FIELDS"]
|
|
@@ -11,17 +11,17 @@ def describe_allowed_value(value: Any, allowed_values: set = None) -> bool:
|
|
|
11
11
|
|
|
12
12
|
@describe_allowed_value.register
|
|
13
13
|
def _(value: int, allowed_values: list = None) -> bool:
|
|
14
|
-
return value
|
|
14
|
+
return value in allowed_values
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
@describe_allowed_value.register
|
|
18
18
|
def _(value: float, allowed_values: list = None) -> bool:
|
|
19
|
-
return value
|
|
19
|
+
return value in allowed_values
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
@describe_allowed_value.register
|
|
23
23
|
def _(value: str, allowed_values: list = None) -> bool:
|
|
24
|
-
return value
|
|
24
|
+
return value in allowed_values
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
@describe_allowed_value.register
|
|
@@ -1,18 +1,16 @@
|
|
|
1
|
-
#
|
|
1
|
+
# jira_base.py
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
5
|
import os
|
|
6
|
-
import
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
7
|
from enum import Enum
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
import requests
|
|
11
10
|
from custom_python_logger import get_logger
|
|
12
11
|
from jira import JIRA, Issue, JIRAError
|
|
13
12
|
from jira.client import ResultList
|
|
14
|
-
|
|
15
|
-
from packaging.version import Version
|
|
13
|
+
|
|
16
14
|
from python_jira_plus.describe_allowed_value import describe_allowed_value
|
|
17
15
|
|
|
18
16
|
|
|
@@ -21,21 +19,16 @@ class UrlScheme(Enum):
|
|
|
21
19
|
HTTPS = "https"
|
|
22
20
|
|
|
23
21
|
|
|
24
|
-
class
|
|
25
|
-
|
|
26
|
-
ON_PREMISE = "on_premise"
|
|
27
|
-
|
|
22
|
+
class JiraBase(ABC):
|
|
23
|
+
use_createmeta: bool
|
|
28
24
|
|
|
29
|
-
class JiraPlus:
|
|
30
25
|
def __init__(
|
|
31
26
|
self,
|
|
32
27
|
base_url: str | None = None,
|
|
33
|
-
server_type: ServerType = ServerType.CLOUD,
|
|
34
28
|
urllib3_log_level: int = logging.WARNING,
|
|
35
29
|
jira_username: str | None = None,
|
|
36
|
-
jira_token: str | None = None,
|
|
30
|
+
jira_token: str | None = None,
|
|
37
31
|
verify_ssl: bool = True,
|
|
38
|
-
sso: bool = False,
|
|
39
32
|
url_scheme: UrlScheme = UrlScheme.HTTPS,
|
|
40
33
|
) -> None:
|
|
41
34
|
self.logger = get_logger(self.__class__.__name__)
|
|
@@ -45,33 +38,28 @@ class JiraPlus:
|
|
|
45
38
|
self.jira_token = jira_token or os.getenv("JIRA_TOKEN")
|
|
46
39
|
self.base_url = base_url or os.getenv("JIRA_BASE_URL")
|
|
47
40
|
self.url_scheme = url_scheme.value
|
|
48
|
-
|
|
49
|
-
self.server_type = server_type
|
|
50
41
|
self.server = f"{self.url_scheme}://{self.base_url}"
|
|
51
42
|
self.verify_ssl = verify_ssl
|
|
52
|
-
self.sso = sso
|
|
53
43
|
|
|
54
44
|
self.jira_client = self.create_connection()
|
|
55
45
|
self.check_client_connection()
|
|
46
|
+
self.server_version = self.get_server_version()
|
|
56
47
|
|
|
57
|
-
@
|
|
58
|
-
def create_connection(self, timeout: int = 580) -> JIRA
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def create_connection(self, timeout: int = 580) -> JIRA: ...
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def _paginate(
|
|
53
|
+
self,
|
|
54
|
+
query: str,
|
|
55
|
+
max_results: int,
|
|
56
|
+
specific_fields: str | list[str],
|
|
57
|
+
jira_err_limit: int,
|
|
58
|
+
json_result: bool,
|
|
59
|
+
) -> ResultList[Issue] | list: ...
|
|
69
60
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return jira_client
|
|
73
|
-
self.logger.error("Jira Connection Failed")
|
|
74
|
-
raise JIRAError("Jira Connection Failed")
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def _format_assignee(self, assignee: str) -> dict: ...
|
|
75
63
|
|
|
76
64
|
def close_connection(self) -> None:
|
|
77
65
|
try:
|
|
@@ -80,7 +68,6 @@ class JiraPlus:
|
|
|
80
68
|
self.logger.exception(f"JIRAError \n{err}")
|
|
81
69
|
except Exception as err:
|
|
82
70
|
self.logger.exception(f"Exception \n{err}")
|
|
83
|
-
|
|
84
71
|
self.check_client_connection()
|
|
85
72
|
|
|
86
73
|
def check_client_connection(self) -> bool:
|
|
@@ -96,6 +83,9 @@ class JiraPlus:
|
|
|
96
83
|
self.logger.exception(f"Exception \n{err}")
|
|
97
84
|
return False
|
|
98
85
|
|
|
86
|
+
def get_server_version(self) -> tuple[int, ...]:
|
|
87
|
+
return tuple(self.jira_client.server_info()["versionNumbers"])
|
|
88
|
+
|
|
99
89
|
def get_issue_by_key(
|
|
100
90
|
self, key: str, specific_fields: str | list[str] = "*all", json_result: bool = True
|
|
101
91
|
) -> None | Issue | dict[str, Any]:
|
|
@@ -118,12 +108,8 @@ class JiraPlus:
|
|
|
118
108
|
specific_fields: str | list[str] = "*all",
|
|
119
109
|
jira_err_count: int = 3,
|
|
120
110
|
json_result: bool = True,
|
|
121
|
-
) -> ResultList[Issue] |
|
|
122
|
-
|
|
123
|
-
_paginate_query_func = self._paginate_query
|
|
124
|
-
else:
|
|
125
|
-
_paginate_query_func = self._paginate_query_new
|
|
126
|
-
return _paginate_query_func(
|
|
111
|
+
) -> ResultList[Issue] | list:
|
|
112
|
+
return self._paginate(
|
|
127
113
|
query=query,
|
|
128
114
|
max_results=max_results,
|
|
129
115
|
specific_fields=specific_fields,
|
|
@@ -131,102 +117,26 @@ class JiraPlus:
|
|
|
131
117
|
json_result=json_result,
|
|
132
118
|
)
|
|
133
119
|
|
|
134
|
-
def
|
|
135
|
-
self,
|
|
136
|
-
query: str,
|
|
137
|
-
max_results: int,
|
|
138
|
-
specific_fields: str | list[str],
|
|
139
|
-
jira_err_limit: int,
|
|
140
|
-
json_result: bool,
|
|
141
|
-
) -> ResultList[Issue] | list:
|
|
142
|
-
all_issues = []
|
|
143
|
-
retries = 0
|
|
144
|
-
page = None
|
|
145
|
-
while True: # pylint: disable=W0149
|
|
146
|
-
try:
|
|
147
|
-
self.logger.debug(f"Fetching issues from startAt={len(all_issues)}")
|
|
148
|
-
page = self.jira_client.enhanced_search_issues(
|
|
149
|
-
jql_str=query,
|
|
150
|
-
nextPageToken=page['nextPageToken'] if page else None,
|
|
151
|
-
maxResults=max_results,
|
|
152
|
-
fields=specific_fields,
|
|
153
|
-
json_result=json_result,
|
|
154
|
-
)
|
|
155
|
-
if not page:
|
|
156
|
-
break
|
|
157
|
-
|
|
158
|
-
all_issues += page["issues"] if json_result else page
|
|
159
|
-
if page['isLast']:
|
|
160
|
-
break
|
|
161
|
-
retries = 0 # reset on success
|
|
162
|
-
except JIRAError as err:
|
|
163
|
-
if err.status_code == 400:
|
|
164
|
-
self.logger.error(f"Bad Request: {err.text}")
|
|
165
|
-
raise JIRAError(f"Bad Request: {err.text}") from err
|
|
166
|
-
retries += 1
|
|
167
|
-
self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
|
|
168
|
-
if retries >= jira_err_limit:
|
|
169
|
-
self.logger.exception("Max retries exceeded.")
|
|
170
|
-
return []
|
|
171
|
-
time.sleep(300 if retries == jira_err_limit - 1 else 90)
|
|
172
|
-
return all_issues
|
|
173
|
-
|
|
174
|
-
def _paginate_query(
|
|
175
|
-
self,
|
|
176
|
-
query: str,
|
|
177
|
-
max_results: int,
|
|
178
|
-
specific_fields: str | list[str],
|
|
179
|
-
jira_err_limit: int,
|
|
180
|
-
json_result: bool,
|
|
181
|
-
) -> ResultList[Issue] | list:
|
|
182
|
-
start_at = 0
|
|
183
|
-
all_issues = []
|
|
184
|
-
retries = 0
|
|
185
|
-
while True: # pylint: disable=W0149
|
|
186
|
-
try:
|
|
187
|
-
self.logger.debug(f"Fetching issues from startAt={start_at}")
|
|
188
|
-
page = self.jira_client.search_issues(
|
|
189
|
-
jql_str=query,
|
|
190
|
-
startAt=start_at,
|
|
191
|
-
maxResults=max_results,
|
|
192
|
-
fields=specific_fields,
|
|
193
|
-
json_result=json_result,
|
|
194
|
-
)
|
|
195
|
-
if not page:
|
|
196
|
-
break
|
|
197
|
-
|
|
198
|
-
all_issues += page["issues"] if json_result else page
|
|
199
|
-
if json_result:
|
|
200
|
-
start_at += page["maxResults"]
|
|
201
|
-
else:
|
|
202
|
-
start_at += page.maxResults
|
|
203
|
-
if start_at >= max_results:
|
|
204
|
-
break
|
|
205
|
-
retries = 0 # reset on success
|
|
206
|
-
except JIRAError as err:
|
|
207
|
-
if err.status_code == 400:
|
|
208
|
-
self.logger.error(f"Bad Request: {err.text}")
|
|
209
|
-
raise JIRAError(f"Bad Request: {err.text}") from err
|
|
210
|
-
retries += 1
|
|
211
|
-
self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
|
|
212
|
-
if retries >= jira_err_limit:
|
|
213
|
-
self.logger.exception("Max retries exceeded.")
|
|
214
|
-
return []
|
|
215
|
-
time.sleep(300 if retries == jira_err_limit - 1 else 90)
|
|
216
|
-
return all_issues
|
|
217
|
-
|
|
218
|
-
def _fetch_metadata(self, project_key: str, issue_type: str) -> tuple[dict, dict]:
|
|
120
|
+
def _fetch_via_createmeta(self, project_key: str, issue_type: str) -> dict:
|
|
219
121
|
meta = self.jira_client.createmeta(
|
|
220
122
|
projectKeys=project_key, issuetypeNames=issue_type, expand="projects.issuetypes.fields"
|
|
221
123
|
)
|
|
222
|
-
|
|
223
124
|
if not (project_meta := next((p for p in meta["projects"] if p["key"] == project_key), None)):
|
|
224
125
|
raise ValueError(f"Project {project_key} not found in metadata")
|
|
225
|
-
|
|
226
126
|
issue_meta = next((it for it in project_meta["issuetypes"] if it["name"].lower() == issue_type.lower()), None)
|
|
227
127
|
if not issue_meta:
|
|
228
128
|
raise ValueError(f"Issue type '{issue_type}' not found in project '{project_key}'")
|
|
229
|
-
return
|
|
129
|
+
return issue_meta["fields"]
|
|
130
|
+
|
|
131
|
+
def _fetch_via_issue_types_api(self, project_key: str, issue_type: str) -> dict:
|
|
132
|
+
issue_types = {t.name: t for t in self.jira_client.project_issue_types(project_key)}
|
|
133
|
+
field_list = self.jira_client.project_issue_fields(project_key, issue_types[issue_type].id)
|
|
134
|
+
return {f.fieldId: f.raw for f in field_list}
|
|
135
|
+
|
|
136
|
+
def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
|
|
137
|
+
if self.use_createmeta:
|
|
138
|
+
return self._fetch_via_createmeta(project_key=project_key, issue_type=issue_type)
|
|
139
|
+
return self._fetch_via_issue_types_api(project_key=project_key, issue_type=issue_type)
|
|
230
140
|
|
|
231
141
|
def get_allowed_values(
|
|
232
142
|
self,
|
|
@@ -234,28 +144,20 @@ class JiraPlus:
|
|
|
234
144
|
issue_type: str,
|
|
235
145
|
field_id_or_name: str,
|
|
236
146
|
) -> list | None:
|
|
237
|
-
"""
|
|
238
|
-
Returns the allowed values for a specific field in a project + issue type context.
|
|
239
|
-
"""
|
|
147
|
+
"""Returns the allowed values for a specific field in a project + issue type context."""
|
|
240
148
|
fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
|
|
241
|
-
|
|
242
|
-
# Try to find by ID or fallback to case-insensitive name
|
|
243
149
|
if not (field := fields_metadata.get(field_id_or_name)):
|
|
244
|
-
# Try matching by name (case-insensitive)
|
|
245
150
|
for _, fmeta in fields_metadata.items():
|
|
246
151
|
if fmeta["name"].lower() == field_id_or_name.lower():
|
|
247
152
|
field = fmeta
|
|
248
153
|
break
|
|
249
|
-
|
|
250
154
|
if not field:
|
|
251
155
|
raise ValueError(
|
|
252
156
|
f"Field '{field_id_or_name}' not found in project '{project_key}' with issue type '{issue_type}'"
|
|
253
157
|
)
|
|
254
|
-
|
|
255
158
|
if not (allowed := field.get("allowedValues")):
|
|
256
159
|
self.logger.info(f"No allowed values found for field '{field_id_or_name}'")
|
|
257
160
|
return None
|
|
258
|
-
|
|
259
161
|
return allowed
|
|
260
162
|
|
|
261
163
|
@staticmethod
|
|
@@ -265,41 +167,13 @@ class JiraPlus:
|
|
|
265
167
|
allowed_values = allowed_names | allowed_keys
|
|
266
168
|
return describe_allowed_value(value, allowed_values=allowed_values)
|
|
267
169
|
|
|
268
|
-
def
|
|
269
|
-
return tuple(self.jira_client.server_info()["versionNumbers"])
|
|
270
|
-
|
|
271
|
-
def check_server_createmeta_compatibility(self) -> bool:
|
|
272
|
-
server_version = self.get_server_version()
|
|
273
|
-
if (
|
|
274
|
-
self.server_type == ServerType.ON_PREMISE and
|
|
275
|
-
Version(".".join(str(x) for x in server_version)) > Version("9.0.0")
|
|
276
|
-
):
|
|
277
|
-
self.logger.warning(
|
|
278
|
-
f"JIRA server version {server_version} is not fully compatible with createmeta-based field validation. "
|
|
279
|
-
"Some features may not work as expected."
|
|
280
|
-
)
|
|
281
|
-
return False
|
|
282
|
-
return True
|
|
283
|
-
|
|
284
|
-
def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
|
|
285
|
-
if self.check_server_createmeta_compatibility():
|
|
286
|
-
_, issue_meta = self._fetch_metadata(project_key=project_key, issue_type=issue_type)
|
|
287
|
-
return issue_meta["fields"]
|
|
288
|
-
_project_issue_types = self.jira_client.project_issue_types(project_key)
|
|
289
|
-
_issue_types = {t.name: t for t in _project_issue_types}
|
|
290
|
-
field_list = self.jira_client.project_issue_fields(project_key, _issue_types[issue_type].id)
|
|
291
|
-
return {f.fieldId: f.raw for f in field_list}
|
|
292
|
-
|
|
293
|
-
def validate_fields( # pylint: disable=R1260
|
|
294
|
-
self, project_key: str, issue_type: str, fields: dict | None = None
|
|
295
|
-
) -> None:
|
|
170
|
+
def validate_fields(self, project_key: str, issue_type: str, fields: dict) -> None: # pylint: disable=R1260
|
|
296
171
|
fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
|
|
297
172
|
for custom_field, value in fields.items():
|
|
298
173
|
if custom_field not in fields_metadata:
|
|
299
174
|
raise ValueError(
|
|
300
175
|
f'Invalid field(s) for project "{project_key}", issue type "{issue_type}": "{custom_field}"'
|
|
301
176
|
)
|
|
302
|
-
|
|
303
177
|
if allowed_value := fields_metadata[custom_field].get("allowedValues"):
|
|
304
178
|
if not self._is_value_allowed(value=value, allowed_values=allowed_value):
|
|
305
179
|
raise ValueError(
|
|
@@ -307,7 +181,6 @@ class JiraPlus:
|
|
|
307
181
|
f"with issue type '{issue_type}'."
|
|
308
182
|
f"The allowed values are: {json.dumps(allowed_value, indent=4, sort_keys=True)}"
|
|
309
183
|
)
|
|
310
|
-
|
|
311
184
|
field_type = fields_metadata[custom_field]["schema"]["type"]
|
|
312
185
|
if field_type == "string" and not isinstance(value, str):
|
|
313
186
|
raise ValueError(f"Field '{custom_field}' must be a string")
|
|
@@ -321,7 +194,6 @@ class JiraPlus:
|
|
|
321
194
|
raise ValueError(f"Field '{custom_field}' must be an object")
|
|
322
195
|
if field_type == "issuetype" and not isinstance(value, dict):
|
|
323
196
|
raise ValueError(f"Field '{custom_field}' must be an issue type")
|
|
324
|
-
# Add more field type checks as needed
|
|
325
197
|
|
|
326
198
|
def create_issue(
|
|
327
199
|
self,
|
|
@@ -332,21 +204,17 @@ class JiraPlus:
|
|
|
332
204
|
assignee: str | None = None,
|
|
333
205
|
custom_fields: dict | None = None,
|
|
334
206
|
) -> Issue | None:
|
|
335
|
-
issue_fields = {
|
|
207
|
+
issue_fields: dict[str, Any] = {
|
|
336
208
|
"project": {"key": project_key},
|
|
337
209
|
"issuetype": {"name": issue_type},
|
|
338
210
|
"summary": summary,
|
|
339
211
|
"description": description,
|
|
340
212
|
}
|
|
341
213
|
if assignee:
|
|
342
|
-
issue_fields["assignee"] = (
|
|
343
|
-
{"accountId": assignee} if self.server_type == ServerType.CLOUD else {"name": assignee}
|
|
344
|
-
)
|
|
345
|
-
|
|
214
|
+
issue_fields["assignee"] = self._format_assignee(assignee)
|
|
346
215
|
if custom_fields:
|
|
347
216
|
issue_fields = issue_fields | custom_fields
|
|
348
217
|
self.validate_fields(project_key=project_key, issue_type=issue_type, fields=issue_fields)
|
|
349
|
-
|
|
350
218
|
issue = self.jira_client.create_issue(fields=issue_fields)
|
|
351
219
|
self.logger.info(f"Issue {issue.key} created successfully.")
|
|
352
220
|
return issue
|
|
@@ -357,7 +225,6 @@ class JiraPlus:
|
|
|
357
225
|
self.validate_fields(
|
|
358
226
|
project_key=issue.fields.project.key, issue_type=issue.fields.issuetype.name, fields=fields_to_update
|
|
359
227
|
)
|
|
360
|
-
|
|
361
228
|
issue.update(fields=fields_to_update)
|
|
362
229
|
self.logger.info(f"Issue {issue.key} updated successfully.")
|
|
363
230
|
return issue
|
|
@@ -370,7 +237,7 @@ class JiraPlus:
|
|
|
370
237
|
|
|
371
238
|
def delete_issue(self, issue_key: str) -> bool:
|
|
372
239
|
try:
|
|
373
|
-
issue = self.get_issue_by_key(key=issue_key)
|
|
240
|
+
issue = self.get_issue_by_key(key=issue_key, json_result=False)
|
|
374
241
|
issue.delete()
|
|
375
242
|
self.logger.info(f"Issue {issue_key} deleted successfully.")
|
|
376
243
|
return True
|
|
@@ -396,7 +263,6 @@ class JiraPlus:
|
|
|
396
263
|
available_link_types = self._get_available_link_types()
|
|
397
264
|
if link_type not in available_link_types:
|
|
398
265
|
raise ValueError(f"Invalid link type '{link_type}'. Available link types: {available_link_types}")
|
|
399
|
-
|
|
400
266
|
try:
|
|
401
267
|
self.jira_client.create_issue_link(
|
|
402
268
|
type=link_type,
|
|
@@ -436,12 +302,10 @@ class JiraPlus:
|
|
|
436
302
|
f"\nAvailable transitions: {transition_names}"
|
|
437
303
|
)
|
|
438
304
|
return False
|
|
439
|
-
|
|
440
305
|
transition_id = next((t["id"] for t in transitions if t["name"].lower() == transition_name.lower()), None)
|
|
441
306
|
if not transition_id:
|
|
442
307
|
self.logger.error(f"Transition '{transition_name}' not found for issue {issue_key}")
|
|
443
308
|
return False
|
|
444
|
-
|
|
445
309
|
self.jira_client.transition_issue(
|
|
446
310
|
issue=issue_key, transition=transition_id, fields=fields or {}, comment=comment or None
|
|
447
311
|
)
|
|
@@ -453,18 +317,3 @@ class JiraPlus:
|
|
|
453
317
|
except Exception as err:
|
|
454
318
|
self.logger.exception(f"Unexpected error during transition of {issue_key}: {err}")
|
|
455
319
|
return False
|
|
456
|
-
|
|
457
|
-
def get_account_data(self, user_mail: str) -> list[dict]:
|
|
458
|
-
jira_rst_url = (
|
|
459
|
-
f"https://{self.jira_username}:{self.jira_token}@{self.base_url}/rest/api/{3}/user/search?query={user_mail}"
|
|
460
|
-
)
|
|
461
|
-
response = requests.get(jira_rst_url)
|
|
462
|
-
return response.json()
|
|
463
|
-
|
|
464
|
-
def get_account_id(self, user_mail: str) -> str:
|
|
465
|
-
data = self.get_account_data(user_mail=user_mail)
|
|
466
|
-
return data[0]["accountId"]
|
|
467
|
-
|
|
468
|
-
def get_account_display_name(self, user_display_name: str) -> str:
|
|
469
|
-
data = self.get_account_data(user_mail=user_display_name)
|
|
470
|
-
return data[0]["displayName"]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# jira_cloud.py
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from jira import JIRA, Issue, JIRAError
|
|
8
|
+
from jira.client import ResultList
|
|
9
|
+
from retrying import retry
|
|
10
|
+
|
|
11
|
+
from python_jira_plus.jira_base import JiraBase, UrlScheme
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JiraCloud(JiraBase):
|
|
15
|
+
use_createmeta = True
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
base_url: str | None = None,
|
|
20
|
+
urllib3_log_level: int = logging.WARNING,
|
|
21
|
+
jira_username: str | None = None,
|
|
22
|
+
jira_token: str | None = None,
|
|
23
|
+
verify_ssl: bool = True,
|
|
24
|
+
url_scheme: UrlScheme = UrlScheme.HTTPS,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(
|
|
27
|
+
base_url=base_url,
|
|
28
|
+
urllib3_log_level=urllib3_log_level,
|
|
29
|
+
jira_username=jira_username,
|
|
30
|
+
jira_token=jira_token,
|
|
31
|
+
verify_ssl=verify_ssl,
|
|
32
|
+
url_scheme=url_scheme,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@retry(stop_max_attempt_number=3, wait_fixed=180000)
|
|
36
|
+
def create_connection(self, timeout: int = 580) -> JIRA:
|
|
37
|
+
jira_client = JIRA(
|
|
38
|
+
basic_auth=(self.jira_username, self.jira_token),
|
|
39
|
+
options={"server": self.server, "verify": self.verify_ssl},
|
|
40
|
+
timeout=timeout,
|
|
41
|
+
)
|
|
42
|
+
if jira_client.projects():
|
|
43
|
+
self.logger.info("Jira Connection Successful")
|
|
44
|
+
return jira_client
|
|
45
|
+
self.logger.error("Jira Connection Failed")
|
|
46
|
+
raise JIRAError("Jira Connection Failed")
|
|
47
|
+
|
|
48
|
+
def _format_assignee(self, assignee: str) -> dict:
|
|
49
|
+
return {"accountId": assignee}
|
|
50
|
+
|
|
51
|
+
def _paginate(
|
|
52
|
+
self,
|
|
53
|
+
query: str,
|
|
54
|
+
max_results: int,
|
|
55
|
+
specific_fields: str | list[str],
|
|
56
|
+
jira_err_limit: int,
|
|
57
|
+
json_result: bool,
|
|
58
|
+
) -> ResultList[Issue] | list:
|
|
59
|
+
all_issues = []
|
|
60
|
+
retries = 0
|
|
61
|
+
page = None
|
|
62
|
+
while True: # pylint: disable=while-used
|
|
63
|
+
try:
|
|
64
|
+
next_token = None
|
|
65
|
+
if page is not None:
|
|
66
|
+
next_token = page.get("nextPageToken") if json_result else page.nextPageToken
|
|
67
|
+
|
|
68
|
+
self.logger.debug(f"Fetching issues from startAt={len(all_issues)}")
|
|
69
|
+
page = self.jira_client.enhanced_search_issues(
|
|
70
|
+
jql_str=query,
|
|
71
|
+
nextPageToken=next_token,
|
|
72
|
+
maxResults=max_results,
|
|
73
|
+
fields=specific_fields,
|
|
74
|
+
json_result=json_result,
|
|
75
|
+
)
|
|
76
|
+
if not page:
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
all_issues += page["issues"] if json_result else page
|
|
80
|
+
if page["isLast"] if json_result else page.isLast:
|
|
81
|
+
break
|
|
82
|
+
retries = 0
|
|
83
|
+
except JIRAError as err:
|
|
84
|
+
if err.status_code == 400:
|
|
85
|
+
self.logger.error(f"Bad Request: {err.text}")
|
|
86
|
+
raise JIRAError(f"Bad Request: {err.text}") from err
|
|
87
|
+
retries += 1
|
|
88
|
+
self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
|
|
89
|
+
if retries >= jira_err_limit:
|
|
90
|
+
self.logger.exception("Max retries exceeded.")
|
|
91
|
+
return []
|
|
92
|
+
time.sleep(300 if retries == jira_err_limit - 1 else 90)
|
|
93
|
+
return all_issues
|
|
94
|
+
|
|
95
|
+
def get_account_data(self, user_mail: str) -> list[dict]:
|
|
96
|
+
response = requests.get(
|
|
97
|
+
f"{self.server}/rest/api/3/user/search",
|
|
98
|
+
params={"query": user_mail},
|
|
99
|
+
auth=(self.jira_username, self.jira_token),
|
|
100
|
+
timeout=30,
|
|
101
|
+
)
|
|
102
|
+
response.raise_for_status()
|
|
103
|
+
return response.json()
|
|
104
|
+
|
|
105
|
+
def get_account_id(self, user_mail: str) -> str:
|
|
106
|
+
data = self.get_account_data(user_mail=user_mail)
|
|
107
|
+
return data[0]["accountId"]
|
|
108
|
+
|
|
109
|
+
def get_account_display_name(self, user_display_name: str) -> str:
|
|
110
|
+
data = self.get_account_data(user_mail=user_display_name)
|
|
111
|
+
return data[0]["displayName"]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# jira_on_premise.py
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from jira import JIRA, Issue, JIRAError
|
|
7
|
+
from jira.client import ResultList
|
|
8
|
+
from packaging.version import Version
|
|
9
|
+
from retrying import retry
|
|
10
|
+
|
|
11
|
+
from python_jira_plus.jira_base import JiraBase, UrlScheme
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JiraOnPremise(JiraBase):
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
base_url: str | None = None,
|
|
18
|
+
urllib3_log_level: int = logging.WARNING,
|
|
19
|
+
jira_username: str | None = None,
|
|
20
|
+
jira_token: str | None = None,
|
|
21
|
+
verify_ssl: bool = True,
|
|
22
|
+
sso: bool = False,
|
|
23
|
+
url_scheme: UrlScheme = UrlScheme.HTTPS,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.sso = sso # must be set before super().__init__ — create_connection reads it
|
|
26
|
+
super().__init__(
|
|
27
|
+
base_url=base_url,
|
|
28
|
+
urllib3_log_level=urllib3_log_level,
|
|
29
|
+
jira_username=jira_username,
|
|
30
|
+
jira_token=jira_token,
|
|
31
|
+
verify_ssl=verify_ssl,
|
|
32
|
+
url_scheme=url_scheme,
|
|
33
|
+
)
|
|
34
|
+
use_legacy = Version(".".join(str(x) for x in self.server_version)) < Version("9.0.0")
|
|
35
|
+
self.logger.debug(
|
|
36
|
+
f"Using {'legacy createmeta' if use_legacy else 'project_issue_types'} API "
|
|
37
|
+
f"(ON_PREMISE {self.server_version})"
|
|
38
|
+
)
|
|
39
|
+
self.use_createmeta = use_legacy
|
|
40
|
+
|
|
41
|
+
@retry(stop_max_attempt_number=3, wait_fixed=180000)
|
|
42
|
+
def create_connection(self, timeout: int = 580) -> JIRA:
|
|
43
|
+
auth = (
|
|
44
|
+
{"token_auth": self.jira_token} if not self.sso else {"basic_auth": (self.jira_username, self.jira_token)}
|
|
45
|
+
)
|
|
46
|
+
jira_client = JIRA(**auth, options={"server": self.server, "verify": self.verify_ssl}, timeout=timeout)
|
|
47
|
+
if jira_client.projects():
|
|
48
|
+
self.logger.info("Jira Connection Successful")
|
|
49
|
+
return jira_client
|
|
50
|
+
self.logger.error("Jira Connection Failed")
|
|
51
|
+
raise JIRAError("Jira Connection Failed")
|
|
52
|
+
|
|
53
|
+
def _format_assignee(self, assignee: str) -> dict:
|
|
54
|
+
return {"name": assignee}
|
|
55
|
+
|
|
56
|
+
def _paginate(
|
|
57
|
+
self,
|
|
58
|
+
query: str,
|
|
59
|
+
max_results: int,
|
|
60
|
+
specific_fields: str | list[str],
|
|
61
|
+
jira_err_limit: int,
|
|
62
|
+
json_result: bool,
|
|
63
|
+
) -> ResultList[Issue] | list:
|
|
64
|
+
start_at = 0
|
|
65
|
+
all_issues = []
|
|
66
|
+
retries = 0
|
|
67
|
+
while True: # pylint: disable=while-used
|
|
68
|
+
try:
|
|
69
|
+
self.logger.debug(f"Fetching issues from startAt={start_at}")
|
|
70
|
+
page = self.jira_client.search_issues(
|
|
71
|
+
jql_str=query,
|
|
72
|
+
startAt=start_at,
|
|
73
|
+
maxResults=max_results,
|
|
74
|
+
fields=specific_fields,
|
|
75
|
+
json_result=json_result,
|
|
76
|
+
)
|
|
77
|
+
if not page:
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
all_issues += page["issues"] if json_result else page
|
|
81
|
+
if json_result:
|
|
82
|
+
start_at += page["maxResults"]
|
|
83
|
+
else:
|
|
84
|
+
start_at += page.maxResults
|
|
85
|
+
if start_at >= max_results:
|
|
86
|
+
break
|
|
87
|
+
retries = 0
|
|
88
|
+
except JIRAError as err:
|
|
89
|
+
if err.status_code == 400:
|
|
90
|
+
self.logger.error(f"Bad Request: {err.text}")
|
|
91
|
+
raise JIRAError(f"Bad Request: {err.text}") from err
|
|
92
|
+
retries += 1
|
|
93
|
+
self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
|
|
94
|
+
if retries >= jira_err_limit:
|
|
95
|
+
self.logger.exception("Max retries exceeded.")
|
|
96
|
+
return []
|
|
97
|
+
time.sleep(300 if retries == jira_err_limit - 1 else 90)
|
|
98
|
+
return all_issues
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-jira-plus
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: Enhanced Python client for JIRA with better error handling, pagination, and metadata validation.
|
|
5
5
|
Author: Avi Zaguri
|
|
6
6
|
License: MIT
|
|
@@ -17,14 +17,16 @@ Requires-Python: >=3.12
|
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
License-File: LICENSE
|
|
19
19
|
Requires-Dist: custom-python-logger>=2.0.10
|
|
20
|
-
Requires-Dist: dotenv>=0.
|
|
20
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
21
21
|
Requires-Dist: jira>=3.10.5
|
|
22
|
-
Requires-Dist:
|
|
23
|
-
Requires-Dist: pytest>=9.0.1
|
|
22
|
+
Requires-Dist: packaging>=26.2
|
|
24
23
|
Requires-Dist: requests>=2.32.5
|
|
25
24
|
Requires-Dist: retrying>=1.4.2
|
|
26
|
-
|
|
27
|
-
Requires-Dist:
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pre-commit>=4.5.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest>=9.0.1; extra == "dev"
|
|
28
|
+
Requires-Dist: setuptools>=80.9.0; extra == "dev"
|
|
29
|
+
Requires-Dist: wheel>=0.45.1; extra == "dev"
|
|
28
30
|
Dynamic: license-file
|
|
29
31
|
|
|
30
32
|

|
|
@@ -59,7 +61,7 @@ pip install python-jira-plus
|
|
|
59
61
|
---
|
|
60
62
|
|
|
61
63
|
## Requirements
|
|
62
|
-
- Python 3.
|
|
64
|
+
- Python 3.12+
|
|
63
65
|
- `jira` package
|
|
64
66
|
- `retrying` package
|
|
65
67
|
- `custom-python-logger` package
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
python_jira_plus/__init__.py,sha256=HMlPp5HOfdEO3VYE7Zc2qfhLsIPpPJ9wQaUojhCOp0k,729
|
|
2
|
+
python_jira_plus/describe_allowed_value.py,sha256=fWfq0DkvJ2zmNIUXPH5rO5OgcY69bUqBIfTPQyxBt5E,1287
|
|
3
|
+
python_jira_plus/jira_base.py,sha256=OKRm5ArLdQRLoQQuIz7rgKv_jKb2S23ADM_RSKghxjw,13839
|
|
4
|
+
python_jira_plus/jira_cloud.py,sha256=pD5w2i3g7FpK-xk8dT9pHsNanAm257PlRt3O2glAFvA,3879
|
|
5
|
+
python_jira_plus/jira_on_premise.py,sha256=NWWOgKmX5M8h58m1j4Rr3MRWK1vRIH2Itgae_76ezAA,3633
|
|
6
|
+
python_jira_plus-3.0.0.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
|
|
7
|
+
python_jira_plus-3.0.0.dist-info/METADATA,sha256=A81DGwl6RL2VC70xOkibt1VtaIs1b6P1j3g47eu3OVo,4285
|
|
8
|
+
python_jira_plus-3.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
python_jira_plus-3.0.0.dist-info/top_level.txt,sha256=3DxBdmf_naggoqDHSvLstG_GX3bW2XXYYorBDybBUiA,17
|
|
10
|
+
python_jira_plus-3.0.0.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
python_jira_plus/__init__.py,sha256=hi4PadXJu9vYDqslH0zxAm-UsU_zfVFHCImqbchHbDU,592
|
|
2
|
-
python_jira_plus/describe_allowed_value.py,sha256=ZU3SN0d3Xy_4qPaVhkhqQYabt3Q_tN64J2yJ3mV0Abc,1287
|
|
3
|
-
python_jira_plus/jira_plus.py,sha256=JcVeWBOHXQdfRbt13C8PH3fMSLxhKtCC1LtNRlS9eSc,19368
|
|
4
|
-
python_jira_plus-2.0.7.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
|
|
5
|
-
python_jira_plus-2.0.7.dist-info/METADATA,sha256=jKNN8D42R_VO_D2WDSHV0I9e470npEOrDxHJVphr9yQ,4162
|
|
6
|
-
python_jira_plus-2.0.7.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
-
python_jira_plus-2.0.7.dist-info/top_level.txt,sha256=3DxBdmf_naggoqDHSvLstG_GX3bW2XXYYorBDybBUiA,17
|
|
8
|
-
python_jira_plus-2.0.7.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|