python-jira-plus 2.0.8__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/{jira_plus.py → jira_base.py} +41 -197
- 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.0.0.dist-info}/METADATA +1 -1
- python_jira_plus-3.0.0.dist-info/RECORD +10 -0
- python_jira_plus-2.0.8.dist-info/RECORD +0 -8
- {python_jira_plus-2.0.8.dist-info → python_jira_plus-3.0.0.dist-info}/WHEEL +0 -0
- {python_jira_plus-2.0.8.dist-info → python_jira_plus-3.0.0.dist-info}/licenses/LICENSE +0 -0
- {python_jira_plus-2.0.8.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"]
|
|
@@ -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,35 +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()
|
|
56
46
|
self.server_version = self.get_server_version()
|
|
57
|
-
self.use_legacy_api = self._resolve_use_legacy_api()
|
|
58
47
|
|
|
59
|
-
@
|
|
60
|
-
def create_connection(self, timeout: int = 580) -> JIRA:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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: ...
|
|
71
60
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return jira_client
|
|
75
|
-
self.logger.error("Jira Connection Failed")
|
|
76
|
-
raise JIRAError("Jira Connection Failed")
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def _format_assignee(self, assignee: str) -> dict: ...
|
|
77
63
|
|
|
78
64
|
def close_connection(self) -> None:
|
|
79
65
|
try:
|
|
@@ -82,7 +68,6 @@ class JiraPlus:
|
|
|
82
68
|
self.logger.exception(f"JIRAError \n{err}")
|
|
83
69
|
except Exception as err:
|
|
84
70
|
self.logger.exception(f"Exception \n{err}")
|
|
85
|
-
|
|
86
71
|
self.check_client_connection()
|
|
87
72
|
|
|
88
73
|
def check_client_connection(self) -> bool:
|
|
@@ -98,6 +83,9 @@ class JiraPlus:
|
|
|
98
83
|
self.logger.exception(f"Exception \n{err}")
|
|
99
84
|
return False
|
|
100
85
|
|
|
86
|
+
def get_server_version(self) -> tuple[int, ...]:
|
|
87
|
+
return tuple(self.jira_client.server_info()["versionNumbers"])
|
|
88
|
+
|
|
101
89
|
def get_issue_by_key(
|
|
102
90
|
self, key: str, specific_fields: str | list[str] = "*all", json_result: bool = True
|
|
103
91
|
) -> None | Issue | dict[str, Any]:
|
|
@@ -120,12 +108,8 @@ class JiraPlus:
|
|
|
120
108
|
specific_fields: str | list[str] = "*all",
|
|
121
109
|
jira_err_count: int = 3,
|
|
122
110
|
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(
|
|
111
|
+
) -> ResultList[Issue] | list:
|
|
112
|
+
return self._paginate(
|
|
129
113
|
query=query,
|
|
130
114
|
max_results=max_results,
|
|
131
115
|
specific_fields=specific_fields,
|
|
@@ -133,107 +117,26 @@ class JiraPlus:
|
|
|
133
117
|
json_result=json_result,
|
|
134
118
|
)
|
|
135
119
|
|
|
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]:
|
|
120
|
+
def _fetch_via_createmeta(self, project_key: str, issue_type: str) -> dict:
|
|
226
121
|
meta = self.jira_client.createmeta(
|
|
227
122
|
projectKeys=project_key, issuetypeNames=issue_type, expand="projects.issuetypes.fields"
|
|
228
123
|
)
|
|
229
|
-
|
|
230
124
|
if not (project_meta := next((p for p in meta["projects"] if p["key"] == project_key), None)):
|
|
231
125
|
raise ValueError(f"Project {project_key} not found in metadata")
|
|
232
|
-
|
|
233
126
|
issue_meta = next((it for it in project_meta["issuetypes"] if it["name"].lower() == issue_type.lower()), None)
|
|
234
127
|
if not issue_meta:
|
|
235
128
|
raise ValueError(f"Issue type '{issue_type}' not found in project '{project_key}'")
|
|
236
|
-
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)
|
|
237
140
|
|
|
238
141
|
def get_allowed_values(
|
|
239
142
|
self,
|
|
@@ -241,28 +144,20 @@ class JiraPlus:
|
|
|
241
144
|
issue_type: str,
|
|
242
145
|
field_id_or_name: str,
|
|
243
146
|
) -> list | None:
|
|
244
|
-
"""
|
|
245
|
-
Returns the allowed values for a specific field in a project + issue type context.
|
|
246
|
-
"""
|
|
147
|
+
"""Returns the allowed values for a specific field in a project + issue type context."""
|
|
247
148
|
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
149
|
if not (field := fields_metadata.get(field_id_or_name)):
|
|
251
|
-
# Try matching by name (case-insensitive)
|
|
252
150
|
for _, fmeta in fields_metadata.items():
|
|
253
151
|
if fmeta["name"].lower() == field_id_or_name.lower():
|
|
254
152
|
field = fmeta
|
|
255
153
|
break
|
|
256
|
-
|
|
257
154
|
if not field:
|
|
258
155
|
raise ValueError(
|
|
259
156
|
f"Field '{field_id_or_name}' not found in project '{project_key}' with issue type '{issue_type}'"
|
|
260
157
|
)
|
|
261
|
-
|
|
262
158
|
if not (allowed := field.get("allowedValues")):
|
|
263
159
|
self.logger.info(f"No allowed values found for field '{field_id_or_name}'")
|
|
264
160
|
return None
|
|
265
|
-
|
|
266
161
|
return allowed
|
|
267
162
|
|
|
268
163
|
@staticmethod
|
|
@@ -272,36 +167,13 @@ class JiraPlus:
|
|
|
272
167
|
allowed_values = allowed_names | allowed_keys
|
|
273
168
|
return describe_allowed_value(value, allowed_values=allowed_values)
|
|
274
169
|
|
|
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:
|
|
170
|
+
def validate_fields(self, project_key: str, issue_type: str, fields: dict) -> None: # pylint: disable=R1260
|
|
298
171
|
fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
|
|
299
172
|
for custom_field, value in fields.items():
|
|
300
173
|
if custom_field not in fields_metadata:
|
|
301
174
|
raise ValueError(
|
|
302
175
|
f'Invalid field(s) for project "{project_key}", issue type "{issue_type}": "{custom_field}"'
|
|
303
176
|
)
|
|
304
|
-
|
|
305
177
|
if allowed_value := fields_metadata[custom_field].get("allowedValues"):
|
|
306
178
|
if not self._is_value_allowed(value=value, allowed_values=allowed_value):
|
|
307
179
|
raise ValueError(
|
|
@@ -309,7 +181,6 @@ class JiraPlus:
|
|
|
309
181
|
f"with issue type '{issue_type}'."
|
|
310
182
|
f"The allowed values are: {json.dumps(allowed_value, indent=4, sort_keys=True)}"
|
|
311
183
|
)
|
|
312
|
-
|
|
313
184
|
field_type = fields_metadata[custom_field]["schema"]["type"]
|
|
314
185
|
if field_type == "string" and not isinstance(value, str):
|
|
315
186
|
raise ValueError(f"Field '{custom_field}' must be a string")
|
|
@@ -323,7 +194,6 @@ class JiraPlus:
|
|
|
323
194
|
raise ValueError(f"Field '{custom_field}' must be an object")
|
|
324
195
|
if field_type == "issuetype" and not isinstance(value, dict):
|
|
325
196
|
raise ValueError(f"Field '{custom_field}' must be an issue type")
|
|
326
|
-
# Add more field type checks as needed
|
|
327
197
|
|
|
328
198
|
def create_issue(
|
|
329
199
|
self,
|
|
@@ -334,21 +204,17 @@ class JiraPlus:
|
|
|
334
204
|
assignee: str | None = None,
|
|
335
205
|
custom_fields: dict | None = None,
|
|
336
206
|
) -> Issue | None:
|
|
337
|
-
issue_fields = {
|
|
207
|
+
issue_fields: dict[str, Any] = {
|
|
338
208
|
"project": {"key": project_key},
|
|
339
209
|
"issuetype": {"name": issue_type},
|
|
340
210
|
"summary": summary,
|
|
341
211
|
"description": description,
|
|
342
212
|
}
|
|
343
213
|
if assignee:
|
|
344
|
-
issue_fields["assignee"] = (
|
|
345
|
-
{"accountId": assignee} if self.server_type == ServerType.CLOUD else {"name": assignee}
|
|
346
|
-
)
|
|
347
|
-
|
|
214
|
+
issue_fields["assignee"] = self._format_assignee(assignee)
|
|
348
215
|
if custom_fields:
|
|
349
216
|
issue_fields = issue_fields | custom_fields
|
|
350
217
|
self.validate_fields(project_key=project_key, issue_type=issue_type, fields=issue_fields)
|
|
351
|
-
|
|
352
218
|
issue = self.jira_client.create_issue(fields=issue_fields)
|
|
353
219
|
self.logger.info(f"Issue {issue.key} created successfully.")
|
|
354
220
|
return issue
|
|
@@ -359,7 +225,6 @@ class JiraPlus:
|
|
|
359
225
|
self.validate_fields(
|
|
360
226
|
project_key=issue.fields.project.key, issue_type=issue.fields.issuetype.name, fields=fields_to_update
|
|
361
227
|
)
|
|
362
|
-
|
|
363
228
|
issue.update(fields=fields_to_update)
|
|
364
229
|
self.logger.info(f"Issue {issue.key} updated successfully.")
|
|
365
230
|
return issue
|
|
@@ -398,7 +263,6 @@ class JiraPlus:
|
|
|
398
263
|
available_link_types = self._get_available_link_types()
|
|
399
264
|
if link_type not in available_link_types:
|
|
400
265
|
raise ValueError(f"Invalid link type '{link_type}'. Available link types: {available_link_types}")
|
|
401
|
-
|
|
402
266
|
try:
|
|
403
267
|
self.jira_client.create_issue_link(
|
|
404
268
|
type=link_type,
|
|
@@ -438,12 +302,10 @@ class JiraPlus:
|
|
|
438
302
|
f"\nAvailable transitions: {transition_names}"
|
|
439
303
|
)
|
|
440
304
|
return False
|
|
441
|
-
|
|
442
305
|
transition_id = next((t["id"] for t in transitions if t["name"].lower() == transition_name.lower()), None)
|
|
443
306
|
if not transition_id:
|
|
444
307
|
self.logger.error(f"Transition '{transition_name}' not found for issue {issue_key}")
|
|
445
308
|
return False
|
|
446
|
-
|
|
447
309
|
self.jira_client.transition_issue(
|
|
448
310
|
issue=issue_key, transition=transition_id, fields=fields or {}, comment=comment or None
|
|
449
311
|
)
|
|
@@ -455,21 +317,3 @@ class JiraPlus:
|
|
|
455
317
|
except Exception as err:
|
|
456
318
|
self.logger.exception(f"Unexpected error during transition of {issue_key}: {err}")
|
|
457
319
|
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
|
|
@@ -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=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
|