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.
Files changed (25) hide show
  1. {python_jira_plus-3.2.0/python_jira_plus.egg-info → python_jira_plus-4.0.0}/PKG-INFO +23 -15
  2. {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/README.md +22 -9
  3. {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/pyproject.toml +3 -2
  4. {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/python_jira_plus/__init__.py +1 -5
  5. python_jira_plus-4.0.0/python_jira_plus/consts.py +21 -0
  6. python_jira_plus-4.0.0/python_jira_plus/jira_base.py +421 -0
  7. python_jira_plus-4.0.0/python_jira_plus/jira_cloud.py +87 -0
  8. {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/python_jira_plus/jira_on_premise.py +27 -36
  9. {python_jira_plus-3.2.0 → python_jira_plus-4.0.0/python_jira_plus.egg-info}/PKG-INFO +23 -15
  10. {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/python_jira_plus.egg-info/SOURCES.txt +3 -1
  11. {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/python_jira_plus.egg-info/requires.txt +0 -6
  12. python_jira_plus-4.0.0/tests/test_jira_cloud_write_integration.py +451 -0
  13. {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/tests/test_jira_plus.py +0 -18
  14. python_jira_plus-4.0.0/tests/test_jira_plus_hardening.py +334 -0
  15. python_jira_plus-3.2.0/python_jira_plus/consts.py +0 -1
  16. python_jira_plus-3.2.0/python_jira_plus/jira_base.py +0 -320
  17. python_jira_plus-3.2.0/python_jira_plus/jira_cloud.py +0 -109
  18. {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/LICENSE +0 -0
  19. {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/MANIFEST.in +0 -0
  20. {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/python_jira_plus/describe_allowed_value.py +0 -0
  21. {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/python_jira_plus.egg-info/dependency_links.txt +0 -0
  22. {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/python_jira_plus.egg-info/top_level.txt +0 -0
  23. {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/setup.cfg +0 -0
  24. {python_jira_plus-3.2.0 → python_jira_plus-4.0.0}/tests/test_describe_allowed_value.py +0 -0
  25. {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.2.0
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
  ![PyPI version](https://img.shields.io/pypi/v/python-jira-plus)
@@ -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
- - ✅ Robust error handling with automatic retries
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.jira_plus import JiraPlus
91
+ from python_jira_plus import JiraCloud # or JiraOnPremise
85
92
 
86
- jira_client = JiraPlus()
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.jira_plus import JiraPlus
108
+ from python_jira_plus import JiraCloud
102
109
 
103
- jira_client = JiraPlus()
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.jira_plus import JiraPlus
125
+ from python_jira_plus import JiraCloud
118
126
 
119
- jira_client = JiraPlus()
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
- - ✅ Robust error handling with automatic retries
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.jira_plus import JiraPlus
66
+ from python_jira_plus import JiraCloud # or JiraOnPremise
55
67
 
56
- jira_client = JiraPlus()
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.jira_plus import JiraPlus
83
+ from python_jira_plus import JiraCloud
72
84
 
73
- jira_client = JiraPlus()
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.jira_plus import JiraPlus
100
+ from python_jira_plus import JiraCloud
88
101
 
89
- jira_client = JiraPlus()
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 = "3.2.0"
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
- [project.optional-dependencies]
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)