runspec-jira-core 0.1.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.
- runspec_jira_core-0.1.0/.gitignore +61 -0
- runspec_jira_core-0.1.0/CHANGELOG.md +26 -0
- runspec_jira_core-0.1.0/PKG-INFO +10 -0
- runspec_jira_core-0.1.0/pyproject.toml +32 -0
- runspec_jira_core-0.1.0/runspec_jira_core/__init__.py +113 -0
- runspec_jira_core-0.1.0/runspec_jira_core/client.py +100 -0
- runspec_jira_core-0.1.0/runspec_jira_core/comments.py +28 -0
- runspec_jira_core-0.1.0/runspec_jira_core/config.py +113 -0
- runspec_jira_core-0.1.0/runspec_jira_core/errors.py +13 -0
- runspec_jira_core-0.1.0/runspec_jira_core/fields.py +19 -0
- runspec_jira_core-0.1.0/runspec_jira_core/issues.py +77 -0
- runspec_jira_core-0.1.0/runspec_jira_core/links.py +95 -0
- runspec_jira_core-0.1.0/runspec_jira_core/projects.py +41 -0
- runspec_jira_core-0.1.0/runspec_jira_core/search.py +31 -0
- runspec_jira_core-0.1.0/runspec_jira_core/servicedesk.py +74 -0
- runspec_jira_core-0.1.0/runspec_jira_core/transitions.py +32 -0
- runspec_jira_core-0.1.0/runspec_jira_core/users.py +43 -0
- runspec_jira_core-0.1.0/runspec_jira_core/watchers.py +43 -0
- runspec_jira_core-0.1.0/runspec_jira_core/worklog.py +38 -0
- runspec_jira_core-0.1.0/tests/__init__.py +0 -0
- runspec_jira_core-0.1.0/tests/conftest.py +86 -0
- runspec_jira_core-0.1.0/tests/test_config.py +97 -0
- runspec_jira_core-0.1.0/tests/test_ops.py +213 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.egg
|
|
8
|
+
*.egg-info/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
.eggs/
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
env/
|
|
15
|
+
.env
|
|
16
|
+
pip-wheel-metadata/
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
htmlcov/
|
|
21
|
+
.coverage
|
|
22
|
+
coverage.xml
|
|
23
|
+
*.cover
|
|
24
|
+
|
|
25
|
+
# Node
|
|
26
|
+
node_modules/
|
|
27
|
+
dist/
|
|
28
|
+
*.js.map
|
|
29
|
+
.npm
|
|
30
|
+
|
|
31
|
+
# Go
|
|
32
|
+
*.exe
|
|
33
|
+
*.test
|
|
34
|
+
*.out
|
|
35
|
+
vendor/
|
|
36
|
+
|
|
37
|
+
# IDE
|
|
38
|
+
.idea/
|
|
39
|
+
.vscode/
|
|
40
|
+
*.iml
|
|
41
|
+
*.iws
|
|
42
|
+
*.ipr
|
|
43
|
+
.DS_Store
|
|
44
|
+
Thumbs.db
|
|
45
|
+
|
|
46
|
+
# Docs
|
|
47
|
+
site/
|
|
48
|
+
|
|
49
|
+
# Misc
|
|
50
|
+
*.log
|
|
51
|
+
*.tmp
|
|
52
|
+
|
|
53
|
+
# External reference repos (cloned locally, not committed)
|
|
54
|
+
chainlit-docs/
|
|
55
|
+
.chainlit/
|
|
56
|
+
|
|
57
|
+
# Claude Code local config (machine-specific)
|
|
58
|
+
.claude/launch.json
|
|
59
|
+
|
|
60
|
+
# Stray committed test venv (removed from tracking)
|
|
61
|
+
.venv-test/
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `runspec-jira-core` are documented here.
|
|
4
|
+
|
|
5
|
+
## [0.1.0] — 2026-08-07
|
|
6
|
+
|
|
7
|
+
Initial release. Pure-Python Jira Software/Core + Service Desk helpers over
|
|
8
|
+
`atlassian-python-api` — the importable core behind `runspec-jira`. No `runspec`
|
|
9
|
+
dependency, no `runspec.toml`, no entry points (surfaces zero runnables).
|
|
10
|
+
|
|
11
|
+
- `JiraConfig` + `config_from_env(env=None)` — env-var auth (`JIRA_URL`,
|
|
12
|
+
`JIRA_USERNAME`/`JIRA_API_TOKEN` for Cloud/DC basic, `JIRA_PERSONAL_TOKEN` for
|
|
13
|
+
DC PAT) with `JIRA_<ENV>_*` prefixes selected by `--env dev|uat|prod`. Cloud vs
|
|
14
|
+
Server/Data Center resolved via `JIRA_CLOUD` or a `.atlassian.net` host.
|
|
15
|
+
- `build_client` / `build_servicedesk` construct `atlassian.Jira` /
|
|
16
|
+
`atlassian.ServiceDesk`, picking exactly one auth mode.
|
|
17
|
+
- Operation helpers grouped by area: issues (get/create/update/assign/delete),
|
|
18
|
+
search (`jql_search`, `get_project_issues`), transitions, comments, links
|
|
19
|
+
(issue links, epic link, remote link), worklog, projects (discovery +
|
|
20
|
+
create-field metadata), users, field discovery, watchers, and service desk (request
|
|
21
|
+
types, request-type fields, create customer request, get request).
|
|
22
|
+
- `translate_errors` surfaces the SDK's `requests` exceptions as `JiraError`
|
|
23
|
+
carrying Jira's own error message; `parse_json_arg` parses JSON-string args.
|
|
24
|
+
|
|
25
|
+
Deferred: attachments/media, images, OAuth 2.0, transition-by-id with screen
|
|
26
|
+
fields, cross-project issue move.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runspec-jira-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pure-Python Jira + Service Desk helpers (over atlassian-python-api) — the importable core behind runspec-jira (no runspec dependency, no runnables)
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: atlassian-python-api>=3.41
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
9
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
10
|
+
Requires-Dist: ruff==0.15.20; extra == 'dev'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runspec-jira-core"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
description = "Pure-Python Jira + Service Desk helpers (over atlassian-python-api) — the importable core behind runspec-jira (no runspec dependency, no runnables)"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"atlassian-python-api>=3.41",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = [
|
|
16
|
+
"ruff==0.15.20",
|
|
17
|
+
"mypy",
|
|
18
|
+
"pytest>=8.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
testpaths = ["tests"]
|
|
23
|
+
|
|
24
|
+
[tool.mypy]
|
|
25
|
+
python_version = "3.10"
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
line-length = 200
|
|
29
|
+
target-version = "py310"
|
|
30
|
+
|
|
31
|
+
[tool.ruff.lint]
|
|
32
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""runspec-jira-core — Jira Software/Core + Service Desk helpers over atlassian-python-api.
|
|
2
|
+
|
|
3
|
+
This package depends only on ``atlassian-python-api`` — it has **no dependency on
|
|
4
|
+
runspec** and ships **no runspec.toml and no entry points**, so installing it
|
|
5
|
+
exposes the helper functions for import without surfacing any runnables (it is
|
|
6
|
+
invisible to ``runspec local`` / ``runspec serve`` discovery). ``runspec-jira``
|
|
7
|
+
depends on it and wraps each helper as a verb of the ``jira`` runnable.
|
|
8
|
+
|
|
9
|
+
Every operation takes an explicit :class:`JiraConfig` first argument, so a private
|
|
10
|
+
(e.g. Nexus-hosted) wrapper package can inject credentials in code, bake in a
|
|
11
|
+
default ``service_desk_id`` / ``project_key`` / ``env``, and build "one runnable
|
|
12
|
+
per project" on top. Each function returns plain data and raises
|
|
13
|
+
:class:`JiraError` on failure. Cloud and Server/Data Center are both supported.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from runspec_jira_core.client import (
|
|
17
|
+
build_client,
|
|
18
|
+
build_servicedesk,
|
|
19
|
+
parse_json_arg,
|
|
20
|
+
translate_errors,
|
|
21
|
+
)
|
|
22
|
+
from runspec_jira_core.comments import add_comment, edit_comment
|
|
23
|
+
from runspec_jira_core.config import VALID_ENVS, JiraConfig, config_from_env
|
|
24
|
+
from runspec_jira_core.errors import JiraError
|
|
25
|
+
from runspec_jira_core.fields import search_fields
|
|
26
|
+
from runspec_jira_core.issues import (
|
|
27
|
+
assign_issue,
|
|
28
|
+
create_issue,
|
|
29
|
+
delete_issue,
|
|
30
|
+
get_issue,
|
|
31
|
+
update_issue,
|
|
32
|
+
)
|
|
33
|
+
from runspec_jira_core.links import (
|
|
34
|
+
create_issue_link,
|
|
35
|
+
create_remote_issue_link,
|
|
36
|
+
get_link_types,
|
|
37
|
+
link_to_epic,
|
|
38
|
+
remove_issue_link,
|
|
39
|
+
)
|
|
40
|
+
from runspec_jira_core.projects import (
|
|
41
|
+
get_all_projects,
|
|
42
|
+
get_create_fields,
|
|
43
|
+
get_project_components,
|
|
44
|
+
get_project_issue_types,
|
|
45
|
+
search_projects,
|
|
46
|
+
)
|
|
47
|
+
from runspec_jira_core.search import get_project_issues, jql_search
|
|
48
|
+
from runspec_jira_core.servicedesk import (
|
|
49
|
+
create_customer_request,
|
|
50
|
+
get_request,
|
|
51
|
+
list_request_type_fields,
|
|
52
|
+
list_request_types,
|
|
53
|
+
)
|
|
54
|
+
from runspec_jira_core.transitions import list_transitions, transition_issue
|
|
55
|
+
from runspec_jira_core.users import get_user_profile, search_assignable_users
|
|
56
|
+
from runspec_jira_core.watchers import add_watcher, get_watchers, remove_watcher
|
|
57
|
+
from runspec_jira_core.worklog import add_worklog, get_worklog
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
# config / client
|
|
61
|
+
"JiraConfig",
|
|
62
|
+
"JiraError",
|
|
63
|
+
"config_from_env",
|
|
64
|
+
"build_client",
|
|
65
|
+
"build_servicedesk",
|
|
66
|
+
"parse_json_arg",
|
|
67
|
+
"translate_errors",
|
|
68
|
+
"VALID_ENVS",
|
|
69
|
+
# issues
|
|
70
|
+
"get_issue",
|
|
71
|
+
"create_issue",
|
|
72
|
+
"update_issue",
|
|
73
|
+
"assign_issue",
|
|
74
|
+
"delete_issue",
|
|
75
|
+
# search
|
|
76
|
+
"jql_search",
|
|
77
|
+
"get_project_issues",
|
|
78
|
+
# transitions
|
|
79
|
+
"list_transitions",
|
|
80
|
+
"transition_issue",
|
|
81
|
+
# comments
|
|
82
|
+
"add_comment",
|
|
83
|
+
"edit_comment",
|
|
84
|
+
# links
|
|
85
|
+
"get_link_types",
|
|
86
|
+
"create_issue_link",
|
|
87
|
+
"link_to_epic",
|
|
88
|
+
"create_remote_issue_link",
|
|
89
|
+
"remove_issue_link",
|
|
90
|
+
# worklog
|
|
91
|
+
"get_worklog",
|
|
92
|
+
"add_worklog",
|
|
93
|
+
# projects
|
|
94
|
+
"get_all_projects",
|
|
95
|
+
"search_projects",
|
|
96
|
+
"get_project_components",
|
|
97
|
+
"get_project_issue_types",
|
|
98
|
+
"get_create_fields",
|
|
99
|
+
# fields
|
|
100
|
+
"search_fields",
|
|
101
|
+
# users
|
|
102
|
+
"get_user_profile",
|
|
103
|
+
"search_assignable_users",
|
|
104
|
+
# watchers
|
|
105
|
+
"get_watchers",
|
|
106
|
+
"add_watcher",
|
|
107
|
+
"remove_watcher",
|
|
108
|
+
# service desk
|
|
109
|
+
"list_request_types",
|
|
110
|
+
"list_request_type_fields",
|
|
111
|
+
"create_customer_request",
|
|
112
|
+
"get_request",
|
|
113
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""client.py — build ``atlassian-python-api`` clients + shared op helpers.
|
|
2
|
+
|
|
3
|
+
``build_client`` / ``build_servicedesk`` turn a :class:`JiraConfig` into an
|
|
4
|
+
``atlassian.Jira`` / ``atlassian.ServiceDesk`` instance, picking exactly one auth
|
|
5
|
+
mode (Basic when username+api_token are set, else Bearer PAT) so the SDK never
|
|
6
|
+
silently drops the PAT because Basic won.
|
|
7
|
+
|
|
8
|
+
``@translate_errors`` wraps each operation so the SDK's ``requests`` exceptions
|
|
9
|
+
surface as a :class:`JiraError` carrying Jira's own error message. ``parse_json_arg``
|
|
10
|
+
parses the JSON-string arguments (create/update fields, comment visibility,
|
|
11
|
+
worklog, service-desk field values) at the operation boundary.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import functools
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from typing import Any, TypeVar
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
from atlassian import Jira, ServiceDesk
|
|
23
|
+
|
|
24
|
+
from runspec_jira_core.config import JiraConfig
|
|
25
|
+
from runspec_jira_core.errors import JiraError
|
|
26
|
+
|
|
27
|
+
_T = TypeVar("_T")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _auth_kwargs(config: JiraConfig) -> dict[str, Any]:
|
|
31
|
+
base: dict[str, Any] = {"url": config.url, "cloud": config.cloud, "verify_ssl": config.verify_ssl}
|
|
32
|
+
if config.username and config.api_token:
|
|
33
|
+
base.update(username=config.username, password=config.api_token)
|
|
34
|
+
elif config.personal_token:
|
|
35
|
+
base.update(token=config.personal_token)
|
|
36
|
+
else:
|
|
37
|
+
raise JiraError("JiraConfig has no usable credentials (need personal_token or username+api_token)")
|
|
38
|
+
return base
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_client(config: JiraConfig) -> Jira:
|
|
42
|
+
"""Construct an ``atlassian.Jira`` client for ``config``."""
|
|
43
|
+
return Jira(**_auth_kwargs(config))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_servicedesk(config: JiraConfig) -> ServiceDesk:
|
|
47
|
+
"""Construct an ``atlassian.ServiceDesk`` client for ``config`` (JSM verbs)."""
|
|
48
|
+
return ServiceDesk(**_auth_kwargs(config))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_json_arg(name: str, raw: str | None) -> Any:
|
|
52
|
+
"""Parse a JSON-string CLI argument, or return ``None`` when unset.
|
|
53
|
+
|
|
54
|
+
Raises :class:`JiraError` (not a bare ``JSONDecodeError``) so the wrapper's
|
|
55
|
+
fixed catch tuple reports it as a clean ``{"error": ...}`` payload.
|
|
56
|
+
"""
|
|
57
|
+
if raw is None or raw == "":
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
return json.loads(raw)
|
|
61
|
+
except (json.JSONDecodeError, TypeError) as exc:
|
|
62
|
+
raise JiraError(f"--{name} is not valid JSON: {exc}") from exc
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _message(exc: requests.HTTPError) -> str:
|
|
66
|
+
"""Extract a human-readable message from a Jira error response."""
|
|
67
|
+
resp = getattr(exc, "response", None)
|
|
68
|
+
if resp is None:
|
|
69
|
+
return f"Jira request failed: {exc}"
|
|
70
|
+
try:
|
|
71
|
+
body = resp.json()
|
|
72
|
+
except Exception:
|
|
73
|
+
return f"Jira {resp.status_code}: {resp.text[:500]}"
|
|
74
|
+
if isinstance(body, dict):
|
|
75
|
+
parts: list[str] = list(body.get("errorMessages") or [])
|
|
76
|
+
errors = body.get("errors")
|
|
77
|
+
if isinstance(errors, dict):
|
|
78
|
+
parts += [f"{k}: {v}" for k, v in errors.items()]
|
|
79
|
+
if parts:
|
|
80
|
+
return f"Jira {resp.status_code}: {'; '.join(map(str, parts))}"
|
|
81
|
+
# servicedeskapi / generic shape
|
|
82
|
+
message = body.get("errorMessage") or body.get("message")
|
|
83
|
+
if message:
|
|
84
|
+
return f"Jira {resp.status_code}: {message}"
|
|
85
|
+
return f"Jira {resp.status_code}: {str(body)[:500]}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def translate_errors(fn: Callable[..., _T]) -> Callable[..., _T]:
|
|
89
|
+
"""Decorator: turn the SDK's ``requests`` exceptions into ``JiraError``."""
|
|
90
|
+
|
|
91
|
+
@functools.wraps(fn)
|
|
92
|
+
def wrapper(*args: Any, **kwargs: Any) -> _T:
|
|
93
|
+
try:
|
|
94
|
+
return fn(*args, **kwargs)
|
|
95
|
+
except requests.HTTPError as exc:
|
|
96
|
+
raise JiraError(_message(exc)) from exc
|
|
97
|
+
except requests.RequestException as exc:
|
|
98
|
+
raise JiraError(f"Jira request failed: {exc}") from exc
|
|
99
|
+
|
|
100
|
+
return wrapper
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""comments.py — add and edit issue comments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from runspec_jira_core.client import build_client, parse_json_arg, translate_errors
|
|
8
|
+
from runspec_jira_core.config import JiraConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@translate_errors
|
|
12
|
+
def add_comment(
|
|
13
|
+
config: JiraConfig,
|
|
14
|
+
key: str,
|
|
15
|
+
body: str,
|
|
16
|
+
*,
|
|
17
|
+
visibility_json: str | None = None,
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
"""Add a comment. ``visibility_json`` optionally restricts it, e.g.
|
|
20
|
+
``{"type": "role", "value": "Administrators"}``."""
|
|
21
|
+
visibility = parse_json_arg("visibility", visibility_json)
|
|
22
|
+
return build_client(config).issue_add_comment(key, body, visibility=visibility)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@translate_errors
|
|
26
|
+
def edit_comment(config: JiraConfig, key: str, comment_id: str, body: str) -> dict[str, Any]:
|
|
27
|
+
"""Edit an existing comment on an issue."""
|
|
28
|
+
return build_client(config).issue_edit_comment(key, comment_id, body)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""config.py — connection + identity for a Jira instance, from the environment.
|
|
2
|
+
|
|
3
|
+
``JiraConfig`` carries everything the client builder needs. ``config_from_env``
|
|
4
|
+
reads the ``JIRA_*`` family (with an optional ``JIRA_<ENV>_*`` prefix selected by
|
|
5
|
+
``--env``), exactly like ``runspec-jsm`` / ``runspec-snow``. Every operation
|
|
6
|
+
function in the package takes an explicit ``JiraConfig`` first argument, so a
|
|
7
|
+
private (e.g. Nexus-hosted) wrapper can inject credentials in code, bake in a
|
|
8
|
+
default ``service_desk_id`` / ``project_key`` / ``env``, and build "one runnable
|
|
9
|
+
per project" on top.
|
|
10
|
+
|
|
11
|
+
Cloud vs Server/Data Center is resolved once: an explicit ``JIRA_CLOUD`` wins,
|
|
12
|
+
otherwise a ``.atlassian.net`` host is treated as Cloud and anything else as
|
|
13
|
+
Server/DC. This drives both the ``atlassian.Jira(cloud=...)`` flag and the
|
|
14
|
+
Cloud-vs-DC branches in a few operations (user identity, assignment, watchers).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from runspec_jira_core.errors import JiraError
|
|
23
|
+
|
|
24
|
+
VALID_ENVS = ("dev", "uat", "prod")
|
|
25
|
+
|
|
26
|
+
_FALSEY = ("0", "false", "no", "off")
|
|
27
|
+
_TRUTHY = ("1", "true", "yes", "on")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class JiraConfig:
|
|
32
|
+
"""Connection + identity for a Jira instance.
|
|
33
|
+
|
|
34
|
+
Provide EITHER ``personal_token`` (a Server/Data Center Personal Access Token,
|
|
35
|
+
sent as a Bearer token) OR ``username`` + ``api_token`` (Cloud email + API
|
|
36
|
+
token, or a Server/DC basic username + password). ``service_desk_id`` and
|
|
37
|
+
``project_key`` are optional defaults a wrapper can bake in.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
url: str
|
|
41
|
+
cloud: bool
|
|
42
|
+
username: str | None = None
|
|
43
|
+
api_token: str | None = None
|
|
44
|
+
personal_token: str | None = None
|
|
45
|
+
service_desk_id: str | None = None
|
|
46
|
+
project_key: str | None = None
|
|
47
|
+
verify_ssl: bool = True
|
|
48
|
+
env: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _env_first(names: list[str]) -> str | None:
|
|
52
|
+
"""Return the first non-empty environment variable among ``names``."""
|
|
53
|
+
for name in names:
|
|
54
|
+
value = os.environ.get(name)
|
|
55
|
+
if value:
|
|
56
|
+
return value
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _resolve_cloud(url: str, override: str | None) -> bool:
|
|
61
|
+
"""Decide Cloud vs Server/DC: explicit override wins, else infer from host."""
|
|
62
|
+
if override is not None:
|
|
63
|
+
return override.strip().lower() in _TRUTHY
|
|
64
|
+
return ".atlassian.net" in url.lower()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def config_from_env(env: str | None = None) -> JiraConfig:
|
|
68
|
+
"""Build a :class:`JiraConfig` from environment variables.
|
|
69
|
+
|
|
70
|
+
When ``env`` (or the ``JIRA_ENV`` variable) selects dev/uat/prod, the
|
|
71
|
+
environment-prefixed variables are read first (e.g. ``JIRA_UAT_URL``), falling
|
|
72
|
+
back to the unprefixed ``JIRA_*`` family for single-instance setups. Raises
|
|
73
|
+
:class:`JiraError` with an actionable message when the env label is invalid,
|
|
74
|
+
the base URL is missing, or no usable credentials are configured.
|
|
75
|
+
"""
|
|
76
|
+
env = env or os.environ.get("JIRA_ENV")
|
|
77
|
+
if env is not None:
|
|
78
|
+
env = env.lower()
|
|
79
|
+
if env not in VALID_ENVS:
|
|
80
|
+
raise JiraError(f"Invalid env {env!r} — expected one of {', '.join(VALID_ENVS)}")
|
|
81
|
+
|
|
82
|
+
def lookup(suffix: str) -> str | None:
|
|
83
|
+
names = []
|
|
84
|
+
if env:
|
|
85
|
+
names.append(f"JIRA_{env.upper()}_{suffix}")
|
|
86
|
+
names.append(f"JIRA_{suffix}")
|
|
87
|
+
return _env_first(names)
|
|
88
|
+
|
|
89
|
+
url = lookup("URL")
|
|
90
|
+
if not url:
|
|
91
|
+
hint = f"JIRA_{env.upper()}_URL" if env else "JIRA_URL"
|
|
92
|
+
raise JiraError(f"No base URL configured — set {hint}")
|
|
93
|
+
|
|
94
|
+
username = lookup("USERNAME")
|
|
95
|
+
api_token = lookup("API_TOKEN")
|
|
96
|
+
personal_token = lookup("PERSONAL_TOKEN")
|
|
97
|
+
if not personal_token and not (username and api_token):
|
|
98
|
+
raise JiraError("No credentials configured — set JIRA_PERSONAL_TOKEN (Server/DC PAT) or JIRA_USERNAME + JIRA_API_TOKEN (Cloud email + API token, or DC basic auth)")
|
|
99
|
+
|
|
100
|
+
verify = lookup("VERIFY_SSL")
|
|
101
|
+
verify_ssl = True if verify is None else verify.strip().lower() not in _FALSEY
|
|
102
|
+
|
|
103
|
+
return JiraConfig(
|
|
104
|
+
url=url.rstrip("/"),
|
|
105
|
+
cloud=_resolve_cloud(url, lookup("CLOUD")),
|
|
106
|
+
username=username,
|
|
107
|
+
api_token=api_token,
|
|
108
|
+
personal_token=personal_token,
|
|
109
|
+
service_desk_id=lookup("SERVICE_DESK_ID"),
|
|
110
|
+
project_key=lookup("PROJECT_KEY"),
|
|
111
|
+
verify_ssl=verify_ssl,
|
|
112
|
+
env=env,
|
|
113
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""errors.py — the exception type raised across runspec-jira-core.
|
|
2
|
+
|
|
3
|
+
Every operation function raises :class:`JiraError` on configuration problems, bad
|
|
4
|
+
JSON arguments, and non-2xx API responses (the underlying ``atlassian-python-api``
|
|
5
|
+
raises ``requests`` exceptions, which the core translates into ``JiraError`` at the
|
|
6
|
+
operation boundary so the wrapper catches one fixed type).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class JiraError(Exception):
|
|
13
|
+
"""Raised on config problems, invalid arguments, and Jira API errors."""
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""fields.py — field discovery (find a custom field's id)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from runspec_jira_core.client import build_client, translate_errors
|
|
8
|
+
from runspec_jira_core.config import JiraConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@translate_errors
|
|
12
|
+
def search_fields(config: JiraConfig, *, query: str | None = None) -> list[dict[str, Any]]:
|
|
13
|
+
"""List fields, optionally filtered by a case-insensitive substring of name or
|
|
14
|
+
id (handy for finding a custom field's ``customfield_XXXXX`` id)."""
|
|
15
|
+
fields: Any = build_client(config).get_all_fields()
|
|
16
|
+
if query:
|
|
17
|
+
needle = query.lower()
|
|
18
|
+
return [f for f in fields if needle in str(f.get("name", "")).lower() or needle in str(f.get("id", "")).lower()]
|
|
19
|
+
return list(fields)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""issues.py — issue read/create/update/assign/delete."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from runspec_jira_core.client import build_client, parse_json_arg, translate_errors
|
|
8
|
+
from runspec_jira_core.config import JiraConfig
|
|
9
|
+
from runspec_jira_core.errors import JiraError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@translate_errors
|
|
13
|
+
def get_issue(
|
|
14
|
+
config: JiraConfig,
|
|
15
|
+
key: str,
|
|
16
|
+
*,
|
|
17
|
+
fields: list[str] | None = None,
|
|
18
|
+
expand: str | None = None,
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
"""Fetch a single issue by key. ``fields`` restricts the returned fields."""
|
|
21
|
+
fields_arg = ",".join(fields) if fields else None
|
|
22
|
+
return build_client(config).get_issue(key, fields=fields_arg, expand=expand)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@translate_errors
|
|
26
|
+
def create_issue(
|
|
27
|
+
config: JiraConfig,
|
|
28
|
+
project: str,
|
|
29
|
+
summary: str,
|
|
30
|
+
issue_type: str,
|
|
31
|
+
*,
|
|
32
|
+
description: str | None = None,
|
|
33
|
+
additional_fields: str | None = None,
|
|
34
|
+
) -> dict[str, Any]:
|
|
35
|
+
"""Create an issue. ``additional_fields`` is a JSON object merged into the
|
|
36
|
+
create payload (e.g. ``{"labels": ["x"], "priority": {"name": "High"}}``)."""
|
|
37
|
+
extra = parse_json_arg("additional-fields", additional_fields) or {}
|
|
38
|
+
if not isinstance(extra, dict):
|
|
39
|
+
raise JiraError("--additional-fields must be a JSON object")
|
|
40
|
+
fields: dict[str, Any] = {
|
|
41
|
+
"project": {"key": project},
|
|
42
|
+
"summary": summary,
|
|
43
|
+
"issuetype": {"name": issue_type},
|
|
44
|
+
}
|
|
45
|
+
if description:
|
|
46
|
+
fields["description"] = description
|
|
47
|
+
fields.update(extra)
|
|
48
|
+
return build_client(config).create_issue(fields=fields)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@translate_errors
|
|
52
|
+
def update_issue(config: JiraConfig, key: str, *, fields_json: str) -> dict[str, Any]:
|
|
53
|
+
"""Update an issue from a JSON object of fields."""
|
|
54
|
+
fields = parse_json_arg("fields", fields_json)
|
|
55
|
+
if not isinstance(fields, dict):
|
|
56
|
+
raise JiraError("--fields must be a JSON object")
|
|
57
|
+
build_client(config).edit_issue(key, fields)
|
|
58
|
+
return {"key": key, "updated": True}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@translate_errors
|
|
62
|
+
def assign_issue(config: JiraConfig, key: str, assignee: str) -> dict[str, Any]:
|
|
63
|
+
"""Assign an issue. On Cloud ``assignee`` is an accountId; on Server/DC it is a
|
|
64
|
+
username."""
|
|
65
|
+
client = build_client(config)
|
|
66
|
+
if config.cloud:
|
|
67
|
+
client.assign_issue(key, account_id=assignee)
|
|
68
|
+
else:
|
|
69
|
+
client.edit_issue(key, {"assignee": {"name": assignee}})
|
|
70
|
+
return {"key": key, "assignee": assignee}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@translate_errors
|
|
74
|
+
def delete_issue(config: JiraConfig, key: str, *, delete_subtasks: bool = True) -> dict[str, Any]:
|
|
75
|
+
"""Delete an issue (and, by default, its subtasks)."""
|
|
76
|
+
build_client(config).delete_issue(key, delete_subtasks=delete_subtasks)
|
|
77
|
+
return {"key": key, "deleted": True}
|