python-jira-plus 3.2.0__py3-none-any.whl → 4.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 +1 -5
- python_jira_plus/consts.py +20 -0
- python_jira_plus/jira_base.py +202 -101
- python_jira_plus/jira_cloud.py +39 -61
- python_jira_plus/jira_on_premise.py +27 -36
- {python_jira_plus-3.2.0.dist-info → python_jira_plus-4.0.0.dist-info}/METADATA +23 -15
- python_jira_plus-4.0.0.dist-info/RECORD +11 -0
- {python_jira_plus-3.2.0.dist-info → python_jira_plus-4.0.0.dist-info}/WHEEL +1 -1
- python_jira_plus-3.2.0.dist-info/RECORD +0 -11
- {python_jira_plus-3.2.0.dist-info → python_jira_plus-4.0.0.dist-info}/licenses/LICENSE +0 -0
- {python_jira_plus-3.2.0.dist-info → python_jira_plus-4.0.0.dist-info}/top_level.txt +0 -0
python_jira_plus/__init__.py
CHANGED
|
@@ -5,15 +5,11 @@ An enhanced Python client for JIRA with better error handling,
|
|
|
5
5
|
pagination, metadata validation, and more.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from dotenv import load_dotenv
|
|
9
|
-
|
|
10
8
|
from python_jira_plus.describe_allowed_value import describe_allowed_value
|
|
11
9
|
from python_jira_plus.jira_base import JiraBase, UrlScheme
|
|
12
10
|
from python_jira_plus.jira_cloud import JiraCloud
|
|
13
11
|
from python_jira_plus.jira_on_premise import JiraOnPremise
|
|
14
12
|
|
|
15
|
-
load_dotenv()
|
|
16
|
-
|
|
17
13
|
BASIC_FIELDS = [
|
|
18
14
|
"created",
|
|
19
15
|
"updated",
|
|
@@ -29,4 +25,4 @@ BASIC_FIELDS = [
|
|
|
29
25
|
"resolution",
|
|
30
26
|
"parent",
|
|
31
27
|
]
|
|
32
|
-
__all__ = ["JiraBase", "JiraCloud", "JiraOnPremise", "UrlScheme", "BASIC_FIELDS"]
|
|
28
|
+
__all__ = ["JiraBase", "JiraCloud", "JiraOnPremise", "UrlScheme", "BASIC_FIELDS", "describe_allowed_value"]
|
python_jira_plus/consts.py
CHANGED
|
@@ -1 +1,21 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import NamedTuple
|
|
3
|
+
|
|
1
4
|
LOGGER_NAME = "python_jira_plus"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FieldTypeCheck(NamedTuple):
|
|
8
|
+
python_types: type | tuple[type, ...]
|
|
9
|
+
label: str
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
ALLOWED_VALUE_KEYS = ("name", "key", "value", "id")
|
|
13
|
+
SCHEME_PATTERN = re.compile(r"^(https?)://", re.IGNORECASE)
|
|
14
|
+
FIELD_TYPE_CHECKS = {
|
|
15
|
+
"string": FieldTypeCheck(str, "a string"),
|
|
16
|
+
"number": FieldTypeCheck((int, float), "a number"),
|
|
17
|
+
"boolean": FieldTypeCheck(bool, "a boolean"),
|
|
18
|
+
"array": FieldTypeCheck(list, "an array"),
|
|
19
|
+
"object": FieldTypeCheck(dict, "an object"),
|
|
20
|
+
"issuetype": FieldTypeCheck(dict, "an issue type"),
|
|
21
|
+
}
|
python_jira_plus/jira_base.py
CHANGED
|
@@ -8,10 +8,16 @@ from enum import Enum
|
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
10
|
from custom_python_logger import get_logger
|
|
11
|
+
from dotenv import load_dotenv
|
|
11
12
|
from jira import JIRA, Issue, JIRAError
|
|
12
13
|
from jira.client import ResultList
|
|
13
14
|
|
|
14
|
-
from python_jira_plus.consts import
|
|
15
|
+
from python_jira_plus.consts import (
|
|
16
|
+
ALLOWED_VALUE_KEYS,
|
|
17
|
+
FIELD_TYPE_CHECKS,
|
|
18
|
+
LOGGER_NAME,
|
|
19
|
+
SCHEME_PATTERN,
|
|
20
|
+
)
|
|
15
21
|
from python_jira_plus.describe_allowed_value import describe_allowed_value
|
|
16
22
|
|
|
17
23
|
|
|
@@ -20,7 +26,7 @@ class UrlScheme(Enum):
|
|
|
20
26
|
HTTPS = "https"
|
|
21
27
|
|
|
22
28
|
|
|
23
|
-
class JiraBase(ABC):
|
|
29
|
+
class JiraBase(ABC): # pylint: disable=too-many-instance-attributes
|
|
24
30
|
use_createmeta: bool
|
|
25
31
|
|
|
26
32
|
def __init__(
|
|
@@ -31,21 +37,45 @@ class JiraBase(ABC):
|
|
|
31
37
|
jira_token: str | None = None,
|
|
32
38
|
verify_ssl: bool = True,
|
|
33
39
|
url_scheme: UrlScheme = UrlScheme.HTTPS,
|
|
40
|
+
raise_on_error: bool = False,
|
|
34
41
|
) -> None:
|
|
35
42
|
self.logger = get_logger(LOGGER_NAME)
|
|
36
43
|
get_logger("urllib3").setLevel(urllib3_log_level)
|
|
37
44
|
|
|
45
|
+
if not (jira_username and jira_token and base_url):
|
|
46
|
+
load_dotenv() # fallback for missing arguments only — no import-time side effect
|
|
38
47
|
self.jira_username = jira_username or os.getenv("JIRA_USER_NAME")
|
|
39
48
|
self.jira_token = jira_token or os.getenv("JIRA_TOKEN")
|
|
40
|
-
self.base_url = base_url or os.getenv("JIRA_BASE_URL")
|
|
41
|
-
self.url_scheme = url_scheme.value
|
|
49
|
+
self.base_url, self.url_scheme = self._parse_base_url(base_url or os.getenv("JIRA_BASE_URL"), url_scheme)
|
|
42
50
|
self.server = f"{self.url_scheme}://{self.base_url}"
|
|
43
51
|
self.verify_ssl = verify_ssl
|
|
52
|
+
self.raise_on_error = raise_on_error
|
|
53
|
+
self._validate_credentials()
|
|
44
54
|
|
|
45
55
|
self.jira_client = self.create_connection()
|
|
46
|
-
self.check_client_connection()
|
|
47
56
|
self.server_version = self.get_server_version()
|
|
48
57
|
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _parse_base_url(base_url: str | None, url_scheme: UrlScheme) -> tuple[str, str]:
|
|
60
|
+
"""Split an optional scheme off `base_url`; a scheme embedded in the URL wins over `url_scheme`."""
|
|
61
|
+
if not base_url or not base_url.strip():
|
|
62
|
+
raise ValueError("Jira base URL is missing — pass `base_url` or set JIRA_BASE_URL")
|
|
63
|
+
base_url = base_url.strip()
|
|
64
|
+
scheme = url_scheme.value
|
|
65
|
+
if match := SCHEME_PATTERN.match(base_url):
|
|
66
|
+
scheme = match.group(1).lower()
|
|
67
|
+
base_url = base_url[match.end() :]
|
|
68
|
+
return base_url.rstrip("/"), scheme
|
|
69
|
+
|
|
70
|
+
def _requires_username(self) -> bool:
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
def _validate_credentials(self) -> None:
|
|
74
|
+
if not self.jira_token:
|
|
75
|
+
raise ValueError("Jira token is missing — pass `jira_token` or set JIRA_TOKEN")
|
|
76
|
+
if self._requires_username() and not self.jira_username:
|
|
77
|
+
raise ValueError("Jira username is missing — pass `jira_username` or set JIRA_USER_NAME")
|
|
78
|
+
|
|
49
79
|
@abstractmethod
|
|
50
80
|
def create_connection(self, timeout: int = 580) -> JIRA: ...
|
|
51
81
|
|
|
@@ -55,13 +85,19 @@ class JiraBase(ABC):
|
|
|
55
85
|
query: str,
|
|
56
86
|
max_results: int,
|
|
57
87
|
specific_fields: str | list[str],
|
|
58
|
-
jira_err_limit: int,
|
|
59
88
|
json_result: bool,
|
|
89
|
+
limit: int | None = None,
|
|
60
90
|
) -> ResultList[Issue] | list: ...
|
|
61
91
|
|
|
62
92
|
@abstractmethod
|
|
63
93
|
def _format_assignee(self, assignee: str) -> dict: ...
|
|
64
94
|
|
|
95
|
+
def _handle_error(self, message: str, err: Exception) -> None:
|
|
96
|
+
"""Log `err`; re-raise it when `raise_on_error` is set. Callers return their failure value after."""
|
|
97
|
+
self.logger.exception(f"{message}\n{err}")
|
|
98
|
+
if self.raise_on_error:
|
|
99
|
+
raise err
|
|
100
|
+
|
|
65
101
|
def close_connection(self) -> None:
|
|
66
102
|
try:
|
|
67
103
|
self.jira_client.close()
|
|
@@ -69,7 +105,6 @@ class JiraBase(ABC):
|
|
|
69
105
|
self.logger.exception(f"JIRAError \n{err}")
|
|
70
106
|
except Exception as err:
|
|
71
107
|
self.logger.exception(f"Exception \n{err}")
|
|
72
|
-
self.check_client_connection()
|
|
73
108
|
|
|
74
109
|
def check_client_connection(self) -> bool:
|
|
75
110
|
try:
|
|
@@ -95,11 +130,8 @@ class JiraBase(ABC):
|
|
|
95
130
|
if json_result:
|
|
96
131
|
return jira_issue.raw
|
|
97
132
|
return jira_issue
|
|
98
|
-
except JIRAError as err:
|
|
99
|
-
self.logger.exception(f"JIRAError \n{err}")
|
|
100
|
-
return None
|
|
101
133
|
except Exception as err:
|
|
102
|
-
self.
|
|
134
|
+
self._handle_error(f"Failed to get issue {key}", err)
|
|
103
135
|
return None
|
|
104
136
|
|
|
105
137
|
def get_objects_by_query(
|
|
@@ -107,31 +139,58 @@ class JiraBase(ABC):
|
|
|
107
139
|
query: str,
|
|
108
140
|
max_results: int = 5000,
|
|
109
141
|
specific_fields: str | list[str] = "*all",
|
|
110
|
-
jira_err_count: int = 3,
|
|
111
142
|
json_result: bool = True,
|
|
143
|
+
limit: int | None = None,
|
|
112
144
|
) -> ResultList[Issue] | list:
|
|
145
|
+
"""Run a JQL query and return all matches.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
query: JQL string.
|
|
149
|
+
max_results: Page size (on-premise: also the total cap unless `limit` is given).
|
|
150
|
+
specific_fields: Fields to fetch.
|
|
151
|
+
json_result: Return raw dicts instead of `Issue` objects.
|
|
152
|
+
limit: Optional cap on the total number of issues returned.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
JIRAError: If Jira rejects the query or a request fails.
|
|
156
|
+
"""
|
|
113
157
|
return self._paginate(
|
|
114
158
|
query=query,
|
|
115
159
|
max_results=max_results,
|
|
116
160
|
specific_fields=specific_fields,
|
|
117
|
-
jira_err_limit=jira_err_count,
|
|
118
161
|
json_result=json_result,
|
|
162
|
+
limit=limit,
|
|
119
163
|
)
|
|
120
164
|
|
|
165
|
+
@staticmethod
|
|
166
|
+
def _find_issue_meta(meta: dict, project_key: str, issue_type: str) -> tuple[dict | None, dict | None]:
|
|
167
|
+
if (project_meta := next((p for p in meta["projects"] if p["key"] == project_key), None)) is None:
|
|
168
|
+
return None, None
|
|
169
|
+
issue_meta = next((it for it in project_meta["issuetypes"] if it["name"].lower() == issue_type.lower()), None)
|
|
170
|
+
return project_meta, issue_meta
|
|
171
|
+
|
|
121
172
|
def _fetch_via_createmeta(self, project_key: str, issue_type: str) -> dict:
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
)
|
|
125
|
-
if
|
|
173
|
+
expand = "projects.issuetypes.fields"
|
|
174
|
+
meta = self.jira_client.createmeta(projectKeys=project_key, issuetypeNames=issue_type, expand=expand)
|
|
175
|
+
project_meta, issue_meta = self._find_issue_meta(meta, project_key, issue_type)
|
|
176
|
+
if issue_meta is None:
|
|
177
|
+
# `issuetypeNames` is matched case-sensitively server-side, so a wrong-cased name comes back empty.
|
|
178
|
+
meta = self.jira_client.createmeta(projectKeys=project_key, expand=expand)
|
|
179
|
+
project_meta, issue_meta = self._find_issue_meta(meta, project_key, issue_type)
|
|
180
|
+
if project_meta is None:
|
|
126
181
|
raise ValueError(f"Project {project_key} not found in metadata")
|
|
127
|
-
|
|
128
|
-
if not issue_meta:
|
|
182
|
+
if issue_meta is None:
|
|
129
183
|
raise ValueError(f"Issue type '{issue_type}' not found in project '{project_key}'")
|
|
130
184
|
return issue_meta["fields"]
|
|
131
185
|
|
|
132
186
|
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
|
-
|
|
187
|
+
issue_types = {t.name.lower(): t for t in self.jira_client.project_issue_types(project_key)}
|
|
188
|
+
if (matched := issue_types.get(issue_type.lower())) is None:
|
|
189
|
+
available = sorted(t.name for t in issue_types.values())
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"Issue type '{issue_type}' not found in project '{project_key}'. Available issue types: {available}"
|
|
192
|
+
)
|
|
193
|
+
field_list = self.jira_client.project_issue_fields(project_key, matched.id)
|
|
135
194
|
return {f.fieldId: f.raw for f in field_list}
|
|
136
195
|
|
|
137
196
|
def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
|
|
@@ -163,38 +222,52 @@ class JiraBase(ABC):
|
|
|
163
222
|
|
|
164
223
|
@staticmethod
|
|
165
224
|
def _is_value_allowed(value: Any, allowed_values: list[Any]) -> bool:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
allowed_values
|
|
169
|
-
return describe_allowed_value(value, allowed_values=allowed_values)
|
|
225
|
+
allowed = {av.get(key) for av in allowed_values for key in ALLOWED_VALUE_KEYS}
|
|
226
|
+
allowed.discard(None)
|
|
227
|
+
return describe_allowed_value(value, allowed_values=allowed)
|
|
170
228
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _check_field_type(field_id: str, field_type: str | None, value: Any) -> None:
|
|
231
|
+
if (check := FIELD_TYPE_CHECKS.get(field_type or "")) and not isinstance(value, check.python_types):
|
|
232
|
+
raise ValueError(f"Field '{field_id}' must be {check.label}")
|
|
233
|
+
|
|
234
|
+
def _validate_against_metadata(self, fields_metadata: dict, fields: dict, context: str) -> None:
|
|
235
|
+
for field_id, value in fields.items():
|
|
236
|
+
if field_id not in fields_metadata:
|
|
237
|
+
raise ValueError(f'Invalid field(s) for {context}: "{field_id}"')
|
|
238
|
+
field_meta = fields_metadata[field_id]
|
|
239
|
+
if (allowed_value := field_meta.get("allowedValues")) and not self._is_value_allowed(
|
|
240
|
+
value=value, allowed_values=allowed_value
|
|
241
|
+
):
|
|
175
242
|
raise ValueError(
|
|
176
|
-
f
|
|
243
|
+
f"Invalid value '{value}' for field '{field_id}' in {context}. "
|
|
244
|
+
f"The allowed values are: {json.dumps(allowed_value, indent=4, sort_keys=True)}"
|
|
177
245
|
)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
246
|
+
self._check_field_type(field_id, field_meta.get("schema", {}).get("type"), value)
|
|
247
|
+
|
|
248
|
+
def validate_fields(
|
|
249
|
+
self, project_key: str, issue_type: str, fields: dict, fields_metadata: dict | None = None
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Validate `fields`; pass `fields_metadata` to reuse an already-fetched result."""
|
|
252
|
+
if fields_metadata is None:
|
|
253
|
+
fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
|
|
254
|
+
self._validate_against_metadata(
|
|
255
|
+
fields_metadata, fields, context=f'project "{project_key}", issue type "{issue_type}"'
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _canonical_issue_type(fields_metadata: dict, issue_type: str) -> str:
|
|
260
|
+
"""Return the issue type's real casing (e.g. 'task' -> 'Task'), or `issue_type` if metadata lacks it."""
|
|
261
|
+
allowed = fields_metadata.get("issuetype", {}).get("allowedValues", [])
|
|
262
|
+
return next((av["name"] for av in allowed if av.get("name", "").lower() == issue_type.lower()), issue_type)
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _merge_custom_fields(issue_fields: dict[str, Any], custom_fields: dict | None) -> dict[str, Any]:
|
|
266
|
+
"""Merge `custom_fields` in, refusing to let them override the dedicated arguments."""
|
|
267
|
+
for key, value in (custom_fields or {}).items():
|
|
268
|
+
if key in issue_fields and issue_fields[key] != value:
|
|
269
|
+
raise ValueError(f"custom_fields['{key}'] conflicts with the dedicated argument for '{key}'")
|
|
270
|
+
return {**(custom_fields or {}), **issue_fields}
|
|
198
271
|
|
|
199
272
|
def create_issue(
|
|
200
273
|
self,
|
|
@@ -213,79 +286,101 @@ class JiraBase(ABC):
|
|
|
213
286
|
}
|
|
214
287
|
if assignee:
|
|
215
288
|
issue_fields["assignee"] = self._format_assignee(assignee)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
self.
|
|
289
|
+
issue_fields = self._merge_custom_fields(issue_fields, custom_fields)
|
|
290
|
+
fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
|
|
291
|
+
issue_fields["issuetype"] = {"name": self._canonical_issue_type(fields_metadata, issue_type)}
|
|
292
|
+
self.validate_fields(project_key, issue_type, issue_fields, fields_metadata=fields_metadata)
|
|
219
293
|
issue = self.jira_client.create_issue(fields=issue_fields)
|
|
220
294
|
self.logger.info(f"Issue {issue.key} created successfully.")
|
|
221
295
|
return issue
|
|
222
296
|
|
|
223
297
|
def update_issue(self, issue_key: str, fields_to_update: dict) -> Issue | None:
|
|
298
|
+
"""Update an issue after validating `fields_to_update` against its edit metadata.
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
ValueError: If a field is not editable or a value is invalid (always raised).
|
|
302
|
+
"""
|
|
224
303
|
try:
|
|
225
304
|
issue = self.jira_client.issue(issue_key)
|
|
226
|
-
self.
|
|
227
|
-
|
|
228
|
-
)
|
|
305
|
+
edit_fields = self.jira_client.editmeta(issue_key)["fields"]
|
|
306
|
+
self._validate_against_metadata(edit_fields, fields_to_update, context=f'issue "{issue_key}"')
|
|
229
307
|
issue.update(fields=fields_to_update)
|
|
230
308
|
self.logger.info(f"Issue {issue.key} updated successfully.")
|
|
231
309
|
return issue
|
|
232
|
-
except
|
|
233
|
-
|
|
234
|
-
return None
|
|
310
|
+
except ValueError:
|
|
311
|
+
raise
|
|
235
312
|
except Exception as err:
|
|
236
|
-
self.
|
|
313
|
+
self._handle_error(f"Failed to update issue {issue_key}", err)
|
|
237
314
|
return None
|
|
238
315
|
|
|
239
316
|
def delete_issue(self, issue_key: str) -> bool:
|
|
240
317
|
try:
|
|
241
|
-
issue = self.
|
|
318
|
+
issue = self.jira_client.issue(issue_key)
|
|
242
319
|
issue.delete()
|
|
243
320
|
self.logger.info(f"Issue {issue_key} deleted successfully.")
|
|
244
321
|
return True
|
|
245
|
-
except JIRAError as err:
|
|
246
|
-
self.logger.error(f"JIRAError while deleting issue: {err.text}")
|
|
247
|
-
return False
|
|
248
322
|
except Exception as err:
|
|
249
|
-
self.
|
|
323
|
+
self._handle_error(f"Failed to delete issue {issue_key}", err)
|
|
250
324
|
return False
|
|
251
325
|
|
|
252
326
|
def add_comment(self, issue: str, comment: str) -> None:
|
|
253
327
|
self.jira_client.add_comment(issue=issue, body=comment)
|
|
254
328
|
self.logger.debug(f"Comment added to issue {issue} successfully.")
|
|
255
329
|
|
|
330
|
+
def get_link_types(self) -> list[dict[str, str]]:
|
|
331
|
+
"""Return the instance's issue link types as `{name, inward, outward}` dicts."""
|
|
332
|
+
return [
|
|
333
|
+
{"name": link_type.name, "inward": link_type.inward, "outward": link_type.outward}
|
|
334
|
+
for link_type in self.jira_client.issue_link_types()
|
|
335
|
+
]
|
|
336
|
+
|
|
256
337
|
def _get_available_link_types(self) -> set[str]:
|
|
257
|
-
|
|
258
|
-
return {link_type.name for link_type in self.jira_client.issue_link_types()}
|
|
259
|
-
except Exception as err:
|
|
260
|
-
self.logger.exception(f"Failed to retrieve link types from JIRA. \n{err}")
|
|
261
|
-
return set()
|
|
338
|
+
return {link_type["name"] for link_type in self.get_link_types()}
|
|
262
339
|
|
|
263
340
|
def create_link(self, issue_key: str, link_type: str, in_issue_key: str) -> bool:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
341
|
+
try:
|
|
342
|
+
available_link_types = self._get_available_link_types()
|
|
343
|
+
except Exception as err:
|
|
344
|
+
self._handle_error("Failed to retrieve link types from JIRA", err)
|
|
345
|
+
return False
|
|
346
|
+
if (canonical := {name.lower(): name for name in available_link_types}.get(link_type.lower())) is None:
|
|
347
|
+
raise ValueError(f"Invalid link type '{link_type}'. Available link types: {sorted(available_link_types)}")
|
|
267
348
|
try:
|
|
268
349
|
self.jira_client.create_issue_link(
|
|
269
|
-
type=
|
|
350
|
+
type=canonical,
|
|
270
351
|
inwardIssue=issue_key,
|
|
271
352
|
outwardIssue=in_issue_key,
|
|
272
353
|
)
|
|
273
354
|
self.logger.info(f"Link between {issue_key} and {in_issue_key} was created")
|
|
274
355
|
return True
|
|
275
|
-
except JIRAError as err:
|
|
276
|
-
self.logger.exception(f"JIRAError - Link between {issue_key} and {in_issue_key} was not created\n{err}")
|
|
277
|
-
return False
|
|
278
356
|
except Exception as err:
|
|
279
|
-
self.
|
|
357
|
+
self._handle_error(f"Link between {issue_key} and {in_issue_key} was not created", err)
|
|
280
358
|
return False
|
|
281
359
|
|
|
282
|
-
def upload_attachment(self, issue_key: str, file_path: str) ->
|
|
360
|
+
def upload_attachment(self, issue_key: str, file_path: str) -> bool:
|
|
283
361
|
try:
|
|
284
362
|
self.jira_client.add_attachment(issue=issue_key, attachment=file_path)
|
|
285
|
-
|
|
286
|
-
self.logger.exception(f"JIRAError - Attachment {file_path} was not uploaded to issue {issue_key}\n{err}")
|
|
363
|
+
return True
|
|
287
364
|
except Exception as err:
|
|
288
|
-
self.
|
|
365
|
+
self._handle_error(f"Attachment {file_path} was not uploaded to issue {issue_key}", err)
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
def _fetch_transitions(self, issue_key: str) -> list[dict]:
|
|
369
|
+
"""Single place that fetches transitions; `fields` is expanded so callers can inspect the screen."""
|
|
370
|
+
return self.jira_client.transitions(issue_key, expand="transitions.fields")
|
|
371
|
+
|
|
372
|
+
def get_transitions(self, issue_key: str) -> list[dict[str, str]]:
|
|
373
|
+
"""Return the transitions currently available for an issue as `{id, name}` dicts."""
|
|
374
|
+
return [{"id": t["id"], "name": t["name"]} for t in self._fetch_transitions(issue_key)]
|
|
375
|
+
|
|
376
|
+
def _resolve_transition(self, issue_key: str, transition_name: str) -> dict:
|
|
377
|
+
transitions = self._fetch_transitions(issue_key)
|
|
378
|
+
if transition := next((t for t in transitions if t["name"].lower() == transition_name.lower()), None):
|
|
379
|
+
return transition
|
|
380
|
+
raise ValueError(
|
|
381
|
+
f"Transition '{transition_name}' not found for issue {issue_key}. "
|
|
382
|
+
f"Available transitions: {sorted(t['name'] for t in transitions)}"
|
|
383
|
+
)
|
|
289
384
|
|
|
290
385
|
def transition_issue(
|
|
291
386
|
self,
|
|
@@ -294,27 +389,33 @@ class JiraBase(ABC):
|
|
|
294
389
|
fields: dict | None = None,
|
|
295
390
|
comment: str | None = None,
|
|
296
391
|
) -> bool:
|
|
392
|
+
"""Transition an issue by name (case-insensitive), optionally with a comment.
|
|
393
|
+
|
|
394
|
+
Jira silently drops a transition comment when the transition screen has no comment field,
|
|
395
|
+
so in that case the comment is added separately after the transition succeeds.
|
|
396
|
+
"""
|
|
397
|
+
needs_separate_comment = False
|
|
297
398
|
try:
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if transition_name not in transition_names:
|
|
301
|
-
self.logger.error(
|
|
302
|
-
f"Transition '{transition_name}' not found for issue {issue_key}. "
|
|
303
|
-
f"\nAvailable transitions: {transition_names}"
|
|
304
|
-
)
|
|
305
|
-
return False
|
|
306
|
-
transition_id = next((t["id"] for t in transitions if t["name"].lower() == transition_name.lower()), None)
|
|
307
|
-
if not transition_id:
|
|
308
|
-
self.logger.error(f"Transition '{transition_name}' not found for issue {issue_key}")
|
|
309
|
-
return False
|
|
399
|
+
transition = self._resolve_transition(issue_key, transition_name)
|
|
400
|
+
needs_separate_comment = bool(comment) and "comment" not in transition.get("fields", {})
|
|
310
401
|
self.jira_client.transition_issue(
|
|
311
|
-
issue=issue_key,
|
|
402
|
+
issue=issue_key,
|
|
403
|
+
transition=transition["id"],
|
|
404
|
+
fields=fields or {},
|
|
405
|
+
comment=None if needs_separate_comment else comment or None,
|
|
312
406
|
)
|
|
313
|
-
self.logger.info(f"Issue {issue_key} transitioned to '{transition_name}'")
|
|
314
|
-
return True
|
|
315
|
-
except JIRAError as err:
|
|
316
|
-
self.logger.exception(f"JIRAError during transition of {issue_key}: {err}")
|
|
317
|
-
return False
|
|
318
407
|
except Exception as err:
|
|
319
|
-
self.
|
|
408
|
+
self._handle_error(f"Failed to transition {issue_key} to '{transition_name}'", err)
|
|
320
409
|
return False
|
|
410
|
+
self.logger.info(f"Issue {issue_key} transitioned to '{transition_name}'")
|
|
411
|
+
if needs_separate_comment:
|
|
412
|
+
self._comment_after_transition(issue_key, comment)
|
|
413
|
+
return True
|
|
414
|
+
|
|
415
|
+
def _comment_after_transition(self, issue_key: str, comment: str | None) -> None:
|
|
416
|
+
"""The transition already happened, so a comment failure is reported but does not undo its success."""
|
|
417
|
+
try:
|
|
418
|
+
if comment:
|
|
419
|
+
self.add_comment(issue=issue_key, comment=comment)
|
|
420
|
+
except Exception as err:
|
|
421
|
+
self._handle_error(f"Issue {issue_key} was transitioned but the comment was not added", err)
|
python_jira_plus/jira_cloud.py
CHANGED
|
@@ -1,36 +1,18 @@
|
|
|
1
1
|
# jira_cloud.py
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import
|
|
3
|
+
from functools import partial
|
|
4
|
+
from typing import Any
|
|
5
5
|
|
|
6
6
|
import requests
|
|
7
7
|
from jira import JIRA, Issue, JIRAError
|
|
8
8
|
from jira.client import ResultList
|
|
9
9
|
|
|
10
|
-
from python_jira_plus.jira_base import JiraBase
|
|
10
|
+
from python_jira_plus.jira_base import JiraBase
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class JiraCloud(JiraBase):
|
|
14
14
|
use_createmeta = True
|
|
15
15
|
|
|
16
|
-
def __init__(
|
|
17
|
-
self,
|
|
18
|
-
base_url: str | None = None,
|
|
19
|
-
urllib3_log_level: int = logging.WARNING,
|
|
20
|
-
jira_username: str | None = None,
|
|
21
|
-
jira_token: str | None = None,
|
|
22
|
-
verify_ssl: bool = True,
|
|
23
|
-
url_scheme: UrlScheme = UrlScheme.HTTPS,
|
|
24
|
-
) -> None:
|
|
25
|
-
super().__init__(
|
|
26
|
-
base_url=base_url,
|
|
27
|
-
urllib3_log_level=urllib3_log_level,
|
|
28
|
-
jira_username=jira_username,
|
|
29
|
-
jira_token=jira_token,
|
|
30
|
-
verify_ssl=verify_ssl,
|
|
31
|
-
url_scheme=url_scheme,
|
|
32
|
-
)
|
|
33
|
-
|
|
34
16
|
def create_connection(self, timeout: int = 580) -> JIRA:
|
|
35
17
|
jira_client = JIRA(
|
|
36
18
|
basic_auth=(self.jira_username, self.jira_token),
|
|
@@ -46,64 +28,60 @@ class JiraCloud(JiraBase):
|
|
|
46
28
|
def _format_assignee(self, assignee: str) -> dict:
|
|
47
29
|
return {"accountId": assignee}
|
|
48
30
|
|
|
31
|
+
@staticmethod
|
|
32
|
+
def _split_page(page: Any, json_result: bool) -> tuple[list, bool, str | None]:
|
|
33
|
+
"""Return (issues, is_last, next_page_token) from a search page; an empty page counts as the last."""
|
|
34
|
+
if not page:
|
|
35
|
+
return [], True, None
|
|
36
|
+
if json_result:
|
|
37
|
+
return page.get("issues", []), page["isLast"], page.get("nextPageToken")
|
|
38
|
+
return list(page), page.isLast, page.nextPageToken
|
|
39
|
+
|
|
49
40
|
def _paginate(
|
|
50
41
|
self,
|
|
51
42
|
query: str,
|
|
52
43
|
max_results: int,
|
|
53
44
|
specific_fields: str | list[str],
|
|
54
|
-
jira_err_limit: int,
|
|
55
45
|
json_result: bool,
|
|
46
|
+
limit: int | None = None,
|
|
56
47
|
) -> ResultList[Issue] | list:
|
|
57
|
-
all_issues = []
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
break
|
|
76
|
-
|
|
77
|
-
all_issues += page["issues"] if json_result else page
|
|
78
|
-
if page["isLast"] if json_result else page.isLast:
|
|
79
|
-
break
|
|
80
|
-
retries = 0
|
|
81
|
-
except JIRAError as err:
|
|
82
|
-
if err.status_code == 400:
|
|
83
|
-
self.logger.error(f"Bad Request: {err.text}")
|
|
84
|
-
raise JIRAError(f"Bad Request: {err.text}") from err
|
|
85
|
-
retries += 1
|
|
86
|
-
self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
|
|
87
|
-
if retries >= jira_err_limit:
|
|
88
|
-
self.logger.exception("Max retries exceeded.")
|
|
89
|
-
return []
|
|
90
|
-
time.sleep(300 if retries == jira_err_limit - 1 else 90)
|
|
91
|
-
return all_issues
|
|
48
|
+
all_issues: list = []
|
|
49
|
+
next_token = None
|
|
50
|
+
while limit is None or len(all_issues) < limit: # pylint: disable=while-used
|
|
51
|
+
self.logger.debug(f"Fetching issues from startAt={len(all_issues)}")
|
|
52
|
+
page_size = max_results if limit is None else min(max_results, limit - len(all_issues))
|
|
53
|
+
fetch = partial(
|
|
54
|
+
self.jira_client.enhanced_search_issues,
|
|
55
|
+
jql_str=query,
|
|
56
|
+
nextPageToken=next_token,
|
|
57
|
+
maxResults=page_size,
|
|
58
|
+
fields=specific_fields,
|
|
59
|
+
json_result=json_result,
|
|
60
|
+
)
|
|
61
|
+
issues, is_last, next_token = self._split_page(fetch(), json_result)
|
|
62
|
+
all_issues += issues
|
|
63
|
+
if is_last or not issues:
|
|
64
|
+
break
|
|
65
|
+
return all_issues if limit is None else all_issues[:limit]
|
|
92
66
|
|
|
93
67
|
def get_account_data(self, user_mail: str) -> list[dict]:
|
|
94
68
|
response = requests.get(
|
|
95
69
|
f"{self.server}/rest/api/3/user/search",
|
|
96
70
|
params={"query": user_mail},
|
|
97
71
|
auth=(self.jira_username, self.jira_token),
|
|
72
|
+
verify=self.verify_ssl,
|
|
98
73
|
timeout=30,
|
|
99
74
|
)
|
|
100
75
|
response.raise_for_status()
|
|
101
76
|
return response.json()
|
|
102
77
|
|
|
78
|
+
def _first_account(self, query: str) -> dict:
|
|
79
|
+
if not (data := self.get_account_data(user_mail=query)):
|
|
80
|
+
raise ValueError(f"No Jira user found matching '{query}'")
|
|
81
|
+
return data[0]
|
|
82
|
+
|
|
103
83
|
def get_account_id(self, user_mail: str) -> str:
|
|
104
|
-
|
|
105
|
-
return data[0]["accountId"]
|
|
84
|
+
return self._first_account(user_mail)["accountId"]
|
|
106
85
|
|
|
107
86
|
def get_account_display_name(self, user_display_name: str) -> str:
|
|
108
|
-
|
|
109
|
-
return data[0]["displayName"]
|
|
87
|
+
return self._first_account(user_display_name)["displayName"]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# jira_on_premise.py
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
import
|
|
4
|
+
from functools import partial
|
|
5
5
|
|
|
6
6
|
from jira import JIRA, Issue, JIRAError
|
|
7
7
|
from jira.client import ResultList
|
|
@@ -20,6 +20,7 @@ class JiraOnPremise(JiraBase):
|
|
|
20
20
|
verify_ssl: bool = True,
|
|
21
21
|
sso: bool = False,
|
|
22
22
|
url_scheme: UrlScheme = UrlScheme.HTTPS,
|
|
23
|
+
raise_on_error: bool = False,
|
|
23
24
|
) -> None:
|
|
24
25
|
self.sso = sso # must be set before super().__init__ — create_connection reads it
|
|
25
26
|
super().__init__(
|
|
@@ -29,6 +30,7 @@ class JiraOnPremise(JiraBase):
|
|
|
29
30
|
jira_token=jira_token,
|
|
30
31
|
verify_ssl=verify_ssl,
|
|
31
32
|
url_scheme=url_scheme,
|
|
33
|
+
raise_on_error=raise_on_error,
|
|
32
34
|
)
|
|
33
35
|
use_legacy = Version(".".join(str(x) for x in self.server_version)) < Version("9.0.0")
|
|
34
36
|
self.logger.debug(
|
|
@@ -37,6 +39,9 @@ class JiraOnPremise(JiraBase):
|
|
|
37
39
|
)
|
|
38
40
|
self.use_createmeta = use_legacy
|
|
39
41
|
|
|
42
|
+
def _requires_username(self) -> bool:
|
|
43
|
+
return self.sso # token auth needs no username
|
|
44
|
+
|
|
40
45
|
def create_connection(self, timeout: int = 580) -> JIRA:
|
|
41
46
|
auth = (
|
|
42
47
|
{"token_auth": self.jira_token} if not self.sso else {"basic_auth": (self.jira_username, self.jira_token)}
|
|
@@ -56,41 +61,27 @@ class JiraOnPremise(JiraBase):
|
|
|
56
61
|
query: str,
|
|
57
62
|
max_results: int,
|
|
58
63
|
specific_fields: str | list[str],
|
|
59
|
-
jira_err_limit: int,
|
|
60
64
|
json_result: bool,
|
|
65
|
+
limit: int | None = None,
|
|
61
66
|
) -> ResultList[Issue] | list:
|
|
67
|
+
cap = max_results if limit is None else limit # without `limit`, max_results is also the total cap
|
|
62
68
|
start_at = 0
|
|
63
|
-
all_issues = []
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
self.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
start_at += page.maxResults
|
|
83
|
-
if start_at >= max_results:
|
|
84
|
-
break
|
|
85
|
-
retries = 0
|
|
86
|
-
except JIRAError as err:
|
|
87
|
-
if err.status_code == 400:
|
|
88
|
-
self.logger.error(f"Bad Request: {err.text}")
|
|
89
|
-
raise JIRAError(f"Bad Request: {err.text}") from err
|
|
90
|
-
retries += 1
|
|
91
|
-
self.logger.warning(f"Retry {retries}/{jira_err_limit} after JIRAError: {err}")
|
|
92
|
-
if retries >= jira_err_limit:
|
|
93
|
-
self.logger.exception("Max retries exceeded.")
|
|
94
|
-
return []
|
|
95
|
-
time.sleep(300 if retries == jira_err_limit - 1 else 90)
|
|
96
|
-
return all_issues
|
|
69
|
+
all_issues: list = []
|
|
70
|
+
while start_at < cap: # pylint: disable=while-used
|
|
71
|
+
self.logger.debug(f"Fetching issues from startAt={start_at}")
|
|
72
|
+
fetch = partial(
|
|
73
|
+
self.jira_client.search_issues,
|
|
74
|
+
jql_str=query,
|
|
75
|
+
startAt=start_at,
|
|
76
|
+
maxResults=min(max_results, cap - start_at),
|
|
77
|
+
fields=specific_fields,
|
|
78
|
+
json_result=json_result,
|
|
79
|
+
)
|
|
80
|
+
page = fetch()
|
|
81
|
+
if not (issues := (page.get("issues") if json_result else list(page)) if page else None):
|
|
82
|
+
break
|
|
83
|
+
all_issues += issues
|
|
84
|
+
start_at += len(issues)
|
|
85
|
+
if start_at >= (page["total"] if json_result else page.total):
|
|
86
|
+
break
|
|
87
|
+
return all_issues[:cap]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-jira-plus
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.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
|
|
@@ -21,11 +21,6 @@ Requires-Dist: python-dotenv>=1.0.0
|
|
|
21
21
|
Requires-Dist: jira>=3.10.5
|
|
22
22
|
Requires-Dist: packaging>=26.2
|
|
23
23
|
Requires-Dist: requests>=2.32.5
|
|
24
|
-
Provides-Extra: dev
|
|
25
|
-
Requires-Dist: pre-commit>=4.5.0; extra == "dev"
|
|
26
|
-
Requires-Dist: pytest>=9.0.1; extra == "dev"
|
|
27
|
-
Requires-Dist: setuptools>=80.9.0; extra == "dev"
|
|
28
|
-
Requires-Dist: wheel>=0.45.1; extra == "dev"
|
|
29
24
|
Dynamic: license-file
|
|
30
25
|
|
|
31
26
|

