Skip to content

Commit 0e4f00f

Browse files
raivilclaude
andcommitted
v1.1.0: Add jira-create command with multiline description support
- Create Jira issues from CLI with --project, --summary, --type - --description for inline, --description-file for file/stdin multiline - Optional --priority and --assignee flags - 110 tests, 100% coverage Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ea7ceef commit 0e4f00f

6 files changed

Lines changed: 167 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## v1.1.0 (2026-03-31)
4+
5+
### Added
6+
- `jira-create` command to create Jira issues from the CLI
7+
- Multiline description support via `--description-file` (reads from file or stdin with `-`)
8+
- `--description` and `--description-file` are mutually exclusive
9+
- Optional `--priority` and `--assignee` flags
10+
- `make jira-create` target
11+
- 110 tests with 100% coverage
12+
313
## v1.0.1 (2026-03-23)
414

515
### Fixed

Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: setup build clean test test-cov wiki-export wiki-update wiki-create jira-get jira-my-tasks jira-transition
1+
.PHONY: setup build clean test test-cov wiki-export wiki-update wiki-create jira-create jira-get jira-my-tasks jira-transition
22

33
setup: ## Install dependencies and create venv
44
uv sync
@@ -30,6 +30,11 @@ wiki-create: ## Create a wiki page. Usage: make wiki-create SPACE=<key> TITLE="<
3030
@if [ -z "$(INPUT)" ]; then echo "Error: INPUT is required."; exit 1; fi
3131
uv run atlassian-local-cli wiki-create $(SPACE) "$(TITLE)" $(INPUT) $(if $(PARENT),--parent $(PARENT))
3232

33+
jira-create: ## Create a Jira issue. Usage: make jira-create PROJECT=<key> SUMMARY="<text>" [TYPE=Task] [PRIORITY=High] [ASSIGNEE=user] [DESCRIPTION="<text>"] [DESC_FILE=<file>]
34+
@if [ -z "$(PROJECT)" ]; then echo "Error: PROJECT is required."; exit 1; fi
35+
@if [ -z "$(SUMMARY)" ]; then echo "Error: SUMMARY is required."; exit 1; fi
36+
uv run atlassian-local-cli jira-create --project $(PROJECT) --summary "$(SUMMARY)" $(if $(TYPE),--type $(TYPE)) $(if $(PRIORITY),--priority $(PRIORITY)) $(if $(ASSIGNEE),--assignee $(ASSIGNEE)) $(if $(DESCRIPTION),--description "$(DESCRIPTION)") $(if $(DESC_FILE),--description-file $(DESC_FILE))
37+
3338
jira-get: ## Get a Jira issue. Usage: make jira-get ISSUE=<key>
3439
@if [ -z "$(ISSUE)" ]; then echo "Error: ISSUE is required."; exit 1; fi
3540
uv run atlassian-local-cli jira-get $(ISSUE)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "atlassian-local-cli"
3-
version = "1.0.1"
3+
version = "1.1.0"
44
description = "CLI to interact with Confluence and Jira from the command line"
55
readme = "README.md"
66
requires-python = ">=3.13"

src/atlassian_local_cli/cli.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import argparse
22

3-
from .jira_commands import jira_get, jira_my_tasks, jira_transition
3+
from .jira_commands import jira_create, jira_get, jira_my_tasks, jira_transition
44
from .wiki import wiki_create, wiki_export, wiki_update
55

66

@@ -25,6 +25,16 @@ def main():
2525
p.add_argument("--parent", help="Parent page ID (optional)")
2626
p.set_defaults(func=wiki_create)
2727

28+
p = subparsers.add_parser("jira-create", help="Create a new Jira issue")
29+
p.add_argument("--project", required=True, help="Project key (e.g. PROJ)")
30+
p.add_argument("--summary", required=True, help="Issue summary/title")
31+
p.add_argument("--type", default="Task", help="Issue type (default: Task)")
32+
p.add_argument("--description", help="Inline description")
33+
p.add_argument("--description-file", help="Read description from file (use '-' for stdin)")
34+
p.add_argument("--priority", help="Priority (Highest, High, Medium, Low, Lowest)")
35+
p.add_argument("--assignee", help="Username to assign")
36+
p.set_defaults(func=jira_create)
37+
2838
p = subparsers.add_parser("jira-get", help="Display a Jira issue")
2939
p.add_argument("issue_key", help="Issue key (e.g. PROJ-123)")
3040
p.set_defaults(func=jira_get)

src/atlassian_local_cli/jira_commands.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import sys
33

44
from .clients import create_jira
5+
from .config import get_config
56

67

78
def build_jql(status="open", status_name=None, issue_type=None, project=None):
@@ -25,6 +26,45 @@ def build_jql(status="open", status_name=None, issue_type=None, project=None):
2526
return " AND ".join(conditions) + " ORDER BY priority DESC, updated DESC"
2627

2728

