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.
@@ -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}