python-jira-plus 3.2.0__tar.gz → 4.0.0__tar.gz
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-3.2.0/python_jira_plus.egg-info → python_jira_plus-4.0.0}/PKG-INFO +23 -15
- {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/README.md +22 -9
- {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/pyproject.toml +3 -2
- {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/python_jira_plus/__init__.py +1 -5
- python_jira_plus-4.0.0/python_jira_plus/consts.py +21 -0
- python_jira_plus-4.0.0/python_jira_plus/jira_base.py +421 -0
- python_jira_plus-4.0.0/python_jira_plus/jira_cloud.py +87 -0
- {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/python_jira_plus/jira_on_premise.py +27 -36
- {python_jira_plus-3.2.0 → python_jira_plus-4.0.0/python_jira_plus.egg-info}/PKG-INFO +23 -15
- {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/python_jira_plus.egg-info/SOURCES.txt +3 -1
- {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/python_jira_plus.egg-info/requires.txt +0 -6
- python_jira_plus-4.0.0/tests/test_jira_cloud_write_integration.py +451 -0
- {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/tests/test_jira_plus.py +0 -18
- python_jira_plus-4.0.0/tests/test_jira_plus_hardening.py +334 -0
- python_jira_plus-3.2.0/python_jira_plus/consts.py +0 -1
- python_jira_plus-3.2.0/python_jira_plus/jira_base.py +0 -320
- python_jira_plus-3.2.0/python_jira_plus/jira_cloud.py +0 -109
- {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/LICENSE +0 -0
- {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/MANIFEST.in +0 -0
- {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/python_jira_plus/describe_allowed_value.py +0 -0
- {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/python_jira_plus.egg-info/dependency_links.txt +0 -0
- {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/python_jira_plus.egg-info/top_level.txt +0 -0
- {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/setup.cfg +0 -0
- {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/tests/test_describe_allowed_value.py +0 -0
- {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/tests/test_jira_cloud_integration.py +0 -0
|
@@ -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 = {
|
|
@@ -14,7 +14,7 @@ An enhanced Python client for JIRA that extends the functionality of the officia
|
|
|
14
14
|
|
|
15
15
|
## Features
|
|
16
16
|
- ✅ Simplified connection to JIRA Cloud and On-Premise instances
|
|
17
|
-
- ✅
|
|
17
|
+
- ✅ 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
|
|
18
18
|
- ✅ Built-in pagination for large result sets
|
|
19
19
|
- ✅ Field validation against JIRA metadata
|
|
20
20
|
- ✅ Enhanced issue creation, retrieval, and updating
|
|
@@ -32,7 +32,6 @@ pip install python-jira-plus
|
|
|
32
32
|
## Requirements
|
|
33
33
|
- Python 3.12+
|
|
34
34
|
- `jira` package
|
|
35
|
-
- `retrying` package
|
|
36
35
|
- `custom-python-logger` package
|
|
37
36
|
|
|
38
37
|
---
|
|
@@ -47,13 +46,26 @@ JIRA_TOKEN=your_jira_api_token
|
|
|
47
46
|
JIRA_BASE_URL=your-instance.atlassian.net # Only used if base_url is not provided to constructor
|
|
48
47
|
```
|
|
49
48
|
|
|
49
|
+
## Error handling
|
|
50
|
+
By default, write helpers (`delete_issue`, `create_link`, `transition_issue`, `upload_attachment`,
|
|
51
|
+
`update_issue`) and `get_issue_by_key` log the error and return `False`/`None`. Pass
|
|
52
|
+
`raise_on_error=True` to the constructor to get the original exception instead:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
jira_client = JiraCloud(raise_on_error=True)
|
|
56
|
+
jira_client.get_issue_by_key("PROJ-404") # raises JIRAError instead of returning None
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Validation problems (`ValueError`) and failed searches (`JIRAError`) are always raised.
|
|
60
|
+
Rate limits (429) and 503 are retried by `jira-python`'s own session, not by this package.
|
|
61
|
+
|
|
50
62
|
## Examples
|
|
51
63
|
|
|
52
64
|
### Creating an Issue with Custom Fields
|
|
53
65
|
```python
|
|
54
|
-
from python_jira_plus
|
|
66
|
+
from python_jira_plus import JiraCloud # or JiraOnPremise
|
|
55
67
|
|
|
56
|
-
jira_client =
|
|
68
|
+
jira_client = JiraCloud()
|
|
57
69
|
issue = jira_client.create_issue(
|
|
58
70
|
project_key="PROJ",
|
|
59
71
|
summary="Implement new feature",
|
|
@@ -68,12 +80,13 @@ issue = jira_client.create_issue(
|
|
|
68
80
|
|
|
69
81
|
### Searching for Issues
|
|
70
82
|
```python
|
|
71
|
-
from python_jira_plus
|
|
83
|
+
from python_jira_plus import JiraCloud
|
|
72
84
|
|
|
73
|
-
jira_client =
|
|
85
|
+
jira_client = JiraCloud()
|
|
74
86
|
issues = jira_client.get_objects_by_query(
|
|
75
87
|
query="project = PROJ AND status = 'In Progress' ORDER BY created DESC",
|
|
76
|
-
max_results=50,
|
|
88
|
+
max_results=50, # page size
|
|
89
|
+
limit=200, # optional cap on the total number of issues returned
|
|
77
90
|
specific_fields=["summary", "status", "assignee"],
|
|
78
91
|
json_result=False
|
|
79
92
|
)
|
|
@@ -84,9 +97,9 @@ for issue in issues:
|
|
|
84
97
|
|
|
85
98
|
### Updating an Issue
|
|
86
99
|
```python
|
|
87
|
-
from python_jira_plus
|
|
100
|
+
from python_jira_plus import JiraCloud
|
|
88
101
|
|
|
89
|
-
jira_client =
|
|
102
|
+
jira_client = JiraCloud()
|
|
90
103
|
issue = jira_client.get_issue_by_key(key="PROJ-123", json_result=False)
|
|
91
104
|
|
|
92
105
|
fields_to_update = {
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-jira-plus"
|
|
7
|
-
version = "
|
|
7
|
+
version = "4.0.0"
|
|
8
8
|
description = "Enhanced Python client for JIRA with better error handling, pagination, and metadata validation."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -28,9 +28,10 @@ dependencies = [
|
|
|
28
28
|
"requests>=2.32.5",
|
|
29
29
|
]
|
|
30
30
|
|
|
31
|
-
[
|
|
31
|
+
[dependency-groups]
|
|
32
32
|
dev = [
|
|
33
33
|
"pre-commit>=4.5.0",
|
|
34
|
+
"python-base-toolkit>=1.1.2",
|
|
34
35
|
"pytest>=9.0.1",
|
|
35
36
|
"setuptools>=80.9.0",
|
|
36
37
|
"wheel>=0.45.1",
|
|
@@ -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"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import NamedTuple
|
|
3
|
+
|
|
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
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
# jira_base.py
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from custom_python_logger import get_logger
|
|
11
|
+
from dotenv import load_dotenv
|
|
12
|
+
from jira import JIRA, Issue, JIRAError
|
|
13
|
+
from jira.client import ResultList
|
|
14
|
+
|
|
15
|
+
from python_jira_plus.consts import (
|
|
16
|
+
ALLOWED_VALUE_KEYS,
|
|
17
|
+
FIELD_TYPE_CHECKS,
|
|
18
|
+
LOGGER_NAME,
|
|
19
|
+
SCHEME_PATTERN,
|
|
20
|
+
)
|
|
21
|
+
from python_jira_plus.describe_allowed_value import describe_allowed_value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UrlScheme(Enum):
|
|
25
|
+
HTTP = "http"
|
|
26
|
+
HTTPS = "https"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JiraBase(ABC): # pylint: disable=too-many-instance-attributes
|
|
30
|
+
use_createmeta: bool
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
base_url: str | None = None,
|
|
35
|
+
urllib3_log_level: int = logging.WARNING,
|
|
36
|
+
jira_username: str | None = None,
|
|
37
|
+
jira_token: str | None = None,
|
|
38
|
+
verify_ssl: bool = True,
|
|
39
|
+
url_scheme: UrlScheme = UrlScheme.HTTPS,
|
|
40
|
+
raise_on_error: bool = False,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.logger = get_logger(LOGGER_NAME)
|
|
43
|
+
get_logger("urllib3").setLevel(urllib3_log_level)
|
|
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
|
|
47
|
+
self.jira_username = jira_username or os.getenv("JIRA_USER_NAME")
|
|
48
|
+
self.jira_token = jira_token or os.getenv("JIRA_TOKEN")
|
|
49
|
+
self.base_url, self.url_scheme = self._parse_base_url(base_url or os.getenv("JIRA_BASE_URL"), url_scheme)
|
|
50
|
+
self.server = f"{self.url_scheme}://{self.base_url}"
|
|
51
|
+
self.verify_ssl = verify_ssl
|
|
52
|
+
self.raise_on_error = raise_on_error
|
|
53
|
+
self._validate_credentials()
|
|
54
|
+
|
|
55
|
+
self.jira_client = self.create_connection()
|
|
56
|
+
self.server_version = self.get_server_version()
|
|
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
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def create_connection(self, timeout: int = 580) -> JIRA: ...
|
|
81
|
+
|
|
82
|
+
@abstractmethod
|
|
83
|
+
def _paginate(
|
|
84
|
+
self,
|
|
85
|
+
query: str,
|
|
86
|
+
max_results: int,
|
|
87
|
+
specific_fields: str | list[str],
|
|
88
|
+
json_result: bool,
|
|
89
|
+
limit: int | None = None,
|
|
90
|
+
) -> ResultList[Issue] | list: ...
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def _format_assignee(self, assignee: str) -> dict: ...
|
|
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
|
+
|
|
101
|
+
def close_connection(self) -> None:
|
|
102
|
+
try:
|
|
103
|
+
self.jira_client.close()
|
|
104
|
+
except JIRAError as err:
|
|
105
|
+
self.logger.exception(f"JIRAError \n{err}")
|
|
106
|
+
except Exception as err:
|
|
107
|
+
self.logger.exception(f"Exception \n{err}")
|
|
108
|
+
|
|
109
|
+
def check_client_connection(self) -> bool:
|
|
110
|
+
try:
|
|
111
|
+
if self.jira_client.projects():
|
|
112
|
+
return True
|
|
113
|
+
self.logger.info("Jira client is closed")
|
|
114
|
+
return False
|
|
115
|
+
except JIRAError as err:
|
|
116
|
+
self.logger.exception(f"JIRAError \n{err}")
|
|
117
|
+
return False
|
|
118
|
+
except Exception as err:
|
|
119
|
+
self.logger.exception(f"Exception \n{err}")
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
def get_server_version(self) -> tuple[int, ...]:
|
|
123
|
+
return tuple(self.jira_client.server_info()["versionNumbers"])
|
|
124
|
+
|
|
125
|
+
def get_issue_by_key(
|
|
126
|
+
self, key: str, specific_fields: str | list[str] = "*all", json_result: bool = True
|
|
127
|
+
) -> None | Issue | dict[str, Any]:
|
|
128
|
+
try:
|
|
129
|
+
jira_issue = self.jira_client.issue(id=key, fields=specific_fields)
|
|
130
|
+
if json_result:
|
|
131
|
+
return jira_issue.raw
|
|
132
|
+
return jira_issue
|
|
133
|
+
except Exception as err:
|
|
134
|
+
self._handle_error(f"Failed to get issue {key}", err)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
def get_objects_by_query(
|
|
138
|
+
self,
|
|
139
|
+
query: str,
|
|
140
|
+
max_results: int = 5000,
|
|
141
|
+
specific_fields: str | list[str] = "*all",
|
|
142
|
+
json_result: bool = True,
|
|
143
|
+
limit: int | None = None,
|
|
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
|
+
"""
|
|
157
|
+
return self._paginate(
|
|
158
|
+
query=query,
|
|
159
|
+
max_results=max_results,
|
|
160
|
+
specific_fields=specific_fields,
|
|
161
|
+
json_result=json_result,
|
|
162
|
+
limit=limit,
|
|
163
|
+
)
|
|
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
|
+
|
|
172
|
+
def _fetch_via_createmeta(self, project_key: str, issue_type: str) -> dict:
|
|
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:
|
|
181
|
+
raise ValueError(f"Project {project_key} not found in metadata")
|
|
182
|
+
if issue_meta is None:
|
|
183
|
+
raise ValueError(f"Issue type '{issue_type}' not found in project '{project_key}'")
|
|
184
|
+
return issue_meta["fields"]
|
|
185
|
+
|
|
186
|
+
def _fetch_via_issue_types_api(self, project_key: str, issue_type: str) -> dict:
|
|
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)
|
|
194
|
+
return {f.fieldId: f.raw for f in field_list}
|
|
195
|
+
|
|
196
|
+
def get_project_fields_metadata(self, project_key: str, issue_type: str) -> dict:
|
|
197
|
+
if self.use_createmeta:
|
|
198
|
+
return self._fetch_via_createmeta(project_key=project_key, issue_type=issue_type)
|
|
199
|
+
return self._fetch_via_issue_types_api(project_key=project_key, issue_type=issue_type)
|
|
200
|
+
|
|
201
|
+
def get_allowed_values(
|
|
202
|
+
self,
|
|
203
|
+
project_key: str,
|
|
204
|
+
issue_type: str,
|
|
205
|
+
field_id_or_name: str,
|
|
206
|
+
) -> list | None:
|
|
207
|
+
"""Returns the allowed values for a specific field in a project + issue type context."""
|
|
208
|
+
fields_metadata = self.get_project_fields_metadata(project_key=project_key, issue_type=issue_type)
|
|
209
|
+
if not (field := fields_metadata.get(field_id_or_name)):
|
|
210
|
+
for _, fmeta in fields_metadata.items():
|
|
211
|
+
if fmeta["name"].lower() == field_id_or_name.lower():
|
|
212
|
+
field = fmeta
|
|
213
|
+
break
|
|
214
|
+
if not field:
|
|
215
|
+
raise ValueError(
|
|
216
|
+
f"Field '{field_id_or_name}' not found in project '{project_key}' with issue type '{issue_type}'"
|
|
217
|
+
)
|
|
218
|
+
if not (allowed := field.get("allowedValues")):
|
|
219
|
+
self.logger.info(f"No allowed values found for field '{field_id_or_name}'")
|
|
220
|
+
return None
|
|
221
|
+
return allowed
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _is_value_allowed(value: Any, allowed_values: list[Any]) -> bool:
|
|
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)
|
|
228
|
+
|
|
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
|
+
):
|
|
242
|
+
raise ValueError(
|
|
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)}"
|
|
245
|
+
)
|
|
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}
|
|
271
|
+
|
|
272
|
+
def create_issue(
|
|
273
|
+
self,
|
|
274
|
+
project_key: str,
|
|
275
|
+
summary: str,
|
|
276
|
+
description: str,
|
|
277
|
+
issue_type: str,
|
|
278
|
+
assignee: str | None = None,
|
|
279
|
+
custom_fields: dict | None = None,
|
|
280
|
+
) -> Issue | None:
|
|
281
|
+
issue_fields: dict[str, Any] = {
|
|
282
|
+
"project": {"key": project_key},
|
|
283
|
+
"issuetype": {"name": issue_type},
|
|
284
|
+
"summary": summary,
|
|
285
|
+
"description": description,
|
|
286
|
+
}
|
|
287
|
+
if assignee:
|
|
288
|
+
issue_fields["assignee"] = self._format_assignee(assignee)
|
|
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)
|
|
293
|
+
issue = self.jira_client.create_issue(fields=issue_fields)
|
|
294
|
+
self.logger.info(f"Issue {issue.key} created successfully.")
|
|
295
|
+
return issue
|
|
296
|
+
|
|
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
|
+
"""
|
|
303
|
+
try:
|
|
304
|
+
issue = self.jira_client.issue(issue_key)
|
|
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}"')
|
|
307
|
+
issue.update(fields=fields_to_update)
|
|
308
|
+
self.logger.info(f"Issue {issue.key} updated successfully.")
|
|
309
|
+
return issue
|
|
310
|
+
except ValueError:
|
|
311
|
+
raise
|
|
312
|
+
except Exception as err:
|
|
313
|
+
self._handle_error(f"Failed to update issue {issue_key}", err)
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
def delete_issue(self, issue_key: str) -> bool:
|
|
317
|
+
try:
|
|
318
|
+
issue = self.jira_client.issue(issue_key)
|
|
319
|
+
issue.delete()
|
|
320
|
+
self.logger.info(f"Issue {issue_key} deleted successfully.")
|
|
321
|
+
return True
|
|
322
|
+
except Exception as err:
|
|
323
|
+
self._handle_error(f"Failed to delete issue {issue_key}", err)
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
def add_comment(self, issue: str, comment: str) -> None:
|
|
327
|
+
self.jira_client.add_comment(issue=issue, body=comment)
|
|
328
|
+
self.logger.debug(f"Comment added to issue {issue} successfully.")
|
|
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
|
+
|
|
337
|
+
def _get_available_link_types(self) -> set[str]:
|
|
338
|
+
return {link_type["name"] for link_type in self.get_link_types()}
|
|
339
|
+
|
|
340
|
+
def create_link(self, issue_key: str, link_type: str, in_issue_key: str) -> bool:
|
|
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)}")
|
|
348
|
+
try:
|
|
349
|
+
self.jira_client.create_issue_link(
|
|
350
|
+
type=canonical,
|
|
351
|
+
inwardIssue=issue_key,
|
|
352
|
+
outwardIssue=in_issue_key,
|
|
353
|
+
)
|
|
354
|
+
self.logger.info(f"Link between {issue_key} and {in_issue_key} was created")
|
|
355
|
+
return True
|
|
356
|
+
except Exception as err:
|
|
357
|
+
self._handle_error(f"Link between {issue_key} and {in_issue_key} was not created", err)
|
|
358
|
+
return False
|
|
359
|
+
|
|
360
|
+
def upload_attachment(self, issue_key: str, file_path: str) -> bool:
|
|
361
|
+
try:
|
|
362
|
+
self.jira_client.add_attachment(issue=issue_key, attachment=file_path)
|
|
363
|
+
return True
|
|
364
|
+
except Exception as err:
|
|
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
|
+
)
|
|
384
|
+
|
|
385
|
+
def transition_issue(
|
|
386
|
+
self,
|
|
387
|
+
issue_key: str,
|
|
388
|
+
transition_name: str,
|
|
389
|
+
fields: dict | None = None,
|
|
390
|
+
comment: str | None = None,
|
|
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
|
|
398
|
+
try:
|
|
399
|
+
transition = self._resolve_transition(issue_key, transition_name)
|
|
400
|
+
needs_separate_comment = bool(comment) and "comment" not in transition.get("fields", {})
|
|
401
|
+
self.jira_client.transition_issue(
|
|
402
|
+
issue=issue_key,
|
|
403
|
+
transition=transition["id"],
|
|
404
|
+
fields=fields or {},
|
|
405
|
+
comment=None if needs_separate_comment else comment or None,
|
|
406
|
+
)
|
|
407
|
+
except Exception as err:
|
|
408
|
+
self._handle_error(f"Failed to transition {issue_key} to '{transition_name}'", err)
|
|
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)
|