|
|
@@ -44,7 +39,7 @@ An enhanced Python client for JIRA that extends the functionality of the officia
|
|
|
44
39
|
|
|
45
40
|
## Features
|
|
46
41
|
- ✅ Simplified connection to JIRA Cloud and On-Premise instances
|
|
47
|
-
- ✅
|
|
42
|
+
- ✅ Clear error handling: failures raise (`raise_on_error=True`) or are logged and returned as `None`/`False`. Rate limits (429) and 503 are retried by `jira-python` itself
|
|
48
43
|
- ✅ Built-in pagination for large result sets
|
|
49
44
|
- ✅ Field validation against JIRA metadata
|
|
50
45
|
- ✅ Enhanced issue creation, retrieval, and updating
|
|
@@ -62,7 +57,6 @@ pip install python-jira-plus
|
|
|
62
57
|
## Requirements
|
|
63
58
|
- Python 3.12+
|
|
64
59
|
- `jira` package
|
|
65
|
-
- `retrying` package
|
|
66
60
|
- `custom-python-logger` package
|
|
67
61
|
|
|
68
62
|
---
|
|
@@ -77,13 +71,26 @@ JIRA_TOKEN=your_jira_api_token
|
|
|
77
71
|
JIRA_BASE_URL=your-instance.atlassian.net # Only used if base_url is not provided to constructor
|
|
78
72
|
```
|
|
79
73
|
|
|
74
|
+
## Error handling
|
|
75
|
+
By default, write helpers (`delete_issue`, `create_link`, `transition_issue`, `upload_attachment`,
|
|
76
|
+
`update_issue`) and `get_issue_by_key` log the error and return `False`/`None`. Pass
|
|
77
|
+
`raise_on_error=True` to the constructor to get the original exception instead:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
jira_client = JiraCloud(raise_on_error=True)
|
|
81
|
+
jira_client.get_issue_by_key("PROJ-404") # raises JIRAError instead of returning None
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Validation problems (`ValueError`) and failed searches (`JIRAError`) are always raised.
|
|
85
|
+
Rate limits (429) and 503 are retried by `jira-python`'s own session, not by this package.
|
|
86
|
+
|
|
80
87
|
## Examples
|
|
81
88
|
|
|
82
89
|
### Creating an Issue with Custom Fields
|
|
83
90
|
```python
|
|
84
|
-
from python_jira_plus
|
|
91
|
+
from python_jira_plus import JiraCloud # or JiraOnPremise
|
|
85
92
|
|
|
86
|
-
jira_client =
|
|
93
|
+
jira_client = JiraCloud()
|
|
87
94
|
issue = jira_client.create_issue(
|
|
88
95
|
project_key="PROJ",
|
|
89
96
|
summary="Implement new feature",
|
|
@@ -98,12 +105,13 @@ issue = jira_client.create_issue(
|
|
|
98
105
|
|
|
99
106
|
### Searching for Issues
|
|
100
107
|
```python
|
|
101
|
-
from python_jira_plus
|
|
108
|
+
from python_jira_plus import JiraCloud
|
|
102
109
|
|
|
103
|
-
jira_client =
|
|
110
|
+
jira_client = JiraCloud()
|
|
104
111
|
issues = jira_client.get_objects_by_query(
|
|
105
112
|
query="project = PROJ AND status = 'In Progress' ORDER BY created DESC",
|
|
106
|
-
max_results=50,
|
|
113
|
+
max_results=50, # page size
|
|
114
|
+
limit=200, # optional cap on the total number of issues returned
|
|
107
115
|
specific_fields=["summary", "status", "assignee"],
|
|
108
116
|
json_result=False
|
|
109
117
|
)
|
|
@@ -114,9 +122,9 @@ for issue in issues:
|
|
|
114
122
|
|
|
115
123
|
### Updating an Issue
|
|
116
124
|
```python
|
|
117
|
-
from python_jira_plus
|
|
125
|
+
from python_jira_plus import JiraCloud
|
|
118
126
|
|
|
119
|
-
jira_client =
|
|
127
|
+
jira_client = JiraCloud()
|
|
120
128
|
issue = jira_client.get_issue_by_key(key="PROJ-123", json_result=False)
|
|
121
129
|
|
|
122
130
|
fields_to_update = {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
python_jira_plus/__init__.py,sha256=4gj7xYxp_zge3tO9F7Euq8ZY29nTHiK57LOg9ILJ9nc,708
|
|
2
|
+
python_jira_plus/consts.py,sha256=ozJ1Q1p0HIFwdhNAuonHQs5bs0dTFa3pUCaC4EC88A8,610
|
|
3
|
+
python_jira_plus/describe_allowed_value.py,sha256=fWfq0DkvJ2zmNIUXPH5rO5OgcY69bUqBIfTPQyxBt5E,1287
|
|
4
|
+
python_jira_plus/jira_base.py,sha256=Ctm9Xt2KECYxCtCueTmEWOPkGlqPRPZf1rFIo0Hmrw0,18913
|
|
5
|
+
python_jira_plus/jira_cloud.py,sha256=CfkJTgf6j3wu7AnUI6BnykHRIL0XhDMtk0PejMiAsjg,3208
|
|
6
|
+
python_jira_plus/jira_on_premise.py,sha256=8GDUDzJ6FYQQgSjbZs01cP9WBvplU5myIG-x6Ykw0hs,3240
|
|
7
|
+
python_jira_plus-4.0.0.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
|
|
8
|
+
python_jira_plus-4.0.0.dist-info/METADATA,sha256=oIgsBhIH_Yh-eQ-jZhnuP0c8y6bYlVh4dn-RrLl_a_I,4836
|
|
9
|
+
python_jira_plus-4.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
python_jira_plus-4.0.0.dist-info/top_level.txt,sha256=3DxBdmf_naggoqDHSvLstG_GX3bW2XXYYorBDybBUiA,17
|
|
11
|
+
python_jira_plus-4.0.0.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
python_jira_plus/__init__.py,sha256=HMlPp5HOfdEO3VYE7Zc2qfhLsIPpPJ9wQaUojhCOp0k,729
|
|
2
|
-
python_jira_plus/consts.py,sha256=pkMp6dZUDjEwqa9OucBIeSs3mxnQt4KXxIpyuyHmY8A,33
|
|
3
|
-
python_jira_plus/describe_allowed_value.py,sha256=fWfq0DkvJ2zmNIUXPH5rO5OgcY69bUqBIfTPQyxBt5E,1287
|
|
4
|
-
python_jira_plus/jira_base.py,sha256=FEdw86I5kh8QUD2ulRNvz69R671Iqohts9DiAUTKQaQ,13875
|
|
5
|
-
python_jira_plus/jira_cloud.py,sha256=5PsyaX0oPoTTq0HBTVlGeIbtt8Zc45qqeyCQlfoYVls,3795
|
|
6
|
-
python_jira_plus/jira_on_premise.py,sha256=lueq9Glo8gluHfQcorlm7SSyT6eGzx4r94UTOrynNP0,3549
|
|
7
|
-
python_jira_plus-3.2.0.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
|
|
8
|
-
python_jira_plus-3.2.0.dist-info/METADATA,sha256=6crZgWe_uZEXKINRoAb5K6sp-ArFZmdYvFfyPfMSSXA,4253
|
|
9
|
-
python_jira_plus-3.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
-
python_jira_plus-3.2.0.dist-info/top_level.txt,sha256=3DxBdmf_naggoqDHSvLstG_GX3bW2XXYYorBDybBUiA,17
|
|
11
|
-
python_jira_plus-3.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|