runspec-jira 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-0.1.0/.gitignore +61 -0
- runspec_jira-0.1.0/CHANGELOG.md +29 -0
- runspec_jira-0.1.0/PKG-INFO +11 -0
- runspec_jira-0.1.0/pyproject.toml +39 -0
- runspec_jira-0.1.0/runspec_jira/__init__.py +87 -0
- runspec_jira-0.1.0/runspec_jira/cli.py +211 -0
- runspec_jira-0.1.0/runspec_jira/runspec.toml +301 -0
- runspec_jira-0.1.0/tests/__init__.py +0 -0
- runspec_jira-0.1.0/tests/test_cli.py +117 -0
- runspec_jira-0.1.0/tests/test_spec.py +98 -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,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `runspec-jira` are documented here.
|
|
4
|
+
|
|
5
|
+
## [0.1.0] — 2026-08-07
|
|
6
|
+
|
|
7
|
+
Initial release. A full-featured `jira` runnable ported from the Atlassian MCP
|
|
8
|
+
server (sooperset/mcp-atlassian) onto `atlassian-python-api`, covering Jira
|
|
9
|
+
Software/Core **and** Service Desk (JSM) on Cloud and Server/Data Center. Thin
|
|
10
|
+
wrapper over `runspec-jira-core`.
|
|
11
|
+
|
|
12
|
+
- One runnable, `jira`, with a verb per tool; `runspec serve` re-expands each into
|
|
13
|
+
a leaf MCP tool (`jira_get-issue`, …). Reads are `autonomous`, writes `confirm`.
|
|
14
|
+
- Verbs: issues (get/create/update/assign/delete), search (`search` JQL,
|
|
15
|
+
`get-project-issues`), transitions (list/transition), comments (add/edit), links
|
|
16
|
+
(link types, issue link, epic link, remote link, remove), worklog (get/add),
|
|
17
|
+
projects (get-all/search/components/issue-types/create-fields), field discovery
|
|
18
|
+
(`search-fields`), users (profile/assignable), watchers (get/add/remove), and
|
|
19
|
+
service desk (`list-request-types`, `list-request-type-fields`, `raise-request`,
|
|
20
|
+
`get-request`).
|
|
21
|
+
- Auth is env-var driven (`JIRA_URL` + `JIRA_PERSONAL_TOKEN` or
|
|
22
|
+
`JIRA_USERNAME`/`JIRA_API_TOKEN`), with `JIRA_<ENV>_*` prefixes selected by the
|
|
23
|
+
global `--env`. Cloud vs Server/DC via `JIRA_CLOUD` or a `.atlassian.net` host.
|
|
24
|
+
- JSON-object arguments (`--additional-fields`, `--fields`, `--visibility`,
|
|
25
|
+
`--worklog-json`, `--field-values`) are passed as strings and parsed in the core.
|
|
26
|
+
|
|
27
|
+
The existing `runspec-jsm` package is unchanged; it remains the narrow
|
|
28
|
+
"one runnable per project" JSM service-desk option. Deferred here: attachments/
|
|
29
|
+
media, images, OAuth 2.0, transition-by-id with screen fields, cross-project move.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runspec-jira
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Full-featured Jira runnable for runspec — issues, search, transitions, links, worklogs, projects, users, and service desk (Cloud + Server/Data Center)
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: runspec-jira-core>=0.1.0
|
|
7
|
+
Requires-Dist: runspec>=0.44.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: ruff==0.15.20; extra == 'dev'
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runspec-jira"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
description = "Full-featured Jira runnable for runspec — issues, search, transitions, links, worklogs, projects, users, and service desk (Cloud + Server/Data Center)"
|
|
10
|
+
dependencies = [
|
|
11
|
+
# 0.44.0 surfaces inherited args (the global --env) on each leaf subcommand's
|
|
12
|
+
# MCP tool via `runspec serve`.
|
|
13
|
+
"runspec>=0.44.0",
|
|
14
|
+
"runspec-jira-core>=0.1.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
# Entry point name MUST match the runnable name in runspec.toml (`jira`).
|
|
19
|
+
jira = "runspec_jira.cli:main"
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = [
|
|
23
|
+
"ruff==0.15.20",
|
|
24
|
+
"mypy",
|
|
25
|
+
"pytest>=8.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
testpaths = ["tests"]
|
|
30
|
+
|
|
31
|
+
[tool.mypy]
|
|
32
|
+
python_version = "3.10"
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
line-length = 200
|
|
36
|
+
target-version = "py310"
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""runspec-jira — a full-featured Jira runnable (issues, search, transitions,
|
|
2
|
+
links, worklogs, projects, users, watchers, and service desk) over
|
|
3
|
+
``atlassian-python-api``, on Cloud and Server/Data Center.
|
|
4
|
+
|
|
5
|
+
The logic lives in the dependency-light ``runspec-jira-core``; this package is the
|
|
6
|
+
thin ``jira`` runnable wrapper. The core helpers are re-exported here for
|
|
7
|
+
back-compat so ``from runspec_jira import get_issue`` works without naming the
|
|
8
|
+
core package.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from runspec_jira_core import (
|
|
12
|
+
JiraConfig,
|
|
13
|
+
JiraError,
|
|
14
|
+
add_comment,
|
|
15
|
+
add_watcher,
|
|
16
|
+
add_worklog,
|
|
17
|
+
assign_issue,
|
|
18
|
+
config_from_env,
|
|
19
|
+
create_customer_request,
|
|
20
|
+
create_issue,
|
|
21
|
+
create_issue_link,
|
|
22
|
+
create_remote_issue_link,
|
|
23
|
+
delete_issue,
|
|
24
|
+
edit_comment,
|
|
25
|
+
get_all_projects,
|
|
26
|
+
get_create_fields,
|
|
27
|
+
get_issue,
|
|
28
|
+
get_link_types,
|
|
29
|
+
get_project_components,
|
|
30
|
+
get_project_issue_types,
|
|
31
|
+
get_project_issues,
|
|
32
|
+
get_request,
|
|
33
|
+
get_user_profile,
|
|
34
|
+
get_watchers,
|
|
35
|
+
get_worklog,
|
|
36
|
+
jql_search,
|
|
37
|
+
link_to_epic,
|
|
38
|
+
list_request_type_fields,
|
|
39
|
+
list_request_types,
|
|
40
|
+
list_transitions,
|
|
41
|
+
remove_issue_link,
|
|
42
|
+
remove_watcher,
|
|
43
|
+
search_assignable_users,
|
|
44
|
+
search_fields,
|
|
45
|
+
search_projects,
|
|
46
|
+
transition_issue,
|
|
47
|
+
update_issue,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"JiraConfig",
|
|
52
|
+
"JiraError",
|
|
53
|
+
"config_from_env",
|
|
54
|
+
"get_issue",
|
|
55
|
+
"create_issue",
|
|
56
|
+
"update_issue",
|
|
57
|
+
"assign_issue",
|
|
58
|
+
"delete_issue",
|
|
59
|
+
"jql_search",
|
|
60
|
+
"get_project_issues",
|
|
61
|
+
"list_transitions",
|
|
62
|
+
"transition_issue",
|
|
63
|
+
"add_comment",
|
|
64
|
+
"edit_comment",
|
|
65
|
+
"get_link_types",
|
|
66
|
+
"create_issue_link",
|
|
67
|
+
"link_to_epic",
|
|
68
|
+
"create_remote_issue_link",
|
|
69
|
+
"remove_issue_link",
|
|
70
|
+
"get_worklog",
|
|
71
|
+
"add_worklog",
|
|
72
|
+
"get_all_projects",
|
|
73
|
+
"search_projects",
|
|
74
|
+
"get_project_components",
|
|
75
|
+
"get_project_issue_types",
|
|
76
|
+
"get_create_fields",
|
|
77
|
+
"search_fields",
|
|
78
|
+
"get_user_profile",
|
|
79
|
+
"search_assignable_users",
|
|
80
|
+
"get_watchers",
|
|
81
|
+
"add_watcher",
|
|
82
|
+
"remove_watcher",
|
|
83
|
+
"list_request_types",
|
|
84
|
+
"list_request_type_fields",
|
|
85
|
+
"create_customer_request",
|
|
86
|
+
"get_request",
|
|
87
|
+
]
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""cli.py — the ``jira`` entry point.
|
|
2
|
+
|
|
3
|
+
One binary, a verb per Jira tool. ``rs.parse("jira")`` resolves the subcommand and
|
|
4
|
+
its args; we build a :class:`JiraConfig` from the environment (scoped by the global
|
|
5
|
+
``--env``), unpack each ``.value`` at the boundary, and call the matching
|
|
6
|
+
:mod:`runspec_jira_core` helper. JSON-object arguments travel as raw strings and
|
|
7
|
+
are parsed inside the core op.
|
|
8
|
+
|
|
9
|
+
Failures (the core :class:`JiraError` and arg-shape ``ValueError``s) become a JSON
|
|
10
|
+
``{"error": ...}`` payload + a non-zero exit.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import runspec as rs
|
|
20
|
+
from runspec_jira_core import (
|
|
21
|
+
JiraConfig,
|
|
22
|
+
JiraError,
|
|
23
|
+
add_comment,
|
|
24
|
+
add_watcher,
|
|
25
|
+
add_worklog,
|
|
26
|
+
assign_issue,
|
|
27
|
+
config_from_env,
|
|
28
|
+
create_customer_request,
|
|
29
|
+
create_issue,
|
|
30
|
+
create_issue_link,
|
|
31
|
+
create_remote_issue_link,
|
|
32
|
+
delete_issue,
|
|
33
|
+
edit_comment,
|
|
34
|
+
get_all_projects,
|
|
35
|
+
get_create_fields,
|
|
36
|
+
get_issue,
|
|
37
|
+
get_link_types,
|
|
38
|
+
get_project_components,
|
|
39
|
+
get_project_issue_types,
|
|
40
|
+
get_project_issues,
|
|
41
|
+
get_request,
|
|
42
|
+
get_user_profile,
|
|
43
|
+
get_watchers,
|
|
44
|
+
get_worklog,
|
|
45
|
+
jql_search,
|
|
46
|
+
link_to_epic,
|
|
47
|
+
list_request_type_fields,
|
|
48
|
+
list_request_types,
|
|
49
|
+
list_transitions,
|
|
50
|
+
remove_issue_link,
|
|
51
|
+
remove_watcher,
|
|
52
|
+
search_assignable_users,
|
|
53
|
+
search_fields,
|
|
54
|
+
search_projects,
|
|
55
|
+
transition_issue,
|
|
56
|
+
update_issue,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _emit(payload: Any) -> None:
|
|
61
|
+
print(json.dumps(payload, default=str))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _dispatch(spec: rs.RunSpec, config: JiraConfig) -> Any:
|
|
65
|
+
"""Run the active verb against ``config``, unpacking only its declared args."""
|
|
66
|
+
command = spec.runspec_command
|
|
67
|
+
|
|
68
|
+
# ── issues ──
|
|
69
|
+
if command == "get-issue":
|
|
70
|
+
return get_issue(config, spec.key.value, fields=spec.fields.value or None, expand=spec.expand.value)
|
|
71
|
+
if command == "create-issue":
|
|
72
|
+
return create_issue(
|
|
73
|
+
config,
|
|
74
|
+
spec.project.value,
|
|
75
|
+
spec.summary.value,
|
|
76
|
+
spec.issue_type.value,
|
|
77
|
+
description=spec.description.value,
|
|
78
|
+
additional_fields=spec.additional_fields.value,
|
|
79
|
+
)
|
|
80
|
+
if command == "update-issue":
|
|
81
|
+
return update_issue(config, spec.key.value, fields_json=spec.fields.value)
|
|
82
|
+
if command == "assign-issue":
|
|
83
|
+
return assign_issue(config, spec.key.value, spec.assignee.value)
|
|
84
|
+
if command == "delete-issue":
|
|
85
|
+
return delete_issue(config, spec.key.value, delete_subtasks=spec.delete_subtasks.value)
|
|
86
|
+
|
|
87
|
+
# ── search ──
|
|
88
|
+
if command == "search":
|
|
89
|
+
return jql_search(
|
|
90
|
+
config,
|
|
91
|
+
spec.jql.value,
|
|
92
|
+
fields=spec.fields.value or None,
|
|
93
|
+
start=spec.start.value,
|
|
94
|
+
limit=spec.limit.value,
|
|
95
|
+
expand=spec.expand.value,
|
|
96
|
+
)
|
|
97
|
+
if command == "get-project-issues":
|
|
98
|
+
return get_project_issues(config, spec.project_key.value, limit=spec.limit.value)
|
|
99
|
+
|
|
100
|
+
# ── transitions ──
|
|
101
|
+
if command == "list-transitions":
|
|
102
|
+
return list_transitions(config, spec.key.value)
|
|
103
|
+
if command == "transition-issue":
|
|
104
|
+
return transition_issue(config, spec.key.value, status=spec.status.value, fields_json=spec.fields.value)
|
|
105
|
+
|
|
106
|
+
# ── comments ──
|
|
107
|
+
if command == "add-comment":
|
|
108
|
+
return add_comment(config, spec.key.value, spec.body.value, visibility_json=spec.visibility.value)
|
|
109
|
+
if command == "edit-comment":
|
|
110
|
+
return edit_comment(config, spec.key.value, spec.comment_id.value, spec.body.value)
|
|
111
|
+
|
|
112
|
+
# ── links ──
|
|
113
|
+
if command == "get-link-types":
|
|
114
|
+
return get_link_types(config)
|
|
115
|
+
if command == "create-issue-link":
|
|
116
|
+
return create_issue_link(
|
|
117
|
+
config,
|
|
118
|
+
spec.link_type.value,
|
|
119
|
+
spec.inward_issue.value,
|
|
120
|
+
spec.outward_issue.value,
|
|
121
|
+
comment=spec.comment.value,
|
|
122
|
+
)
|
|
123
|
+
if command == "link-to-epic":
|
|
124
|
+
return link_to_epic(config, spec.issue_key.value, spec.epic_key.value, epic_link_field=spec.epic_link_field.value)
|
|
125
|
+
if command == "create-remote-issue-link":
|
|
126
|
+
return create_remote_issue_link(
|
|
127
|
+
config,
|
|
128
|
+
spec.issue_key.value,
|
|
129
|
+
spec.url.value,
|
|
130
|
+
spec.title.value,
|
|
131
|
+
relationship=spec.relationship.value,
|
|
132
|
+
)
|
|
133
|
+
if command == "remove-issue-link":
|
|
134
|
+
return remove_issue_link(config, spec.link_id.value)
|
|
135
|
+
|
|
136
|
+
# ── worklog ──
|
|
137
|
+
if command == "get-worklog":
|
|
138
|
+
return get_worklog(config, spec.key.value)
|
|
139
|
+
if command == "add-worklog":
|
|
140
|
+
return add_worklog(
|
|
141
|
+
config,
|
|
142
|
+
spec.key.value,
|
|
143
|
+
time_sec=spec.time_sec.value,
|
|
144
|
+
started=spec.started.value,
|
|
145
|
+
comment=spec.comment.value,
|
|
146
|
+
worklog_json=spec.worklog_json.value,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# ── projects ──
|
|
150
|
+
if command == "get-all-projects":
|
|
151
|
+
return get_all_projects(config)
|
|
152
|
+
if command == "search-projects":
|
|
153
|
+
return search_projects(config, spec.query.value)
|
|
154
|
+
if command == "get-project-components":
|
|
155
|
+
return get_project_components(config, spec.project_key.value)
|
|
156
|
+
if command == "get-project-issue-types":
|
|
157
|
+
return get_project_issue_types(config, spec.project_key.value)
|
|
158
|
+
if command == "get-create-fields":
|
|
159
|
+
return get_create_fields(config, spec.project_key.value)
|
|
160
|
+
|
|
161
|
+
# ── fields ──
|
|
162
|
+
if command == "search-fields":
|
|
163
|
+
return search_fields(config, query=spec.query.value)
|
|
164
|
+
|
|
165
|
+
# ── users ──
|
|
166
|
+
if command == "get-user-profile":
|
|
167
|
+
return get_user_profile(config, username=spec.username.value, account_id=spec.account_id.value, key=spec.key.value)
|
|
168
|
+
if command == "search-assignable-users":
|
|
169
|
+
return search_assignable_users(config, spec.project_key.value, start=spec.start.value, limit=spec.limit.value)
|
|
170
|
+
|
|
171
|
+
# ── watchers ──
|
|
172
|
+
if command == "get-watchers":
|
|
173
|
+
return get_watchers(config, spec.key.value)
|
|
174
|
+
if command == "add-watcher":
|
|
175
|
+
return add_watcher(config, spec.key.value, spec.user.value)
|
|
176
|
+
if command == "remove-watcher":
|
|
177
|
+
return remove_watcher(config, spec.key.value, user=spec.user.value, account_id=spec.account_id.value)
|
|
178
|
+
|
|
179
|
+
# ── service desk ──
|
|
180
|
+
if command == "list-request-types":
|
|
181
|
+
return list_request_types(config, service_desk_id=spec.service_desk_id.value)
|
|
182
|
+
if command == "list-request-type-fields":
|
|
183
|
+
return list_request_type_fields(config, spec.request_type_id.value, service_desk_id=spec.service_desk_id.value)
|
|
184
|
+
if command == "raise-request":
|
|
185
|
+
return create_customer_request(
|
|
186
|
+
config,
|
|
187
|
+
spec.request_type_id.value,
|
|
188
|
+
spec.field_values.value,
|
|
189
|
+
service_desk_id=spec.service_desk_id.value,
|
|
190
|
+
raise_on_behalf_of=spec.raise_on_behalf_of.value,
|
|
191
|
+
)
|
|
192
|
+
if command == "get-request":
|
|
193
|
+
return get_request(config, spec.issue_id_or_key.value)
|
|
194
|
+
|
|
195
|
+
# require-command = true makes a bare invocation unreachable.
|
|
196
|
+
raise SystemExit("no command — see `jira --help`") # pragma: no cover
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def main() -> None:
|
|
200
|
+
spec = rs.parse("jira")
|
|
201
|
+
try:
|
|
202
|
+
config = config_from_env(env=spec.env.value)
|
|
203
|
+
result = _dispatch(spec, config)
|
|
204
|
+
except (JiraError, ValueError) as exc:
|
|
205
|
+
_emit({"error": str(exc), "kind": type(exc).__name__})
|
|
206
|
+
sys.exit(1)
|
|
207
|
+
_emit(result)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__":
|
|
211
|
+
main()
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
#:schema https://runspec.app/runspec.schema.json
|
|
2
|
+
|
|
3
|
+
# runspec-jira — a full-featured Jira runnable ported from the Atlassian MCP
|
|
4
|
+
# server (sooperset/mcp-atlassian), onto atlassian-python-api.
|
|
5
|
+
#
|
|
6
|
+
# One runnable, `jira`, with a verb per tool. `runspec serve` re-expands each
|
|
7
|
+
# verb into its own leaf MCP tool (jira_get-issue, jira_create-issue, …), so a
|
|
8
|
+
# runspec-console agent calls them individually — over SSH on any fleet host,
|
|
9
|
+
# with no second server process.
|
|
10
|
+
#
|
|
11
|
+
# Covers Jira Software/Core AND Service Desk (JSM), on Cloud and Server/Data
|
|
12
|
+
# Center. Auth is env-var driven:
|
|
13
|
+
# JIRA_URL + (JIRA_PERSONAL_TOKEN | JIRA_USERNAME + JIRA_API_TOKEN)
|
|
14
|
+
# Per-environment overrides use a JIRA_<ENV>_ prefix (e.g. JIRA_UAT_URL),
|
|
15
|
+
# selected with the global --env (dev/uat/prod) arg. Cloud vs Server/DC is set
|
|
16
|
+
# by JIRA_CLOUD, else inferred from a .atlassian.net host.
|
|
17
|
+
#
|
|
18
|
+
# Reads are autonomous; writes require confirmation. JSON-object arguments
|
|
19
|
+
# (--additional-fields, --fields, --visibility, --worklog-json, --field-values)
|
|
20
|
+
# are passed as strings and parsed by the runnable.
|
|
21
|
+
|
|
22
|
+
[config]
|
|
23
|
+
autonomy-default = "confirm"
|
|
24
|
+
|
|
25
|
+
[config.logging]
|
|
26
|
+
store = "per-run"
|
|
27
|
+
|
|
28
|
+
[jira]
|
|
29
|
+
description = "Query and manipulate Jira issues, projects, and service-desk requests (Cloud or Server/Data Center)"
|
|
30
|
+
output = "json"
|
|
31
|
+
require-command = true
|
|
32
|
+
examples = [
|
|
33
|
+
{cmd = "jira get-issue --key PROJ-1", description = "Fetch a single issue"},
|
|
34
|
+
{cmd = "jira search --jql 'assignee = currentUser() AND statusCategory != Done'", description = "Run a JQL search"},
|
|
35
|
+
{cmd = "jira create-issue --project PROJ --summary 'Fix login' --issue-type Bug", description = "Create an issue"},
|
|
36
|
+
{cmd = "jira transition-issue --key PROJ-1 --status Done", description = "Move an issue to a status"},
|
|
37
|
+
{cmd = "jira list-request-types --service-desk-id 5", description = "List a service desk's request types"},
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# ── global args (inherited by every verb) ──────────────────────────────────
|
|
41
|
+
[jira.args]
|
|
42
|
+
env = {type = "choice", options = ["dev", "uat", "prod"], short = "-e", required = false, description = "Target environment (selects JIRA_<ENV>_* vars, falling back to JIRA_*)"}
|
|
43
|
+
|
|
44
|
+
# ── issues ─────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
[jira.commands.get-issue]
|
|
47
|
+
description = "Fetch a single issue by key, optionally limiting fields or expanding sections"
|
|
48
|
+
autonomy = "autonomous"
|
|
49
|
+
[jira.commands.get-issue.args]
|
|
50
|
+
key = {type = "str", required = true, description = "Issue key, e.g. PROJ-123"}
|
|
51
|
+
fields = {type = "str", multiple = true, required = false, description = "Restrict to these fields (repeat --fields)"}
|
|
52
|
+
expand = {type = "str", required = false, description = "Comma-separated expand sections (e.g. changelog,renderedFields)"}
|
|
53
|
+
|
|
54
|
+
[jira.commands.create-issue]
|
|
55
|
+
description = "Create an issue in a project. Extra/custom fields go through --additional-fields as a JSON object."
|
|
56
|
+
autonomy = "confirm"
|
|
57
|
+
[jira.commands.create-issue.args]
|
|
58
|
+
project = {type = "str", required = true, description = "Project key"}
|
|
59
|
+
summary = {type = "str", required = true, description = "Issue summary"}
|
|
60
|
+
issue-type = {type = "str", required = true, description = "Issue type name, e.g. Bug, Task, Story"}
|
|
61
|
+
description = {type = "str", required = false, description = "Issue description (wiki markup on DC; plain text on Cloud)"}
|
|
62
|
+
additional-fields = {type = "str", required = false, description = "JSON object of extra fields merged into the create payload, e.g. '{\"labels\":[\"x\"],\"priority\":{\"name\":\"High\"}}'"}
|
|
63
|
+
|
|
64
|
+
[jira.commands.update-issue]
|
|
65
|
+
description = "Update an issue's fields from a JSON object"
|
|
66
|
+
autonomy = "confirm"
|
|
67
|
+
[jira.commands.update-issue.args]
|
|
68
|
+
key = {type = "str", required = true, description = "Issue key"}
|
|
69
|
+
fields = {type = "str", required = true, description = "JSON object of fields to set, e.g. '{\"summary\":\"New title\"}'"}
|
|
70
|
+
|
|
71
|
+
[jira.commands.assign-issue]
|
|
72
|
+
description = "Assign an issue to a user (accountId on Cloud, username on Server/DC)"
|
|
73
|
+
autonomy = "confirm"
|
|
74
|
+
[jira.commands.assign-issue.args]
|
|
75
|
+
key = {type = "str", required = true, description = "Issue key"}
|
|
76
|
+
assignee = {type = "str", required = true, description = "Cloud accountId or Server/DC username"}
|
|
77
|
+
|
|
78
|
+
[jira.commands.delete-issue]
|
|
79
|
+
description = "Delete an issue (and, by default, its subtasks)"
|
|
80
|
+
autonomy = "confirm"
|
|
81
|
+
[jira.commands.delete-issue.args]
|
|
82
|
+
key = {type = "str", required = true, description = "Issue key"}
|
|
83
|
+
delete-subtasks = {type = "flag", default = true, description = "Also delete subtasks (default true)"}
|
|
84
|
+
|
|
85
|
+
# ── search ─────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
[jira.commands.search]
|
|
88
|
+
description = "Search issues with JQL; returns one page (pass --start/--limit to page)"
|
|
89
|
+
autonomy = "autonomous"
|
|
90
|
+
[jira.commands.search.args]
|
|
91
|
+
jql = {type = "str", required = true, description = "JQL query string"}
|
|
92
|
+
fields = {type = "str", multiple = true, required = false, description = "Fields to return (repeat --fields; default all)"}
|
|
93
|
+
limit = {type = "int", default = 50, description = "Max results (maxResults)"}
|
|
94
|
+
start = {type = "int", default = 0, description = "Pagination offset (startAt)"}
|
|
95
|
+
expand = {type = "str", required = false, description = "Comma-separated expand sections"}
|
|
96
|
+
|
|
97
|
+
[jira.commands.get-project-issues]
|
|
98
|
+
description = "List a project's issues, newest first (one page)"
|
|
99
|
+
autonomy = "autonomous"
|
|
100
|
+
[jira.commands.get-project-issues.args]
|
|
101
|
+
project-key = {type = "str", required = true, description = "Project key"}
|
|
102
|
+
limit = {type = "int", default = 50, description = "Max results"}
|
|
103
|
+
|
|
104
|
+
# ── transitions ────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
[jira.commands.list-transitions]
|
|
107
|
+
description = "List the workflow transitions available from an issue's current status"
|
|
108
|
+
autonomy = "autonomous"
|
|
109
|
+
[jira.commands.list-transitions.args]
|
|
110
|
+
key = {type = "str", required = true, description = "Issue key"}
|
|
111
|
+
|
|
112
|
+
[jira.commands.transition-issue]
|
|
113
|
+
description = "Move an issue to a target status by name (resolves the workflow transition automatically)"
|
|
114
|
+
autonomy = "confirm"
|
|
115
|
+
[jira.commands.transition-issue.args]
|
|
116
|
+
key = {type = "str", required = true, description = "Issue key"}
|
|
117
|
+
status = {type = "str", required = true, description = "Target status name, e.g. 'In Progress', Done"}
|
|
118
|
+
fields = {type = "str", required = false, description = "JSON object of fields required by the transition screen"}
|
|
119
|
+
|
|
120
|
+
# ── comments ───────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
[jira.commands.add-comment]
|
|
123
|
+
description = "Add a comment to an issue"
|
|
124
|
+
autonomy = "confirm"
|
|
125
|
+
[jira.commands.add-comment.args]
|
|
126
|
+
key = {type = "str", required = true, description = "Issue key"}
|
|
127
|
+
body = {type = "str", required = true, description = "Comment body"}
|
|
128
|
+
visibility = {type = "str", required = false, description = "JSON object restricting visibility, e.g. '{\"type\":\"role\",\"value\":\"Administrators\"}'"}
|
|
129
|
+
|
|
130
|
+
[jira.commands.edit-comment]
|
|
131
|
+
description = "Edit an existing comment on an issue"
|
|
132
|
+
autonomy = "confirm"
|
|
133
|
+
[jira.commands.edit-comment.args]
|
|
134
|
+
key = {type = "str", required = true, description = "Issue key"}
|
|
135
|
+
comment-id = {type = "str", required = true, description = "Comment id"}
|
|
136
|
+
body = {type = "str", required = true, description = "New comment body"}
|
|
137
|
+
|
|
138
|
+
# ── links ──────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
[jira.commands.get-link-types]
|
|
141
|
+
description = "List the issue link types available on the instance (e.g. Blocks, Relates)"
|
|
142
|
+
autonomy = "autonomous"
|
|
143
|
+
|
|
144
|
+
[jira.commands.create-issue-link]
|
|
145
|
+
description = "Link two issues with a named link type"
|
|
146
|
+
autonomy = "confirm"
|
|
147
|
+
[jira.commands.create-issue-link.args]
|
|
148
|
+
link-type = {type = "str", required = true, description = "Link type name, e.g. Blocks"}
|
|
149
|
+
inward-issue = {type = "str", required = true, description = "The inward issue key (e.g. the blocker)"}
|
|
150
|
+
outward-issue = {type = "str", required = true, description = "The outward issue key (e.g. the blocked)"}
|
|
151
|
+
comment = {type = "str", required = false, description = "Optional comment to add with the link"}
|
|
152
|
+
|
|
153
|
+
[jira.commands.link-to-epic]
|
|
154
|
+
description = "Link an issue to an epic (Cloud parent / Server/DC Epic Link field)"
|
|
155
|
+
autonomy = "confirm"
|
|
156
|
+
[jira.commands.link-to-epic.args]
|
|
157
|
+
issue-key = {type = "str", required = true, description = "Issue to link"}
|
|
158
|
+
epic-key = {type = "str", required = true, description = "Epic issue key"}
|
|
159
|
+
epic-link-field = {type = "str", required = false, description = "Force a specific Epic Link custom field id (customfield_XXXXX)"}
|
|
160
|
+
|
|
161
|
+
[jira.commands.create-remote-issue-link]
|
|
162
|
+
description = "Attach a remote (web) link to an issue"
|
|
163
|
+
autonomy = "confirm"
|
|
164
|
+
[jira.commands.create-remote-issue-link.args]
|
|
165
|
+
issue-key = {type = "str", required = true, description = "Issue key"}
|
|
166
|
+
url = {type = "str", required = true, description = "The web URL to link to"}
|
|
167
|
+
title = {type = "str", required = true, description = "Link title"}
|
|
168
|
+
relationship = {type = "str", required = false, description = "Optional relationship label"}
|
|
169
|
+
|
|
170
|
+
[jira.commands.remove-issue-link]
|
|
171
|
+
description = "Remove an issue link by its id"
|
|
172
|
+
autonomy = "confirm"
|
|
173
|
+
[jira.commands.remove-issue-link.args]
|
|
174
|
+
link-id = {type = "str", required = true, description = "Issue link id"}
|
|
175
|
+
|
|
176
|
+
# ── worklog ────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
[jira.commands.get-worklog]
|
|
179
|
+
description = "Return an issue's work log entries"
|
|
180
|
+
autonomy = "autonomous"
|
|
181
|
+
[jira.commands.get-worklog.args]
|
|
182
|
+
key = {type = "str", required = true, description = "Issue key"}
|
|
183
|
+
|
|
184
|
+
[jira.commands.add-worklog]
|
|
185
|
+
description = "Log work on an issue (pass --time-sec, or a full --worklog-json object)"
|
|
186
|
+
autonomy = "confirm"
|
|
187
|
+
[jira.commands.add-worklog.args]
|
|
188
|
+
key = {type = "str", required = true, description = "Issue key"}
|
|
189
|
+
time-sec = {type = "int", required = false, description = "Time spent in seconds"}
|
|
190
|
+
started = {type = "str", required = false, description = "Start time (ISO 8601, e.g. 2026-08-07T09:00:00.000+0000)"}
|
|
191
|
+
comment = {type = "str", required = false, description = "Worklog comment"}
|
|
192
|
+
worklog-json = {type = "str", required = false, description = "Full worklog JSON object (overrides the scalar args)"}
|
|
193
|
+
|
|
194
|
+
# ── projects ───────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
[jira.commands.get-all-projects]
|
|
197
|
+
description = "List every project visible to the configured credentials"
|
|
198
|
+
autonomy = "autonomous"
|
|
199
|
+
|
|
200
|
+
[jira.commands.search-projects]
|
|
201
|
+
description = "Filter visible projects by a substring of name or key"
|
|
202
|
+
autonomy = "autonomous"
|
|
203
|
+
[jira.commands.search-projects.args]
|
|
204
|
+
query = {type = "str", required = true, description = "Text to match against project name/key"}
|
|
205
|
+
|
|
206
|
+
[jira.commands.get-project-components]
|
|
207
|
+
description = "List a project's components"
|
|
208
|
+
autonomy = "autonomous"
|
|
209
|
+
[jira.commands.get-project-components.args]
|
|
210
|
+
project-key = {type = "str", required = true, description = "Project key"}
|
|
211
|
+
|
|
212
|
+
[jira.commands.get-project-issue-types]
|
|
213
|
+
description = "List the issue types available when creating in a project"
|
|
214
|
+
autonomy = "autonomous"
|
|
215
|
+
[jira.commands.get-project-issue-types.args]
|
|
216
|
+
project-key = {type = "str", required = true, description = "Project key"}
|
|
217
|
+
|
|
218
|
+
[jira.commands.get-create-fields]
|
|
219
|
+
description = "Return the create-screen field metadata for a project (the discovery step before create-issue --additional-fields)"
|
|
220
|
+
autonomy = "autonomous"
|
|
221
|
+
[jira.commands.get-create-fields.args]
|
|
222
|
+
project-key = {type = "str", required = true, description = "Project key"}
|
|
223
|
+
|
|
224
|
+
# ── fields ─────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
[jira.commands.search-fields]
|
|
227
|
+
description = "List fields, optionally filtered by a substring of name or id (find a custom field's id)"
|
|
228
|
+
autonomy = "autonomous"
|
|
229
|
+
[jira.commands.search-fields.args]
|
|
230
|
+
query = {type = "str", required = false, description = "Text to match against field name/id"}
|
|
231
|
+
|
|
232
|
+
# ── users ──────────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
[jira.commands.get-user-profile]
|
|
235
|
+
description = "Fetch a user profile (Cloud: --account-id; Server/DC: --username or --key)"
|
|
236
|
+
autonomy = "autonomous"
|
|
237
|
+
[jira.commands.get-user-profile.args]
|
|
238
|
+
username = {type = "str", required = false, description = "Server/DC username"}
|
|
239
|
+
account-id = {type = "str", required = false, description = "Cloud accountId"}
|
|
240
|
+
key = {type = "str", required = false, description = "Server/DC user key"}
|
|
241
|
+
|
|
242
|
+
[jira.commands.search-assignable-users]
|
|
243
|
+
description = "List users assignable to issues in a project (one page)"
|
|
244
|
+
autonomy = "autonomous"
|
|
245
|
+
[jira.commands.search-assignable-users.args]
|
|
246
|
+
project-key = {type = "str", required = true, description = "Project key"}
|
|
247
|
+
start = {type = "int", default = 0, description = "Pagination offset"}
|
|
248
|
+
limit = {type = "int", default = 50, description = "Max results"}
|
|
249
|
+
|
|
250
|
+
# ── watchers ───────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
[jira.commands.get-watchers]
|
|
253
|
+
description = "List the watchers of an issue"
|
|
254
|
+
autonomy = "autonomous"
|
|
255
|
+
[jira.commands.get-watchers.args]
|
|
256
|
+
key = {type = "str", required = true, description = "Issue key"}
|
|
257
|
+
|
|
258
|
+
[jira.commands.add-watcher]
|
|
259
|
+
description = "Add a watcher to an issue (username on Server/DC, accountId on Cloud)"
|
|
260
|
+
autonomy = "confirm"
|
|
261
|
+
[jira.commands.add-watcher.args]
|
|
262
|
+
key = {type = "str", required = true, description = "Issue key"}
|
|
263
|
+
user = {type = "str", required = true, description = "Username (Server/DC) or accountId (Cloud)"}
|
|
264
|
+
|
|
265
|
+
[jira.commands.remove-watcher]
|
|
266
|
+
description = "Remove a watcher (Cloud: --account-id; Server/DC: --user)"
|
|
267
|
+
autonomy = "confirm"
|
|
268
|
+
[jira.commands.remove-watcher.args]
|
|
269
|
+
key = {type = "str", required = true, description = "Issue key"}
|
|
270
|
+
user = {type = "str", required = false, description = "Server/DC username"}
|
|
271
|
+
account-id = {type = "str", required = false, description = "Cloud accountId"}
|
|
272
|
+
|
|
273
|
+
# ── service desk (JSM) ─────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
[jira.commands.list-request-types]
|
|
276
|
+
description = "List the request types offered by a service desk"
|
|
277
|
+
autonomy = "autonomous"
|
|
278
|
+
[jira.commands.list-request-types.args]
|
|
279
|
+
service-desk-id = {type = "str", required = false, description = "Service desk id (falls back to JIRA_SERVICE_DESK_ID)"}
|
|
280
|
+
|
|
281
|
+
[jira.commands.list-request-type-fields]
|
|
282
|
+
description = "List the fields a request type expects (feeds raise-request)"
|
|
283
|
+
autonomy = "autonomous"
|
|
284
|
+
[jira.commands.list-request-type-fields.args]
|
|
285
|
+
request-type-id = {type = "str", required = true, description = "Request type id"}
|
|
286
|
+
service-desk-id = {type = "str", required = false, description = "Service desk id (falls back to JIRA_SERVICE_DESK_ID)"}
|
|
287
|
+
|
|
288
|
+
[jira.commands.raise-request]
|
|
289
|
+
description = "Raise a customer request. --field-values is a JSON object keyed by fieldId (see list-request-type-fields)."
|
|
290
|
+
autonomy = "confirm"
|
|
291
|
+
[jira.commands.raise-request.args]
|
|
292
|
+
request-type-id = {type = "str", required = true, description = "Request type id"}
|
|
293
|
+
field-values = {type = "str", required = true, description = "JSON object of fieldId: value, e.g. '{\"summary\":\"Laptop broken\"}'"}
|
|
294
|
+
service-desk-id = {type = "str", required = false, description = "Service desk id (falls back to JIRA_SERVICE_DESK_ID)"}
|
|
295
|
+
raise-on-behalf-of = {type = "str", required = false, description = "Raise the request on behalf of this reporter"}
|
|
296
|
+
|
|
297
|
+
[jira.commands.get-request]
|
|
298
|
+
description = "Fetch a customer request by issue id or key"
|
|
299
|
+
autonomy = "autonomous"
|
|
300
|
+
[jira.commands.get-request.args]
|
|
301
|
+
issue-id-or-key = {type = "str", required = true, description = "Request issue id or key"}
|
|
File without changes
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Wrapper logic: real parsed spec → `_dispatch` → core helper, with the core's
|
|
2
|
+
atlassian client faked (no real SDK calls), plus the main() error path."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from runspec.testing import ParseHarness
|
|
10
|
+
from runspec_jira_core import JiraConfig
|
|
11
|
+
from runspec_jira_core import client as core_client
|
|
12
|
+
|
|
13
|
+
from runspec_jira import cli
|
|
14
|
+
|
|
15
|
+
SPEC = Path(__file__).parent.parent / "runspec_jira" / "runspec.toml"
|
|
16
|
+
CLOUD = JiraConfig(url="https://acme.atlassian.net", cloud=True, username="me@x.com", api_token="tok", service_desk_id="5")
|
|
17
|
+
DC = JiraConfig(url="https://jira.corp", cloud=False, personal_token="pat")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FakeClient:
|
|
21
|
+
def __init__(self, returns: dict[str, Any] | None = None):
|
|
22
|
+
self.calls: list[tuple[str, tuple, dict]] = []
|
|
23
|
+
self._returns = returns or {}
|
|
24
|
+
|
|
25
|
+
def __getattr__(self, name: str):
|
|
26
|
+
def method(*args: Any, **kwargs: Any) -> Any:
|
|
27
|
+
self.calls.append((name, args, kwargs))
|
|
28
|
+
return self._returns.get(name)
|
|
29
|
+
|
|
30
|
+
return method
|
|
31
|
+
|
|
32
|
+
def last(self, name: str) -> tuple[tuple, dict]:
|
|
33
|
+
for called, args, kwargs in reversed(self.calls):
|
|
34
|
+
if called == name:
|
|
35
|
+
return args, kwargs
|
|
36
|
+
raise AssertionError(f"{name} not called; saw {[c[0] for c in self.calls]}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def fake(monkeypatch):
|
|
41
|
+
jira = FakeClient()
|
|
42
|
+
sd = FakeClient()
|
|
43
|
+
monkeypatch.setattr(core_client, "Jira", lambda **kw: jira)
|
|
44
|
+
monkeypatch.setattr(core_client, "ServiceDesk", lambda **kw: sd)
|
|
45
|
+
return jira, sd
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _harness() -> ParseHarness:
|
|
49
|
+
return ParseHarness(script_name="jira", config_path=SPEC)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_dispatch_get_issue(fake) -> None:
|
|
53
|
+
jira, _ = fake
|
|
54
|
+
jira._returns["get_issue"] = {"key": "PROJ-1"}
|
|
55
|
+
h = _harness()
|
|
56
|
+
spec = h.expect_ok(["get-issue", "--key", "PROJ-1", "--fields", "summary"])
|
|
57
|
+
result = cli._dispatch(spec, CLOUD)
|
|
58
|
+
h.close()
|
|
59
|
+
assert result == {"key": "PROJ-1"}
|
|
60
|
+
args, kwargs = jira.last("get_issue")
|
|
61
|
+
assert args == ("PROJ-1",)
|
|
62
|
+
assert kwargs["fields"] == "summary"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_dispatch_create_issue_merges_json(fake) -> None:
|
|
66
|
+
jira, _ = fake
|
|
67
|
+
h = _harness()
|
|
68
|
+
spec = h.expect_ok(["create-issue", "--project", "P", "--summary", "s", "--issue-type", "Bug", "--additional-fields", '{"labels":["x"]}'])
|
|
69
|
+
cli._dispatch(spec, CLOUD)
|
|
70
|
+
h.close()
|
|
71
|
+
_, kwargs = jira.last("create_issue")
|
|
72
|
+
assert kwargs["fields"]["labels"] == ["x"]
|
|
73
|
+
assert kwargs["fields"]["project"] == {"key": "P"}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_dispatch_transition_issue(fake) -> None:
|
|
77
|
+
jira, _ = fake
|
|
78
|
+
h = _harness()
|
|
79
|
+
spec = h.expect_ok(["transition-issue", "--key", "PROJ-1", "--status", "Done"])
|
|
80
|
+
cli._dispatch(spec, CLOUD)
|
|
81
|
+
h.close()
|
|
82
|
+
args, _ = jira.last("set_issue_status")
|
|
83
|
+
assert args == ("PROJ-1", "Done")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_dispatch_raise_request_uses_servicedesk(fake) -> None:
|
|
87
|
+
_, sd = fake
|
|
88
|
+
h = _harness()
|
|
89
|
+
spec = h.expect_ok(["raise-request", "--request-type-id", "17", "--field-values", '{"summary":"x"}'])
|
|
90
|
+
cli._dispatch(spec, CLOUD)
|
|
91
|
+
h.close()
|
|
92
|
+
args, kwargs = sd.last("create_customer_request")
|
|
93
|
+
assert args[0] == "5" # falls back to config.service_desk_id
|
|
94
|
+
assert args[1] == "17"
|
|
95
|
+
assert args[2] == {"summary": "x"}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_dispatch_bad_json_raises_jira_error(fake) -> None:
|
|
99
|
+
h = _harness()
|
|
100
|
+
spec = h.expect_ok(["update-issue", "--key", "PROJ-1", "--fields", "{oops"])
|
|
101
|
+
with pytest.raises(Exception, match="not valid JSON"):
|
|
102
|
+
cli._dispatch(spec, CLOUD)
|
|
103
|
+
h.close()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_main_missing_config_emits_error(monkeypatch, capsys) -> None:
|
|
107
|
+
for var in ("JIRA_URL", "JIRA_USERNAME", "JIRA_API_TOKEN", "JIRA_PERSONAL_TOKEN", "JIRA_ENV"):
|
|
108
|
+
monkeypatch.delenv(var, raising=False)
|
|
109
|
+
h = _harness()
|
|
110
|
+
spec = h.expect_ok(["get-issue", "--key", "PROJ-1"])
|
|
111
|
+
h.close()
|
|
112
|
+
monkeypatch.setattr(cli.rs, "parse", lambda name: spec)
|
|
113
|
+
with pytest.raises(SystemExit) as exc:
|
|
114
|
+
cli.main()
|
|
115
|
+
assert exc.value.code == 1
|
|
116
|
+
payload = json.loads(capsys.readouterr().out)
|
|
117
|
+
assert payload["kind"] == "JiraError"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Wiring / parsing tests for the `jira` runnable — no mocks, real spec."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from runspec.testing import ParseHarness
|
|
6
|
+
|
|
7
|
+
SPEC = Path(__file__).parent.parent / "runspec_jira" / "runspec.toml"
|
|
8
|
+
|
|
9
|
+
# One value per arg name, reused across every verb that declares it.
|
|
10
|
+
VALUES = {
|
|
11
|
+
"key": "PROJ-1",
|
|
12
|
+
"project": "PROJ",
|
|
13
|
+
"project-key": "PROJ",
|
|
14
|
+
"summary": "Fix login",
|
|
15
|
+
"issue-type": "Bug",
|
|
16
|
+
"fields": "{}", # required JSON string on update-issue; harmless elsewhere
|
|
17
|
+
"assignee": "acc-1",
|
|
18
|
+
"jql": "project = PROJ",
|
|
19
|
+
"status": "Done",
|
|
20
|
+
"body": "a comment",
|
|
21
|
+
"comment-id": "10010",
|
|
22
|
+
"link-type": "Blocks",
|
|
23
|
+
"inward-issue": "PROJ-1",
|
|
24
|
+
"outward-issue": "PROJ-2",
|
|
25
|
+
"issue-key": "PROJ-1",
|
|
26
|
+
"epic-key": "EPIC-1",
|
|
27
|
+
"url": "https://example.com",
|
|
28
|
+
"title": "Docs",
|
|
29
|
+
"link-id": "10100",
|
|
30
|
+
"query": "epic",
|
|
31
|
+
"user": "jbloggs",
|
|
32
|
+
"request-type-id": "17",
|
|
33
|
+
"field-values": "{}",
|
|
34
|
+
"issue-id-or-key": "SD-1",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _harness() -> ParseHarness:
|
|
39
|
+
return ParseHarness(script_name="jira", config_path=SPEC)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_smoke_all_verbs() -> None:
|
|
43
|
+
h = _harness()
|
|
44
|
+
done = h.smoke(values=VALUES)
|
|
45
|
+
# Guard against accidentally checking nothing (34 verbs → many checks).
|
|
46
|
+
assert len(done) > 60
|
|
47
|
+
h.close()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_global_env_choice_inherited() -> None:
|
|
51
|
+
h = _harness()
|
|
52
|
+
spec = h.expect_ok(["--env", "uat", "get-issue", "--key", "PROJ-1"])
|
|
53
|
+
assert spec.env.value == "uat"
|
|
54
|
+
assert spec.runspec_command == "get-issue"
|
|
55
|
+
h.close()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_bad_env_choice_rejected() -> None:
|
|
59
|
+
h = _harness()
|
|
60
|
+
h.expect_exit(["--env", "staging", "get-issue", "--key", "PROJ-1"], contains="env")
|
|
61
|
+
h.close()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_fields_multiple_collects_list() -> None:
|
|
65
|
+
h = _harness()
|
|
66
|
+
spec = h.expect_ok(["get-issue", "--key", "PROJ-1", "--fields", "summary", "--fields", "status"])
|
|
67
|
+
assert spec.fields.value == ["summary", "status"]
|
|
68
|
+
h.close()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_search_limit_defaults_and_coerces() -> None:
|
|
72
|
+
h = _harness()
|
|
73
|
+
spec = h.expect_ok(["search", "--jql", "project = PROJ"])
|
|
74
|
+
assert spec.limit.value == 50
|
|
75
|
+
assert spec.start.value == 0
|
|
76
|
+
spec2 = h.expect_ok(["search", "--jql", "x", "--limit", "5"])
|
|
77
|
+
assert spec2.limit.value == 5
|
|
78
|
+
assert isinstance(spec2.limit.value, int)
|
|
79
|
+
h.close()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_delete_subtasks_flag_defaults_true() -> None:
|
|
83
|
+
h = _harness()
|
|
84
|
+
spec = h.expect_ok(["delete-issue", "--key", "PROJ-1"])
|
|
85
|
+
assert spec.delete_subtasks.value is True
|
|
86
|
+
h.close()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_missing_required_key_rejected() -> None:
|
|
90
|
+
h = _harness()
|
|
91
|
+
h.expect_exit(["get-issue"], contains="key")
|
|
92
|
+
h.close()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_require_command_enforced() -> None:
|
|
96
|
+
h = _harness()
|
|
97
|
+
h.expect_exit([])
|
|
98
|
+
h.close()
|