python-jira-plus 3.2.0__py3-none-any.whl → 4.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 +10 -5
- python_jira_plus/consts.py +21 -0
- python_jira_plus/helpers.py +16 -0
- python_jira_plus/jira_base.py +229 -101
- python_jira_plus/jira_cloud.py +39 -61
- python_jira_plus/jira_on_premise.py +27 -36
- python_jira_plus/types.py +9 -0
- {python_jira_plus-3.2.0.dist-info → python_jira_plus-4.1.0.dist-info}/METADATA +23 -15
- python_jira_plus-4.1.0.dist-info/RECORD +13 -0
- {python_jira_plus-3.2.0.dist-info → python_jira_plus-4.1.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.1.0.dist-info}/licenses/LICENSE +0 -0
- {python_jira_plus-3.2.0.dist-info → python_jira_plus-4.1.0.dist-info}/top_level.txt +0 -0
python_jira_plus/__init__.py
CHANGED
|
@@ -5,14 +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
|
-
|
|
15
|
-
load_dotenv()
|
|
12
|
+
from python_jira_plus.types import CreateField
|
|
16
13
|
|
|
17
14
|
BASIC_FIELDS = [
|
|
18
15
|
"created",
|
|
@@ -29,4 +26,12 @@ BASIC_FIELDS = [
|
|
|
29
26
|
"resolution",
|
|
30
27
|
"parent",
|
|
31
28
|
]
|
|
32
|
-
__all__ = [
|
|
29
|
+
__all__ = [
|
|
30
|
+
"JiraBase",
|
|
31
|
+
"JiraCloud",
|
|
32
|
+
"JiraOnPremise",
|
|
33
|
+
"UrlScheme",
|
|
34
|
+
"BASIC_FIELDS",
|
|
35
|
+
"describe_allowed_value",
|
|
36
|
+
"CreateField",
|
|
37
|
+
]
|
python_jira_plus/consts.py
CHANGED
|
@@ -1 +1,22 @@
|
|
|
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
|
+
}
|
|
22
|
+
ISSUE_REF_PATTERN = re.compile(r"[A-Za-z0-9_]+(-[0-9]+)?")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from python_jira_plus.consts import ISSUE_REF_PATTERN
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def allowed_value_label(value: Any) -> str:
|
|
7
|
+
"""A readable label for one entry of a field's `allowedValues`."""
|
|
8
|
+
if isinstance(value, dict):
|
|
9
|
+
return str(value.get("name") or value.get("value") or value.get("key") or value.get("id"))
|
|
10
|
+
return str(value)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_issue_ref(issue_ref: Any) -> None:
|
|
14
|
+
"""Reject an issue key/id that could change the request path (jira-python does not escape it)."""
|
|
15
|
+
if isinstance(issue_ref, str) and not ISSUE_REF_PATTERN.fullmatch(issue_ref):
|
|
16
|
+
raise ValueError(f"Invalid issue key '{issue_ref}'. Expected a key like PROJ-123 or a numeric id")
|
python_jira_plus/jira_base.py
CHANGED
|
@@ -8,11 +8,19 @@ 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
|
|
22
|
+
from python_jira_plus.helpers import allowed_value_label, check_issue_ref
|
|
23
|
+
from python_jira_plus.types import CreateField
|
|
16
24
|
|
|
17
25
|
|
|
18
26
|
class UrlScheme(Enum):
|
|
@@ -20,7 +28,7 @@ class UrlScheme(Enum):
|
|
|
20
28
|
HTTPS = "https"
|
|
21
29
|
|
|
22
30
|
|
|
23
|
-
class JiraBase(ABC):
|
|
31
|
+
class JiraBase(ABC): # pylint: disable=too-many-instance-attributes
|
|
24
32
|
use_createmeta: bool
|
|
25
33
|
|
|
26
34
|
def __init__(
|
|
@@ -31,21 +39,45 @@ class JiraBase(ABC):
|
|
|
31
39
|
jira_token: str | None = None,
|
|
32
40
|
verify_ssl: bool = True,
|
|
33
41
|
url_scheme: UrlScheme = UrlScheme.HTTPS,
|
|
42
|
+
raise_on_error: bool = False,
|
|
34
43
|
) -> None:
|
|
35
44
|
self.logger = get_logger(LOGGER_NAME)
|
|
36
45
|
get_logger("urllib3").setLevel(urllib3_log_level)
|
|
37
46
|
|
|
47
|
+
if not (jira_username and jira_token and base_url):
|
|
48
|
+
load_dotenv() # fallback for missing arguments only — no import-time side effect
|
|
38
49
|
self.jira_username = jira_username or os.getenv("JIRA_USER_NAME")
|
|
39
50
|
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
|
|
51
|
+
self.base_url, self.url_scheme = self._parse_base_url(base_url or os.getenv("JIRA_BASE_URL"), url_scheme)
|
|
42
52
|
self.server = f"{self.url_scheme}://{self.base_url}"
|
|
43
53
|
self.verify_ssl = verify_ssl
|
|
54
|
+
self.raise_on_error = raise_on_error
|
|
55
|
+
self._validate_credentials()
|
|
44
56
|
|
|
45
57
|
self.jira_client = self.create_connection()
|
|
46
|
-
self.check_client_connection()
|
|
47
58
|
self.server_version = self.get_server_version()
|
|
48
59
|
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _parse_base_url(base_url: str | None, url_scheme: UrlScheme) -> tuple[str, str]:
|
|
62
|
+
"""Split an optional scheme off `base_url`; a scheme embedded in the URL wins over `url_scheme`."""
|
|
63
|
+
if not base_url or not base_url.strip():
|
|
64
|
+
raise ValueError("Jira base URL is missing — pass `base_url` or set JIRA_BASE_URL")
|
|
65
|
+
base_url = base_url.strip()
|
|
66
|
+
scheme = url_scheme.value
|
|
67
|
+
if match := SCHEME_PATTERN.match(base_url):
|
|
68
|
+
scheme = match.group(1).lower()
|
|
69
|
+
base_url = base_url[match.end() :]
|
|
70
|
+
return base_url.rstrip("/"), scheme
|
|
71
|
+
|
|
72
|
+
def _requires_username(self) -> bool:
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def _validate_credentials(self) -> None:
|
|
76
|
+
if not self.jira_token:
|
|
77
|
+
raise ValueError("Jira token is missing — pass `jira_token` or set JIRA_TOKEN")
|
|
78
|
+
if self._requires_username() and not self.jira_username:
|
|
79
|
+
raise ValueError("Jira username is missing — pass `jira_username` or set JIRA_USER_NAME")
|
|
80
|
+
|
|
49
81
|
@abstractmethod
|
|
50
82
|
def create_connection(self, timeout: int = 580) -> JIRA: ...
|
|
51
83
|
|
|
@@ -55,13 +87,19 @@ class JiraBase(ABC):
|
|
|
55
87
|
query: str,
|
|
56
88
|
max_results: int,
|
|
57
89
|
specific_fields: str | list[str],
|
|
58
|
-
jira_err_limit: int,
|
|
59
90
|
json_result: bool,
|
|
91
|
+
limit: int | None = None,
|
|
60
92
|
) -> ResultList[Issue] | list: ...
|
|
61
93
|
|
|
62
94
|
@abstractmethod
|
|
63
95
|
def _format_assignee(self, assignee: str) -> dict: ...
|
|
64
96
|
|
|
97
|
+
def _handle_error(self, message: str, err: Exception) -> None:
|
|
98
|
+
"""Log `err`; re-raise it when `raise_on_error` is set. Callers return their failure value after."""
|
|
99
|
+
self.logger.exception(f"{message}\n{err}")
|
|
100
|
+
if self.raise_on_error:
|
|
101
|
+
raise err
|
|
102
|
+
|
|
65
103
|
def close_connection(self) -> None:
|
|
66
104
|
try:
|
|
67
105
|
self.jira_client.close()
|
|
@@ -69,7 +107,6 @@ class JiraBase(ABC):
|
|
|
69
107
|
self.logger.exception(f"JIRAError \n{err}")
|
|
70
108
|
except Exception as err:
|
|
71
109
|
self.logger.exception(f"Exception \n{err}")
|
|
72
|
-
self.check_client_connection()
|
|
73
110
|
|
|
74
111
|
def check_client_connection(self) -> bool:
|
|
75
112
|
try:
|
|
@@ -90,16 +127,14 @@ class JiraBase(ABC):
|
|
|
90
127
|
def get_issue_by_key(
|
|
91
128
|
self, key: str, specific_fields: str | list[str] = "*all", json_result: bool = True
|
|
92
129
|
) -> None | Issue | dict[str, Any]:
|
|
130
|
+
check_issue_ref(key)
|
|
93
131
|
try:
|
|
94
132
|
jira_issue = self.jira_client.issue(id=key, fields=specific_fields)
|
|
95
133
|
if json_result:
|
|
96
134
|
return jira_issue.raw
|
|
97
135
|
return jira_issue
|
|
98
|
-
except JIRAError as err:
|
|
99
|
-
self.logger.exception(f"JIRAError \n{err}")
|
|
100
|
-
return None
|
|
101
136
|
except Exception as err:
|
|
102
|
-
self.
|
|
137
|
+
self._handle_error(f"Failed to get issue {key}", err)
|
|
103
138
|
return None
|
|
104
139
|
|
|
105
140
|
def get_objects_by_query(
|
|
@@ -107,31 +142,58 @@ class JiraBase(ABC):
|
|
|
107
142
|
query: str,
|
|
108
143
|
max_results: int = 5000,
|
|
109
144
|
specific_fields: str | list[str] = "*all",
|
|
110
|
-
jira_err_count: int = 3,
|
|
111
145
|
json_result: bool = True,
|
|
146
|
+
limit: int | None = None,
|
|
112
147
|
) -> ResultList[Issue] | list:
|
|
148
|
+
"""Run a JQL query and return all matches.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
query: JQL string.
|
|
152
|
+
max_results: Page size (on-premise: also the total cap unless `limit` is given).
|
|
153
|
+
specific_fields: Fields to fetch.
|
|
154
|
+
json_result: Return raw dicts instead of `Issue` objects.
|
|
155
|
+
limit: Optional cap on the total number of issues returned.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
JIRAError: If Jira rejects the query or a request fails.
|
|
159
|
+
"""
|
|
113
160
|
return self._paginate(
|
|
114
161
|
query=query,
|
|
115
162
|
max_results=max_results,
|
|
116
163
|
specific_fields=specific_fields,
|
|
117
|
-
jira_err_limit=jira_err_count,
|
|
118
164
|
json_result=json_result,
|
|
165
|
+
limit=limit,
|
|
119
166
|
)
|
|
120
167
|
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _find_issue_meta(meta: dict, project_key: str, issue_type: str) -> tuple[dict | None, dict | None]:
|
|
170
|
+
if (project_meta := next((p for p in meta["projects"] if p["key"] == project_key), None)) is None:
|
|
171
|
+
return None, None
|
|
172
|
+
issue_meta = next((it for it in project_meta["issuetypes"] if it["name"].lower() == issue_type.lower()), None)
|
|
173
|
+
return project_meta, issue_meta
|
|
174
|
+
|
|
121
175
|
def _fetch_via_createmeta(self, project_key: str, issue_type: str) -> dict:
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
)
|
|
125
|
-
if
|
|
176
|
+
expand = "projects.issuetypes.fields"
|
|
177
|
+
meta = self.jira_client.createmeta(projectKeys=project_key, issuetypeNames=issue_type, expand=expand)
|
|
178
|
+
project_meta, issue_meta = self._find_issue_meta(meta, project_key, issue_type)
|
|
179
|
+
if issue_meta is None:
|
|
180
|
+
# `issuetypeNames` is matched case-sensitively server-side, so a wrong-cased name comes back empty.
|
|
181
|
+
meta = self.jira_client.createmeta(projectKeys=project_key, expand=expand)
|
|
182
|
+
project_meta, issue_meta = self._find_issue_meta(meta, project_key, issue_type)
|
|
183
|
+
if project_meta is None:
|
|
126
184
|
raise ValueError(f"Project {project_key} not found in metadata")
|
|
127
|
-
|
|
128
|
-
if not issue_meta:
|
|
185
|
+
if issue_meta is None:
|
|
129
186
|
raise ValueError(f"Issue type '{issue_type}' not found in project '{project_key}'")
|
|
130
187
|
return issue_meta["fields"]
|
|
131
188
|
|
|
132
189
|
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
|
-
|
|
190
|
+
issue_types = {t.name.lower(): t for t in self.jira_client.project_issue_types(project_key)}
|
|
191
|
+
if (matched := issue_types.get(issue_type.lower())) is None:
|
|
192
|
+
available = sorted(t.name for t in issue_types.values())
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"Issue type '{issue_type}' not found in project '{project_key}'. Available issue types: {available}"
|
|
195
|
+
)
|
|
196
|
+
field_list = self.jira_client.project_issue_fields(project_key, matched.id)
|
|
135
197
|
return {f.fieldId: f.raw for f in field_list}
|
|
136
198
|
|
|
137
199
|
def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
|
|
@@ -161,40 +223,70 @@ class JiraBase(ABC):
|
|
|
161
223
|
return None
|
|
162
224
|
return allowed
|
|
163
225
|
|
|
226
|
+
def get_create_fields(self, project_key: str, issue_type: str) -> list[CreateField]:
|
|
227
|
+
"""Fields for creating an issue: required first, each with its allowed values as readable labels."""
|
|
228
|
+
result = [
|
|
229
|
+
CreateField(
|
|
230
|
+
id=field_id,
|
|
231
|
+
name=meta.get("name", field_id),
|
|
232
|
+
required=bool(meta.get("required")),
|
|
233
|
+
type=(meta.get("schema") or {}).get("type"),
|
|
234
|
+
allowed_values=(
|
|
235
|
+
[allowed_value_label(v) for v in allowed] if (allowed := meta.get("allowedValues")) else None
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
for field_id, meta in self.get_project_fields_metadata(project_key, issue_type).items()
|
|
239
|
+
]
|
|
240
|
+
return sorted(result, key=lambda f: (not f["required"], f["name"]))
|
|
241
|
+
|
|
164
242
|
@staticmethod
|
|
165
243
|
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)
|
|
244
|
+
allowed = {av.get(key) for av in allowed_values for key in ALLOWED_VALUE_KEYS}
|
|
245
|
+
allowed.discard(None)
|
|
246
|
+
return describe_allowed_value(value, allowed_values=allowed)
|
|
170
247
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _check_field_type(field_id: str, field_type: str | None, value: Any) -> None:
|
|
250
|
+
if (check := FIELD_TYPE_CHECKS.get(field_type or "")) and not isinstance(value, check.python_types):
|
|
251
|
+
raise ValueError(f"Field '{field_id}' must be {check.label}")
|
|
252
|
+
|
|
253
|
+
def _validate_against_metadata(self, fields_metadata: dict, fields: dict, context: str) -> None:
|
|
254
|
+
for field_id, value in fields.items():
|
|
255
|
+
if field_id not in fields_metadata:
|
|
256
|
+
raise ValueError(f'Invalid field(s) for {context}: "{field_id}"')
|
|
257
|
+
field_meta = fields_metadata[field_id]
|
|
258
|
+
if (allowed_value := field_meta.get("allowedValues")) and not self._is_value_allowed(
|
|
259
|
+
value=value, allowed_values=allowed_value
|
|
260
|
+
):
|
|
175
261
|
raise ValueError(
|
|
176
|
-
f
|
|
262
|
+
f"Invalid value '{value}' for field '{field_id}' in {context}. "
|
|
263
|
+
f"The allowed values are: {json.dumps(allowed_value, indent=4, sort_keys=True)}"
|
|
177
264
|
)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
265
|
+
self._check_field_type(field_id, field_meta.get("schema", {}).get("type"), value)
|
|
266
|
+
|
|
267
|
+
def validate_fields(
|
|
268
|
+
self, project_key: str, issue_type: str, fields: dict, fields_metadata: dict | None = None
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Validate `fields`; pass `fields_metadata` to reuse an already-fetched result."""
|
|
271
|
+
if fields_metadata is None:
|
|
272
|
+
fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
|
|
273
|
+
self._validate_against_metadata(
|
|
274
|
+
fields_metadata, fields, context=f'project "{project_key}", issue type "{issue_type}"'
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
@staticmethod
|
|
278
|
+
def _canonical_issue_type(fields_metadata: dict, issue_type: str) -> str:
|
|
279
|
+
"""Return the issue type's real casing (e.g. 'task' -> 'Task'), or `issue_type` if metadata lacks it."""
|
|
280
|
+
allowed = fields_metadata.get("issuetype", {}).get("allowedValues", [])
|
|
281
|
+
return next((av["name"] for av in allowed if av.get("name", "").lower() == issue_type.lower()), issue_type)
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def _merge_custom_fields(issue_fields: dict[str, Any], custom_fields: dict | None) -> dict[str, Any]:
|
|
285
|
+
"""Merge `custom_fields` in, refusing to let them override the dedicated arguments."""
|
|
286
|
+
for key, value in (custom_fields or {}).items():
|
|
287
|
+
if key in issue_fields and issue_fields[key] != value:
|
|
288
|
+
raise ValueError(f"custom_fields['{key}'] conflicts with the dedicated argument for '{key}'")
|
|
289
|
+
return {**(custom_fields or {}), **issue_fields}
|
|
198
290
|
|
|
199
291
|
def create_issue(
|
|
200
292
|
self,
|
|
@@ -213,79 +305,108 @@ class JiraBase(ABC):
|
|
|
213
305
|
}
|
|
214
306
|
if assignee:
|
|
215
307
|
issue_fields["assignee"] = self._format_assignee(assignee)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
self.
|
|
308
|
+
issue_fields = self._merge_custom_fields(issue_fields, custom_fields)
|
|
309
|
+
fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
|
|
310
|
+
issue_fields["issuetype"] = {"name": self._canonical_issue_type(fields_metadata, issue_type)}
|
|
311
|
+
self.validate_fields(project_key, issue_type, issue_fields, fields_metadata=fields_metadata)
|
|
219
312
|
issue = self.jira_client.create_issue(fields=issue_fields)
|
|
220
313
|
self.logger.info(f"Issue {issue.key} created successfully.")
|
|
221
314
|
return issue
|
|
222
315
|
|
|
223
316
|
def update_issue(self, issue_key: str, fields_to_update: dict) -> Issue | None:
|
|
317
|
+
"""Update an issue after validating `fields_to_update` against its edit metadata.
|
|
318
|
+
|
|
319
|
+
Raises:
|
|
320
|
+
ValueError: If a field is not editable or a value is invalid (always raised).
|
|
321
|
+
"""
|
|
322
|
+
check_issue_ref(issue_key)
|
|
224
323
|
try:
|
|
225
324
|
issue = self.jira_client.issue(issue_key)
|
|
226
|
-
self.
|
|
227
|
-
|
|
228
|
-
)
|
|
325
|
+
edit_fields = self.jira_client.editmeta(issue_key)["fields"]
|
|
326
|
+
self._validate_against_metadata(edit_fields, fields_to_update, context=f'issue "{issue_key}"')
|
|
229
327
|
issue.update(fields=fields_to_update)
|
|
230
328
|
self.logger.info(f"Issue {issue.key} updated successfully.")
|
|
231
329
|
return issue
|
|
232
|
-
except
|
|
233
|
-
|
|
234
|
-
return None
|
|
330
|
+
except ValueError:
|
|
331
|
+
raise
|
|
235
332
|
except Exception as err:
|
|
236
|
-
self.
|
|
333
|
+
self._handle_error(f"Failed to update issue {issue_key}", err)
|
|
237
334
|
return None
|
|
238
335
|
|
|
239
336
|
def delete_issue(self, issue_key: str) -> bool:
|
|
337
|
+
check_issue_ref(issue_key)
|
|
240
338
|
try:
|
|
241
|
-
issue = self.
|
|
339
|
+
issue = self.jira_client.issue(issue_key)
|
|
242
340
|
issue.delete()
|
|
243
341
|
self.logger.info(f"Issue {issue_key} deleted successfully.")
|
|
244
342
|
return True
|
|
245
|
-
except JIRAError as err:
|
|
246
|
-
self.logger.error(f"JIRAError while deleting issue: {err.text}")
|
|
247
|
-
return False
|
|
248
343
|
except Exception as err:
|
|
249
|
-
self.
|
|
344
|
+
self._handle_error(f"Failed to delete issue {issue_key}", err)
|
|
250
345
|
return False
|
|
251
346
|
|
|
252
347
|
def add_comment(self, issue: str, comment: str) -> None:
|
|
348
|
+
check_issue_ref(issue)
|
|
253
349
|
self.jira_client.add_comment(issue=issue, body=comment)
|
|
254
350
|
self.logger.debug(f"Comment added to issue {issue} successfully.")
|
|
255
351
|
|
|
352
|
+
def get_link_types(self) -> list[dict[str, str]]:
|
|
353
|
+
"""Return the instance's issue link types as `{name, inward, outward}` dicts."""
|
|
354
|
+
return [
|
|
355
|
+
{"name": link_type.name, "inward": link_type.inward, "outward": link_type.outward}
|
|
356
|
+
for link_type in self.jira_client.issue_link_types()
|
|
357
|
+
]
|
|
358
|
+
|
|
256
359
|
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()
|
|
360
|
+
return {link_type["name"] for link_type in self.get_link_types()}
|
|
262
361
|
|
|
263
362
|
def create_link(self, issue_key: str, link_type: str, in_issue_key: str) -> bool:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
363
|
+
check_issue_ref(issue_key)
|
|
364
|
+
check_issue_ref(in_issue_key)
|
|
365
|
+
try:
|
|
366
|
+
available_link_types = self._get_available_link_types()
|
|
367
|
+
except Exception as err:
|
|
368
|
+
self._handle_error("Failed to retrieve link types from JIRA", err)
|
|
369
|
+
return False
|
|
370
|
+
if (canonical := {name.lower(): name for name in available_link_types}.get(link_type.lower())) is None:
|
|
371
|
+
raise ValueError(f"Invalid link type '{link_type}'. Available link types: {sorted(available_link_types)}")
|
|
267
372
|
try:
|
|
268
373
|
self.jira_client.create_issue_link(
|
|
269
|
-
type=
|
|
374
|
+
type=canonical,
|
|
270
375
|
inwardIssue=issue_key,
|
|
271
376
|
outwardIssue=in_issue_key,
|
|
272
377
|
)
|
|
273
378
|
self.logger.info(f"Link between {issue_key} and {in_issue_key} was created")
|
|
274
379
|
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
380
|
except Exception as err:
|
|
279
|
-
self.
|
|
381
|
+
self._handle_error(f"Link between {issue_key} and {in_issue_key} was not created", err)
|
|
280
382
|
return False
|
|
281
383
|
|
|
282
|
-
def upload_attachment(self, issue_key: str, file_path: str) ->
|
|
384
|
+
def upload_attachment(self, issue_key: str, file_path: str) -> bool:
|
|
385
|
+
check_issue_ref(issue_key)
|
|
283
386
|
try:
|
|
284
387
|
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}")
|
|
388
|
+
return True
|
|
287
389
|
except Exception as err:
|
|
288
|
-
self.
|
|
390
|
+
self._handle_error(f"Attachment {file_path} was not uploaded to issue {issue_key}", err)
|
|
391
|
+
return False
|
|
392
|
+
|
|
393
|
+
def _fetch_transitions(self, issue_key: str) -> list[dict]:
|
|
394
|
+
"""Single place that fetches transitions; `fields` is expanded so callers can inspect the screen."""
|
|
395
|
+
return self.jira_client.transitions(issue_key, expand="transitions.fields")
|
|
396
|
+
|
|
397
|
+
def get_transitions(self, issue_key: str) -> list[dict[str, str]]:
|
|
398
|
+
"""Return the transitions currently available for an issue as `{id, name}` dicts."""
|
|
399
|
+
check_issue_ref(issue_key)
|
|
400
|
+
return [{"id": t["id"], "name": t["name"]} for t in self._fetch_transitions(issue_key)]
|
|
401
|
+
|
|
402
|
+
def _resolve_transition(self, issue_key: str, transition_name: str) -> dict:
|
|
403
|
+
transitions = self._fetch_transitions(issue_key)
|
|
404
|
+
if transition := next((t for t in transitions if t["name"].lower() == transition_name.lower()), None):
|
|
405
|
+
return transition
|
|
406
|
+
raise ValueError(
|
|
407
|
+
f"Transition '{transition_name}' not found for issue {issue_key}. "
|
|
408
|
+
f"Available transitions: {sorted(t['name'] for t in transitions)}"
|
|
409
|
+
)
|
|
289
410
|
|
|
290
411
|
def transition_issue(
|
|
291
412
|
self,
|
|
@@ -294,27 +415,34 @@ class JiraBase(ABC):
|
|
|
294
415
|
fields: dict | None = None,
|
|
295
416
|
comment: str | None = None,
|
|
296
417
|
) -> bool:
|
|
418
|
+
"""Transition an issue by name (case-insensitive), optionally with a comment.
|
|
419
|
+
|
|
420
|
+
Jira silently drops a transition comment when the transition screen has no comment field,
|
|
421
|
+
so in that case the comment is added separately after the transition succeeds.
|
|
422
|
+
"""
|
|
423
|
+
check_issue_ref(issue_key)
|
|
424
|
+
needs_separate_comment = False
|
|
297
425
|
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
|
|
426
|
+
transition = self._resolve_transition(issue_key, transition_name)
|
|
427
|
+
needs_separate_comment = bool(comment) and "comment" not in transition.get("fields", {})
|
|
310
428
|
self.jira_client.transition_issue(
|
|
311
|
-
issue=issue_key,
|
|
429
|
+
issue=issue_key,
|
|
430
|
+
transition=transition["id"],
|
|
431
|
+
fields=fields or {},
|
|
432
|
+
comment=None if needs_separate_comment else comment or None,
|
|
312
433
|
)
|
|
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
434
|
except Exception as err:
|
|
319
|
-
self.
|
|
435
|
+
self._handle_error(f"Failed to transition {issue_key} to '{transition_name}'", err)
|
|
320
436
|
return False
|
|
437
|
+
self.logger.info(f"Issue {issue_key} transitioned to '{transition_name}'")
|
|
438
|
+
if needs_separate_comment:
|
|
439
|
+
self._comment_after_transition(issue_key, comment)
|
|
440
|
+
return True
|
|
441
|
+
|
|
442
|
+
def _comment_after_transition(self, issue_key: str, comment: str | None) -> None:
|
|
443
|
+
"""The transition already happened, so a comment failure is reported but does not undo its success."""
|
|
444
|
+
try:
|
|
445
|
+
if comment:
|
|
446
|
+
self.add_comment(issue=issue_key, comment=comment)
|
|
447
|
+
except Exception as err:
|
|
448
|
+
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.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
|
|
@@ -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,13 @@
|
|
|
1
|
+
python_jira_plus/__init__.py,sha256=IwvlvL8KpeiSyeFh_DKLAqlGcHfZk7JINVUAGFRdYTo,801
|
|
2
|
+
python_jira_plus/consts.py,sha256=YkuTHnXbP7JFC-u-7O3Kq_xf1I0x5djZuWgA5D8eDzw,669
|
|
3
|
+
python_jira_plus/describe_allowed_value.py,sha256=fWfq0DkvJ2zmNIUXPH5rO5OgcY69bUqBIfTPQyxBt5E,1287
|
|
4
|
+
python_jira_plus/helpers.py,sha256=DZybIN1zoW2QtT9WXJ2T45Lphtmym-GfG6gHOrT-AHI,686
|
|
5
|
+
python_jira_plus/jira_base.py,sha256=SEObK7QW-Z6YNy4H7XXylagSNNLienZp0Nedopr1TS0,20143
|
|
6
|
+
python_jira_plus/jira_cloud.py,sha256=CfkJTgf6j3wu7AnUI6BnykHRIL0XhDMtk0PejMiAsjg,3208
|
|
7
|
+
python_jira_plus/jira_on_premise.py,sha256=8GDUDzJ6FYQQgSjbZs01cP9WBvplU5myIG-x6Ykw0hs,3240
|
|
8
|
+
python_jira_plus/types.py,sha256=KxMR3Iv1ztmZjr9nhkdTpiyB9x7Mu1hrwDBBUuWdOi8,164
|
|
9
|
+
python_jira_plus-4.1.0.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
|
|
10
|
+
python_jira_plus-4.1.0.dist-info/METADATA,sha256=OnG9XYFlZnIWSsYGX1oypuHWpqXQWlUlXIJKSCCPJA0,4836
|
|
11
|
+
python_jira_plus-4.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
12
|
+
python_jira_plus-4.1.0.dist-info/top_level.txt,sha256=3DxBdmf_naggoqDHSvLstG_GX3bW2XXYYorBDybBUiA,17
|
|
13
|
+
python_jira_plus-4.1.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
|