python-jira-plus 2.0.8__py3-none-any.whl → 3.1.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/const.py +1 -0
- python_jira_plus/{jira_plus.py → jira_base.py} +43 -198
- python_jira_plus/jira_cloud.py +111 -0
- python_jira_plus/jira_on_premise.py +98 -0
- {python_jira_plus-2.0.8.dist-info → python_jira_plus-3.1.0.dist-info}/METADATA +2 -2
- python_jira_plus-3.1.0.dist-info/RECORD +11 -0
- python_jira_plus-2.0.8.dist-info/RECORD +0 -8
- {python_jira_plus-2.0.8.dist-info → python_jira_plus-3.1.0.dist-info}/WHEEL +0 -0
- {python_jira_plus-2.0.8.dist-info → python_jira_plus-3.1.0.dist-info}/licenses/LICENSE +0 -0
- {python_jira_plus-2.0.8.dist-info → python_jira_plus-3.1.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"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
LOGGER_NAME = "pytest_depends_on"
|
|
@@ -1,18 +1,17 @@
|
|
|
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
|
|
13
|
+
|
|
14
|
+
from python_jira_plus.const import LOGGER_NAME
|
|
16
15
|
from python_jira_plus.describe_allowed_value import describe_allowed_value
|
|
17
16
|
|
|
18
17
|
|
|
@@ -21,59 +20,47 @@ class UrlScheme(Enum):
|
|
|
21
20
|
HTTPS = "https"
|
|
22
21
|
|
|
23
22
|
|
|
24
|
-
class
|
|
25
|
-
|
|
26
|
-
ON_PREMISE = "on_premise"
|
|
27
|
-
|
|
23
|
+
class JiraBase(ABC):
|
|
24
|
+
use_createmeta: bool
|
|
28
25
|
|
|
29
|
-
class JiraPlus:
|
|
30
26
|
def __init__(
|
|
31
27
|
self,
|
|
32
28
|
base_url: str | None = None,
|
|
33
|
-
server_type: ServerType = ServerType.CLOUD,
|
|
34
29
|
urllib3_log_level: int = logging.WARNING,
|
|
35
30
|
jira_username: str | None = None,
|
|
36
|
-
jira_token: str | None = None,
|
|
31
|
+
jira_token: str | None = None,
|
|
37
32
|
verify_ssl: bool = True,
|
|
38
|
-
sso: bool = False,
|
|
39
33
|
url_scheme: UrlScheme = UrlScheme.HTTPS,
|
|
40
34
|
) -> None:
|
|
41
|
-
self.logger = get_logger(
|
|
35
|
+
self.logger = get_logger(LOGGER_NAME)
|
|
42
36
|
get_logger("urllib3").setLevel(urllib3_log_level)
|
|
43
37
|
|
|
44
38
|
self.jira_username = jira_username or os.getenv("JIRA_USER_NAME")
|
|
45
39
|
self.jira_token = jira_token or os.getenv("JIRA_TOKEN")
|
|
46
40
|
self.base_url = base_url or os.getenv("JIRA_BASE_URL")
|
|
47
41
|
self.url_scheme = url_scheme.value
|
|
48
|
-
|
|
49
|
-
self.server_type = server_type
|
|
50
42
|
self.server = f"{self.url_scheme}://{self.base_url}"
|
|
51
43
|
self.verify_ssl = verify_ssl
|
|
52
|
-
self.sso = sso
|
|
53
44
|
|
|
54
45
|
self.jira_client = self.create_connection()
|
|
55
46
|
self.check_client_connection()
|
|
56
47
|
self.server_version = self.get_server_version()
|
|
57
|
-
self.use_legacy_api = self._resolve_use_legacy_api()
|
|
58
48
|
|
|
59
|
-
@
|
|
60
|
-
def create_connection(self, timeout: int = 580) -> JIRA:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def create_connection(self, timeout: int = 580) -> JIRA: ...
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def _paginate(
|
|
54
|
+
self,
|
|
55
|
+
query: str,
|
|
56
|
+
max_results: int,
|
|
57
|
+
specific_fields: str | list[str],
|
|
58
|
+
jira_err_limit: int,
|
|
59
|
+
json_result: bool,
|
|
60
|
+
) -> ResultList[Issue] | list: ...
|
|
71
61
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return jira_client
|
|
75
|
-
self.logger.error("Jira Connection Failed")
|
|
76
|
-
raise JIRAError("Jira Connection Failed")
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def _format_assignee(self, assignee: str) -> dict: ...
|
|
77
64
|
|
|
78
65
|
def close_connection(self) -> None:
|
|
79
66
|
try:
|
|
@@ -82,7 +69,6 @@ class JiraPlus:
|
|
|
82
69
|
self.logger.exception(f"JIRAError \n{err}")
|
|
83
70
|
except Exception as err:
|
|
84
71
|
self.logger.exception(f"Exception \n{err}")
|
|
85
|
-
|
|
86
72
|
self.check_client_connection()
|
|
87
73
|
|
|
88
74
|
def check_client_connection(self) -> bool:
|
|
@@ -98,6 +84,9 @@ class JiraPlus:
|
|
|
98
84
|
self.logger.exception(f"Exception \n{err}")
|
|
99
85
|
return False
|
|
100
86
|
|
|
87
|
+
def get_server_version(self) -> tuple[int, ...]:
|
|
88
|
+
return tuple(self.jira_client.server_info()["versionNumbers"])
|
|
89
|
+
|
|
101
90
|
def get_issue_by_key(
|
|
102
91
|
self, key: str, specific_fields: str | list[str] = "*all", json_result: bool = True
|
|
103
92
|
) -> None | Issue | dict[str, Any]:
|
|
@@ -120,12 +109,8 @@ class JiraPlus:
|
|
|
120
109
|
specific_fields: str | list[str] = "*all",
|
|
121
110
|
jira_err_count: int = 3,
|
|
122
111
|
json_result: bool = True,
|
|
123
|
-
) -> ResultList[Issue] |
|
|
124
|
-
|
|
125
|
-
_paginate_query_func = self._paginate_query
|
|
126
|
-
else:
|
|
127
|
-
_paginate_query_func = self._paginate_query_new
|
|
128
|
-
return _paginate_query_func(
|
|
112
|
+
) -> ResultList[Issue] | list:
|
|
113
|
+
return self._paginate(
|
|
129
114
|
query=query,
|
|
130
115
|
max_results=max_results,
|
|
131
116
|
specific_fields=specific_fields,
|
|
@@ -133,107 +118,26 @@ class JiraPlus:
|
|
|
133
118
|
json_result=json_result,
|
|
134
119
|
)
|
|
135
120
|
|
|
136
|
-
def
|
|
137
|
-
self,
|
|
138
|
-
query: str,
|
|
139
|
-
max_results: int,
|
|
140
|
-
specific_fields: str | list[str],
|
|
141
|
-
jira_err_limit: int,
|
|
142
|
-
json_result: bool,
|
|
143
|
-
) -> ResultList[Issue] | list:
|
|
144
|
-
all_issues = []
|
|
145
|
-
retries = 0
|
|
146
|
-
page = None
|
|
147
|
-
while True:
|
|
148
|
-
try:
|
|
149
|
-
next_token = None
|
|
150
|
-
if page is not None:
|
|
151
|
-
next_token = page.get('nextPageToken') if json_result else page.nextPageToken
|
|
152
|
-
|
|
153
|
-
self.logger.debug(f"Fetching issues from startAt={len(all_issues)}")
|
|
154
|
-
page = self.jira_client.enhanced_search_issues(
|
|
155
|
-
jql_str=query,
|
|
156
|
-
nextPageToken=next_token,
|
|
157
|
-
maxResults=max_results,
|
|
158
|
-
fields=specific_fields,
|
|
159
|
-
json_result=json_result,
|
|
160
|
-
)
|
|
161
|
-
if not page:
|
|
162
|
-
break
|
|
163
|
-
|
|
164
|
-
all_issues += page["issues"] if json_result else page
|
|
165
|
-
is_last = page['isLast'] if json_result else page.isLast
|
|
166
|
-
if is_last:
|
|
167
|
-
break
|
|
168
|
-
retries = 0 # reset on success
|
|
169
|
-
except JIRAError as err:
|
|
170
|
-
if err.status_code == 400:
|
|
171
|
-
self.logger.error(f"Bad Request: {err.text}")
|
|
172
|
-
raise JIRAError(f"Bad Request: {err.text}") from err
|
|
173
|
-
retries += 1
|
|
174
|
-
self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
|
|
175
|
-
if retries >= jira_err_limit:
|
|
176
|
-
self.logger.exception("Max retries exceeded.")
|
|
177
|
-
return []
|
|
178
|
-
time.sleep(300 if retries == jira_err_limit - 1 else 90)
|
|
179
|
-
return all_issues
|
|
180
|
-
|
|
181
|
-
def _paginate_query(
|
|
182
|
-
self,
|
|
183
|
-
query: str,
|
|
184
|
-
max_results: int,
|
|
185
|
-
specific_fields: str | list[str],
|
|
186
|
-
jira_err_limit: int,
|
|
187
|
-
json_result: bool,
|
|
188
|
-
) -> ResultList[Issue] | list:
|
|
189
|
-
start_at = 0
|
|
190
|
-
all_issues = []
|
|
191
|
-
retries = 0
|
|
192
|
-
while True: # pylint: disable=W0149
|
|
193
|
-
try:
|
|
194
|
-
self.logger.debug(f"Fetching issues from startAt={start_at}")
|
|
195
|
-
page = self.jira_client.search_issues(
|
|
196
|
-
jql_str=query,
|
|
197
|
-
startAt=start_at,
|
|
198
|
-
maxResults=max_results,
|
|
199
|
-
fields=specific_fields,
|
|
200
|
-
json_result=json_result,
|
|
201
|
-
)
|
|
202
|
-
if not page:
|
|
203
|
-
break
|
|
204
|
-
|
|
205
|
-
all_issues += page["issues"] if json_result else page
|
|
206
|
-
if json_result:
|
|
207
|
-
start_at += page["maxResults"]
|
|
208
|
-
else:
|
|
209
|
-
start_at += page.maxResults
|
|
210
|
-
if start_at >= max_results:
|
|
211
|
-
break
|
|
212
|
-
retries = 0 # reset on success
|
|
213
|
-
except JIRAError as err:
|
|
214
|
-
if err.status_code == 400:
|
|
215
|
-
self.logger.error(f"Bad Request: {err.text}")
|
|
216
|
-
raise JIRAError(f"Bad Request: {err.text}") from err
|
|
217
|
-
retries += 1
|
|
218
|
-
self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
|
|
219
|
-
if retries >= jira_err_limit:
|
|
220
|
-
self.logger.exception("Max retries exceeded.")
|
|
221
|
-
return []
|
|
222
|
-
time.sleep(300 if retries == jira_err_limit - 1 else 90)
|
|
223
|
-
return all_issues
|
|
224
|
-
|
|
225
|
-
def _fetch_metadata(self, project_key: str, issue_type: str) -> tuple[dict, dict]:
|
|
121
|
+
def _fetch_via_createmeta(self, project_key: str, issue_type: str) -> dict:
|
|
226
122
|
meta = self.jira_client.createmeta(
|
|
227
123
|
projectKeys=project_key, issuetypeNames=issue_type, expand="projects.issuetypes.fields"
|
|
228
124
|
)
|
|
229
|
-
|
|
230
125
|
if not (project_meta := next((p for p in meta["projects"] if p["key"] == project_key), None)):
|
|
231
126
|
raise ValueError(f"Project {project_key} not found in metadata")
|
|
232
|
-
|
|
233
127
|
issue_meta = next((it for it in project_meta["issuetypes"] if it["name"].lower() == issue_type.lower()), None)
|
|
234
128
|
if not issue_meta:
|
|
235
129
|
raise ValueError(f"Issue type '{issue_type}' not found in project '{project_key}'")
|
|
236
|
-
return
|
|
130
|
+
return issue_meta["fields"]
|
|
131
|
+
|
|
132
|
+
def _fetch_via_issue_types_api(self, project_key: str, issue_type: str) -> dict:
|
|
133
|
+
issue_types = {t.name: t for t in self.jira_client.project_issue_types(project_key)}
|
|
134
|
+
field_list = self.jira_client.project_issue_fields(project_key, issue_types[issue_type].id)
|
|
135
|
+
return {f.fieldId: f.raw for f in field_list}
|
|
136
|
+
|
|
137
|
+
def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
|
|
138
|
+
if self.use_createmeta:
|
|
139
|
+
return self._fetch_via_createmeta(project_key=project_key, issue_type=issue_type)
|
|
140
|
+
return self._fetch_via_issue_types_api(project_key=project_key, issue_type=issue_type)
|
|
237
141
|
|
|
238
142
|
def get_allowed_values(
|
|
239
143
|
self,
|
|
@@ -241,28 +145,20 @@ class JiraPlus:
|
|
|
241
145
|
issue_type: str,
|
|
242
146
|
field_id_or_name: str,
|
|
243
147
|
) -> list | None:
|
|
244
|
-
"""
|
|
245
|
-
Returns the allowed values for a specific field in a project + issue type context.
|
|
246
|
-
"""
|
|
148
|
+
"""Returns the allowed values for a specific field in a project + issue type context."""
|
|
247
149
|
fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
|
|
248
|
-
|
|
249
|
-
# Try to find by ID or fallback to case-insensitive name
|
|
250
150
|
if not (field := fields_metadata.get(field_id_or_name)):
|
|
251
|
-
# Try matching by name (case-insensitive)
|
|
252
151
|
for _, fmeta in fields_metadata.items():
|
|
253
152
|
if fmeta["name"].lower() == field_id_or_name.lower():
|
|
254
153
|
field = fmeta
|
|
255
154
|
break
|
|
256
|
-
|
|
257
155
|
if not field:
|
|
258
156
|
raise ValueError(
|
|
259
157
|
f"Field '{field_id_or_name}' not found in project '{project_key}' with issue type '{issue_type}'"
|
|
260
158
|
)
|
|
261
|
-
|
|
262
159
|
if not (allowed := field.get("allowedValues")):
|
|
263
160
|
self.logger.info(f"No allowed values found for field '{field_id_or_name}'")
|
|
264
161
|
return None
|
|
265
|
-
|
|
266
162
|
return allowed
|
|
267
163
|
|
|
268
164
|
@staticmethod
|
|
@@ -272,36 +168,13 @@ class JiraPlus:
|
|
|
272
168
|
allowed_values = allowed_names | allowed_keys
|
|
273
169
|
return describe_allowed_value(value, allowed_values=allowed_values)
|
|
274
170
|
|
|
275
|
-
def
|
|
276
|
-
return tuple(self.jira_client.server_info()["versionNumbers"])
|
|
277
|
-
|
|
278
|
-
def _resolve_use_legacy_api(self) -> bool:
|
|
279
|
-
if self.server_type == ServerType.ON_PREMISE:
|
|
280
|
-
use_legacy = Version(".".join(str(x) for x in self.server_version)) < Version("9.0.0")
|
|
281
|
-
self.logger.debug(f"Using {'legacy createmeta' if use_legacy else 'project_issue_types'} API (ON_PREMISE {self.server_version})")
|
|
282
|
-
return use_legacy
|
|
283
|
-
self.logger.debug("Using legacy createmeta API (Cloud)")
|
|
284
|
-
return True
|
|
285
|
-
|
|
286
|
-
def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
|
|
287
|
-
if self.use_legacy_api:
|
|
288
|
-
_, issue_meta = self._fetch_metadata(project_key=project_key, issue_type=issue_type)
|
|
289
|
-
return issue_meta["fields"]
|
|
290
|
-
_project_issue_types = self.jira_client.project_issue_types(project_key)
|
|
291
|
-
_issue_types = {t.name: t for t in _project_issue_types}
|
|
292
|
-
field_list = self.jira_client.project_issue_fields(project_key, _issue_types[issue_type].id)
|
|
293
|
-
return {f.fieldId: f.raw for f in field_list}
|
|
294
|
-
|
|
295
|
-
def validate_fields( # pylint: disable=R1260
|
|
296
|
-
self, project_key: str, issue_type: str, fields: dict
|
|
297
|
-
) -> None:
|
|
171
|
+
def validate_fields(self, project_key: str, issue_type: str, fields: dict) -> None: # pylint: disable=R1260
|
|
298
172
|
fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
|
|
299
173
|
for custom_field, value in fields.items():
|
|
300
174
|
if custom_field not in fields_metadata:
|
|
301
175
|
raise ValueError(
|
|
302
176
|
f'Invalid field(s) for project "{project_key}", issue type "{issue_type}": "{custom_field}"'
|
|
303
177
|
)
|
|
304
|
-
|
|
305
178
|
if allowed_value := fields_metadata[custom_field].get("allowedValues"):
|
|
306
179
|
if not self._is_value_allowed(value=value, allowed_values=allowed_value):
|
|
307
180
|
raise ValueError(
|
|
@@ -309,7 +182,6 @@ class JiraPlus:
|
|
|
309
182
|
f"with issue type '{issue_type}'."
|
|
310
183
|
f"The allowed values are: {json.dumps(allowed_value, indent=4, sort_keys=True)}"
|
|
311
184
|
)
|
|
312
|
-
|
|
313
185
|
field_type = fields_metadata[custom_field]["schema"]["type"]
|
|
314
186
|
if field_type == "string" and not isinstance(value, str):
|
|
315
187
|
raise ValueError(f"Field '{custom_field}' must be a string")
|
|
@@ -323,7 +195,6 @@ class JiraPlus:
|
|
|
323
195
|
raise ValueError(f"Field '{custom_field}' must be an object")
|
|
324
196
|
if field_type == "issuetype" and not isinstance(value, dict):
|
|
325
197
|
raise ValueError(f"Field '{custom_field}' must be an issue type")
|
|
326
|
-
# Add more field type checks as needed
|
|
327
198
|
|
|
328
199
|
def create_issue(
|
|
329
200
|
self,
|
|
@@ -334,21 +205,17 @@ class JiraPlus:
|
|
|
334
205
|
assignee: str | None = None,
|
|
335
206
|
custom_fields: dict | None = None,
|
|
336
207
|
) -> Issue | None:
|
|
337
|
-
issue_fields = {
|
|
208
|
+
issue_fields: dict[str, Any] = {
|
|
338
209
|
"project": {"key": project_key},
|
|
339
210
|
"issuetype": {"name": issue_type},
|
|
340
211
|
"summary": summary,
|
|
341
212
|
"description": description,
|
|
342
213
|
}
|
|
343
214
|
if assignee:
|
|
344
|
-
issue_fields["assignee"] = (
|
|
345
|
-
{"accountId": assignee} if self.server_type == ServerType.CLOUD else {"name": assignee}
|
|
346
|
-
)
|
|
347
|
-
|
|
215
|
+
issue_fields["assignee"] = self._format_assignee(assignee)
|
|
348
216
|
if custom_fields:
|
|
349
217
|
issue_fields = issue_fields | custom_fields
|
|
350
218
|
self.validate_fields(project_key=project_key, issue_type=issue_type, fields=issue_fields)
|
|
351
|
-
|
|
352
219
|
issue = self.jira_client.create_issue(fields=issue_fields)
|
|
353
220
|
self.logger.info(f"Issue {issue.key} created successfully.")
|
|
354
221
|
return issue
|
|
@@ -359,7 +226,6 @@ class JiraPlus:
|
|
|
359
226
|
self.validate_fields(
|
|
360
227
|
project_key=issue.fields.project.key, issue_type=issue.fields.issuetype.name, fields=fields_to_update
|
|
361
228
|
)
|
|
362
|
-
|
|
363
229
|
issue.update(fields=fields_to_update)
|
|
364
230
|
self.logger.info(f"Issue {issue.key} updated successfully.")
|
|
365
231
|
return issue
|
|
@@ -398,7 +264,6 @@ class JiraPlus:
|
|
|
398
264
|
available_link_types = self._get_available_link_types()
|
|
399
265
|
if link_type not in available_link_types:
|
|
400
266
|
raise ValueError(f"Invalid link type '{link_type}'. Available link types: {available_link_types}")
|
|
401
|
-
|
|
402
267
|
try:
|
|
403
268
|
self.jira_client.create_issue_link(
|
|
404
269
|
type=link_type,
|
|
@@ -438,12 +303,10 @@ class JiraPlus:
|
|
|
438
303
|
f"\nAvailable transitions: {transition_names}"
|
|
439
304
|
)
|
|
440
305
|
return False
|
|
441
|
-
|
|
442
306
|
transition_id = next((t["id"] for t in transitions if t["name"].lower() == transition_name.lower()), None)
|
|
443
307
|
if not transition_id:
|
|
444
308
|
self.logger.error(f"Transition '{transition_name}' not found for issue {issue_key}")
|
|
445
309
|
return False
|
|
446
|
-
|
|
447
310
|
self.jira_client.transition_issue(
|
|
448
311
|
issue=issue_key, transition=transition_id, fields=fields or {}, comment=comment or None
|
|
449
312
|
)
|
|
@@ -455,21 +318,3 @@ class JiraPlus:
|
|
|
455
318
|
except Exception as err:
|
|
456
319
|
self.logger.exception(f"Unexpected error during transition of {issue_key}: {err}")
|
|
457
320
|
return False
|
|
458
|
-
|
|
459
|
-
def get_account_data(self, user_mail: str) -> list[dict]:
|
|
460
|
-
response = requests.get(
|
|
461
|
-
f"{self.server}/rest/api/3/user/search",
|
|
462
|
-
params={"query": user_mail},
|
|
463
|
-
auth=(self.jira_username, self.jira_token),
|
|
464
|
-
timeout=30,
|
|
465
|
-
)
|
|
466
|
-
response.raise_for_status()
|
|
467
|
-
return response.json()
|
|
468
|
-
|
|
469
|
-
def get_account_id(self, user_mail: str) -> str:
|
|
470
|
-
data = self.get_account_data(user_mail=user_mail)
|
|
471
|
-
return data[0]["accountId"]
|
|
472
|
-
|
|
473
|
-
def get_account_display_name(self, user_display_name: str) -> str:
|
|
474
|
-
data = self.get_account_data(user_mail=user_display_name)
|
|
475
|
-
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.1.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
|
|
@@ -16,7 +16,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
16
16
|
Requires-Python: >=3.12
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
License-File: LICENSE
|
|
19
|
-
Requires-Dist: custom-python-logger>=
|
|
19
|
+
Requires-Dist: custom-python-logger>=4.0.0
|
|
20
20
|
Requires-Dist: python-dotenv>=1.0.0
|
|
21
21
|
Requires-Dist: jira>=3.10.5
|
|
22
22
|
Requires-Dist: packaging>=26.2
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
python_jira_plus/__init__.py,sha256=HMlPp5HOfdEO3VYE7Zc2qfhLsIPpPJ9wQaUojhCOp0k,729
|
|
2
|
+
python_jira_plus/const.py,sha256=lS3kH5jzxhNlJf_jfvH3bc98jQGmpnHoDkJGdj7jLzI,34
|
|
3
|
+
python_jira_plus/describe_allowed_value.py,sha256=fWfq0DkvJ2zmNIUXPH5rO5OgcY69bUqBIfTPQyxBt5E,1287
|
|
4
|
+
python_jira_plus/jira_base.py,sha256=pWLm19YIp8ususNSqIAl8rkvFvhYyg0Cn-W0aZZ64cQ,13874
|
|
5
|
+
python_jira_plus/jira_cloud.py,sha256=pD5w2i3g7FpK-xk8dT9pHsNanAm257PlRt3O2glAFvA,3879
|
|
6
|
+
python_jira_plus/jira_on_premise.py,sha256=NWWOgKmX5M8h58m1j4Rr3MRWK1vRIH2Itgae_76ezAA,3633
|
|
7
|
+
python_jira_plus-3.1.0.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
|
|
8
|
+
python_jira_plus-3.1.0.dist-info/METADATA,sha256=aOiGFJdWuzpzw7URve9c2AVJ_vNVTvAgferlIpdQqr0,4284
|
|
9
|
+
python_jira_plus-3.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
python_jira_plus-3.1.0.dist-info/top_level.txt,sha256=3DxBdmf_naggoqDHSvLstG_GX3bW2XXYYorBDybBUiA,17
|
|
11
|
+
python_jira_plus-3.1.0.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
python_jira_plus/__init__.py,sha256=BUKjGxQfkxWyZs-mKZs2bAgGnsYHQ4uOFjuB8MPmhfg,616
|
|
2
|
-
python_jira_plus/describe_allowed_value.py,sha256=fWfq0DkvJ2zmNIUXPH5rO5OgcY69bUqBIfTPQyxBt5E,1287
|
|
3
|
-
python_jira_plus/jira_plus.py,sha256=TNJkwFdDbkaVrsEaO3kBRGHi_GDFVhRcpoX6bAgyRTs,19568
|
|
4
|
-
python_jira_plus-2.0.8.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
|
|
5
|
-
python_jira_plus-2.0.8.dist-info/METADATA,sha256=AdZUwcmuzp3qtoFVuaB1En8QQqjKxFz6_3U99X6G-o8,4285
|
|
6
|
-
python_jira_plus-2.0.8.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
-
python_jira_plus-2.0.8.dist-info/top_level.txt,sha256=3DxBdmf_naggoqDHSvLstG_GX3bW2XXYYorBDybBUiA,17
|
|
8
|
-
python_jira_plus-2.0.8.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|