29+
def _resolve_description(args):
30+
"""Resolve description from --description or --description-file (supports stdin via '-')."""
31+
if args.description and args.description_file:
32+
print("Error: --description and --description-file are mutually exclusive.", file=sys.stderr)
33+
sys.exit(1)
34+
35+
if args.description_file:
36+
if args.description_file == "-":
37+
return sys.stdin.read()
38+
with open(args.description_file, "r", encoding="utf-8") as f:
39+
return f.read()
40+
41+
return args.description
42+
43+
44+
def jira_create(args):
45+
description = _resolve_description(args)
46+
47+
fields = {
48+
"project": {"key": args.project},
49+
"summary": args.summary,
50+
"issuetype": {"name": args.type},
51+
}
52+
if description:
53+
fields["description"] = description
54+
if args.priority:
55+
fields["priority"] = {"name": args.priority}
56+
if args.assignee:
57+
fields["assignee"] = {"name": args.assignee}
58+
59+
jira = create_jira()
60+
result = jira.issue_create(fields=fields)
61+
62+
issue_key = result["key"]
63+
config = get_config()
64+
print(f"Created {issue_key}: {args.summary}")
65+
print(f"{config.jira_url.rstrip('/')}/browse/{issue_key}")
66+
67+
2868
def jira_get(args):
2969
jira = create_jira()
3070
issue = jira.issue(args.issue_key)

tests/test_jira_commands.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from atlassian_local_cli.jira_commands import (
88
build_jql,
9+
jira_create,
910
jira_get,
1011
jira_my_tasks,
1112
jira_transition,
@@ -173,3 +174,101 @@ def test_invalid_transition_exits(self, mock_create):
173174

174175
with pytest.raises(SystemExit):
175176
jira_transition(Namespace(issue_key="PROJ-1", status="invalid"))
177+
178+
179+
class TestJiraCreate:
180+
@patch("atlassian_local_cli.jira_commands.get_config")
181+
@patch("atlassian_local_cli.jira_commands.create_jira")
182+
def test_basic_create(self, mock_create, mock_config, capsys):
183+
mock_config.return_value = MagicMock(jira_url="https://jira.test.com/")
184+
mock_jira = MagicMock()
185+
mock_jira.issue_create.return_value = {"key": "PROJ-99"}
186+
mock_create.return_value = mock_jira
187+
188+
args = Namespace(
189+
project="PROJ", summary="New task", type="Task",
190+
description="Short desc", description_file=None,
191+
priority=None, assignee=None,
192+
)
193+
jira_create(args)
194+
output = capsys.readouterr().out
195+
assert "PROJ-99" in output
196+
assert "New task" in output
197+
198+
fields = mock_jira.issue_create.call_args[1]["fields"]
199+
assert fields["project"] == {"key": "PROJ"}
200+
assert fields["summary"] == "New task"
201+
assert fields["description"] == "Short desc"
202+
203+
@patch("atlassian_local_cli.jira_commands.get_config")
204+
@patch("atlassian_local_cli.jira_commands.create_jira")
205+
def test_with_priority_and_assignee(self, mock_create, mock_config):
206+
mock_config.return_value = MagicMock(jira_url="https://jira.test.com/")
207+
mock_jira = MagicMock()
208+
mock_jira.issue_create.return_value = {"key": "PROJ-100"}
209+
mock_create.return_value = mock_jira
210+
211+
args = Namespace(
212+
project="PROJ", summary="Bug", type="Bug",
213+
description=None, description_file=None,
214+
priority="High", assignee="jdoe",
215+
)
216+
jira_create(args)
217+
218+
fields = mock_jira.issue_create.call_args[1]["fields"]
219+
assert fields["priority"] == {"name": "High"}
220+
assert fields["assignee"] == {"name": "jdoe"}
221+
assert "description" not in fields
222+
223+
@patch("atlassian_local_cli.jira_commands.get_config")
224+
@patch("atlassian_local_cli.jira_commands.create_jira")
225+
def test_description_from_file(self, mock_create, mock_config, tmp_path):
226+
mock_config.return_value = MagicMock(jira_url="https://jira.test.com/")
227+
mock_jira = MagicMock()
228+
mock_jira.issue_create.return_value = {"key": "PROJ-101"}
229+
mock_create.return_value = mock_jira
230+
231+
desc_file = tmp_path / "desc.md"
232+
desc_file.write_text("Line 1\n\nLine 2\n\n- bullet\n- items")
233+
234+
args = Namespace(
235+
project="PROJ", summary="From file", type="Task",
236+
description=None, description_file=str(desc_file),
237+
priority=None, assignee=None,
238+
)
239+
jira_create(args)
240+
241+
fields = mock_jira.issue_create.call_args[1]["fields"]
242+
assert "Line 1" in fields["description"]
243+
assert "Line 2" in fields["description"]
244+
assert "- bullet" in fields["description"]
245+
246+
@patch("atlassian_local_cli.jira_commands.get_config")
247+
@patch("atlassian_local_cli.jira_commands.create_jira")
248+
def test_description_from_stdin(self, mock_create, mock_config, monkeypatch):
249+
mock_config.return_value = MagicMock(jira_url="https://jira.test.com/")
250+
mock_jira = MagicMock()
251+
mock_jira.issue_create.return_value = {"key": "PROJ-102"}
252+
mock_create.return_value = mock_jira
253+
254+
import io
255+
monkeypatch.setattr("sys.stdin", io.StringIO("Piped\nmultiline\ncontent"))
256+
257+
args = Namespace(
258+
project="PROJ", summary="From stdin", type="Task",
259+
description=None, description_file="-",
260+
priority=None, assignee=None,
261+
)
262+
jira_create(args)
263+
264+
fields = mock_jira.issue_create.call_args[1]["fields"]
265+
assert "Piped\nmultiline\ncontent" == fields["description"]
266+
267+
def test_description_and_file_mutually_exclusive(self):
268+
args = Namespace(
269+
project="PROJ", summary="Test", type="Task",
270+
description="inline", description_file="file.md",
271+
priority=None, assignee=None,
272+
)
273+
with pytest.raises(SystemExit):
274+
jira_create(args)

0 commit comments

Comments
 (0)