From a170c2749cb779df165b2a25e2669c12b03dc811 Mon Sep 17 00:00:00 2001 From: wiliam Date: Thu, 23 Apr 2026 21:53:24 +0800 Subject: [PATCH 01/24] Bootstrap tests/ package for project-ops work --- tests/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/__init__.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 From c8f41e5ae035da50b9e7162366320d3b6072b141 Mon Sep 17 00:00:00 2001 From: wiliam Date: Thu, 23 Apr 2026 22:04:33 +0800 Subject: [PATCH 02/24] Add merge_labels helper with set semantics and clear --- bjira/_fields.py | 19 +++++++++++++++++++ tests/test_fields.py | 25 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 bjira/_fields.py create mode 100644 tests/test_fields.py diff --git a/bjira/_fields.py b/bjira/_fields.py new file mode 100644 index 0000000..15b51ed --- /dev/null +++ b/bjira/_fields.py @@ -0,0 +1,19 @@ +def merge_labels(existing, add, clear): + """Return the final label list after merge, or None if no API change needed. + + - clear=True → wipe existing first, then add + - clear=False → union of existing + add, preserving existing order + - If result equals existing, return None to signal no-op. + """ + if clear: + result = [] + for label in add: + if label not in result: + result.append(label) + return result if result != existing else None + + result = list(existing) + for label in add: + if label not in result: + result.append(label) + return result if result != existing else None diff --git a/tests/test_fields.py b/tests/test_fields.py new file mode 100644 index 0000000..cc910a3 --- /dev/null +++ b/tests/test_fields.py @@ -0,0 +1,25 @@ +from bjira._fields import merge_labels + + +def test_merge_labels_adds_new(): + assert merge_labels(existing=["a", "b"], add=["c"], clear=False) == ["a", "b", "c"] + + +def test_merge_labels_ignores_duplicates(): + assert merge_labels(existing=["a", "b"], add=["a", "c"], clear=False) == ["a", "b", "c"] + + +def test_merge_labels_clear_wipes_then_adds(): + assert merge_labels(existing=["a", "b"], add=["x"], clear=True) == ["x"] + + +def test_merge_labels_clear_only(): + assert merge_labels(existing=["a", "b"], add=[], clear=True) == [] + + +def test_merge_labels_returns_none_when_no_change(): + assert merge_labels(existing=["a", "b"], add=["a"], clear=False) is None + + +def test_merge_labels_preserves_existing_order(): + assert merge_labels(existing=["z", "a", "m"], add=["b"], clear=False) == ["z", "a", "m", "b"] From 5bba2f0e5d72f13305f005a085f06dacad5e0a30 Mon Sep 17 00:00:00 2001 From: wiliam Date: Thu, 23 Apr 2026 22:06:16 +0800 Subject: [PATCH 03/24] Add parse_due helper for --due flag --- bjira/_fields.py | 16 ++++++++++++++++ tests/test_fields.py | 27 ++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/bjira/_fields.py b/bjira/_fields.py index 15b51ed..f4ce737 100644 --- a/bjira/_fields.py +++ b/bjira/_fields.py @@ -1,3 +1,6 @@ +import datetime + + def merge_labels(existing, add, clear): """Return the final label list after merge, or None if no API change needed. @@ -17,3 +20,16 @@ def merge_labels(existing, add, clear): if label not in result: result.append(label) return result if result != existing else None + + +def parse_due(value): + """Return ISO date string, or None to clear. Raise ValueError on invalid input.""" + if value == "none": + return None + if not value: + raise ValueError("due must be YYYY-MM-DD or 'none'") + try: + datetime.date.fromisoformat(value) + except ValueError as exc: + raise ValueError(f"due must be YYYY-MM-DD or 'none', got {value!r}") from exc + return value diff --git a/tests/test_fields.py b/tests/test_fields.py index cc910a3..8468704 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,4 +1,6 @@ -from bjira._fields import merge_labels +import pytest + +from bjira._fields import merge_labels, parse_due def test_merge_labels_adds_new(): @@ -23,3 +25,26 @@ def test_merge_labels_returns_none_when_no_change(): def test_merge_labels_preserves_existing_order(): assert merge_labels(existing=["z", "a", "m"], add=["b"], clear=False) == ["z", "a", "m", "b"] + + +def test_parse_due_valid_date(): + assert parse_due("2026-05-15") == "2026-05-15" + + +def test_parse_due_none_keyword_returns_none_string(): + assert parse_due("none") is None + + +def test_parse_due_rejects_invalid_format(): + with pytest.raises(ValueError, match="YYYY-MM-DD"): + parse_due("15/05/2026") + + +def test_parse_due_rejects_empty(): + with pytest.raises(ValueError): + parse_due("") + + +def test_parse_due_rejects_bad_calendar_date(): + with pytest.raises(ValueError): + parse_due("2026-13-40") From 9b50e94a9d3fbeea0882cbe1528e1703ca6502eb Mon Sep 17 00:00:00 2001 From: wiliam Date: Thu, 23 Apr 2026 22:07:17 +0800 Subject: [PATCH 04/24] Add should_require_force for --force gate on summary/description --- bjira/_fields.py | 18 ++++++++++++++++++ tests/test_fields.py | 28 +++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/bjira/_fields.py b/bjira/_fields.py index f4ce737..b374c95 100644 --- a/bjira/_fields.py +++ b/bjira/_fields.py @@ -33,3 +33,21 @@ def parse_due(value): except ValueError as exc: raise ValueError(f"due must be YYYY-MM-DD or 'none', got {value!r}") from exc return value + + +_FORCE_GATED_FIELDS = {"summary", "description"} + + +def should_require_force(field, current, force): + """Return True if this edit needs --force but force was not passed. + + Only 'summary' and 'description' are gated: they are free-form, potentially long, + and a typo'd overwrite is painful. Whitespace-only existing values count as empty. + """ + if force: + return False + if field not in _FORCE_GATED_FIELDS: + return False + if not current or not current.strip(): + return False + return True diff --git a/tests/test_fields.py b/tests/test_fields.py index 8468704..f62f5aa 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,6 +1,6 @@ import pytest -from bjira._fields import merge_labels, parse_due +from bjira._fields import merge_labels, parse_due, should_require_force def test_merge_labels_adds_new(): @@ -48,3 +48,29 @@ def test_parse_due_rejects_empty(): def test_parse_due_rejects_bad_calendar_date(): with pytest.raises(ValueError): parse_due("2026-13-40") + + +def test_force_required_when_description_nonempty_and_no_force(): + assert should_require_force("description", current="old body", force=False) is True + + +def test_force_not_required_when_description_empty(): + assert should_require_force("description", current="", force=False) is False + + +def test_force_not_required_when_description_whitespace_only(): + assert should_require_force("description", current=" \n\n ", force=False) is False + + +def test_force_not_required_when_force_passed(): + assert should_require_force("description", current="old body", force=True) is False + + +def test_force_required_when_summary_nonempty_and_no_force(): + assert should_require_force("summary", current="old title", force=False) is True + + +def test_force_not_required_for_other_fields(): + assert should_require_force("duedate", current="2026-01-01", force=False) is False + assert should_require_force("labels", current=["a"], force=False) is False + assert should_require_force("assignee", current="someone", force=False) is False From 60b1244625f27874e49aa83e2a00320f0f3f0c7e Mon Sep 17 00:00:00 2001 From: wiliam Date: Thu, 23 Apr 2026 22:18:02 +0800 Subject: [PATCH 05/24] Add match_transition with id/exact/substring resolution --- bjira/_match.py | 45 ++++++++++++++++++++++++++++++++++++++++++ tests/test_match.py | 48 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 bjira/_match.py create mode 100644 tests/test_match.py diff --git a/bjira/_match.py b/bjira/_match.py new file mode 100644 index 0000000..7aa0299 --- /dev/null +++ b/bjira/_match.py @@ -0,0 +1,45 @@ +class AmbiguousMatch(ValueError): + pass + + +class NoMatch(ValueError): + pass + + +def match_transition(query, transitions): + """Resolve a user query to a transition id. + + Match order: numeric id -> case-insensitive exact name -> case-insensitive substring. + Ambiguous substring matches raise AmbiguousMatch with candidate list. + """ + if query.isdigit(): + for t in transitions: + if t["id"] == query: + return t["id"] + raise NoMatch(f"no transition with id={query}") + + q = query.lower() + exact = [t for t in transitions if t["name"].lower() == q] + if len(exact) == 1: + return exact[0]["id"] + + # Substring matching: check if all words in query appear in the name (in order) + query_words = q.split() + def matches_query_words(name): + name_lower = name.lower() + pos = 0 + for word in query_words: + pos = name_lower.find(word, pos) + if pos == -1: + return False + pos += len(word) + return True + + substr = [t for t in transitions if matches_query_words(t["name"])] + if len(substr) == 1: + return substr[0]["id"] + if len(substr) > 1: + candidates = ", ".join(f"{t['id']} {t['name']}" for t in substr) + raise AmbiguousMatch(f"ambiguous (matches {len(substr)}: {candidates})") + + raise NoMatch(f"no transition matches {query!r}") diff --git a/tests/test_match.py b/tests/test_match.py new file mode 100644 index 0000000..165d2f4 --- /dev/null +++ b/tests/test_match.py @@ -0,0 +1,48 @@ +import pytest +from bjira._match import match_transition, AmbiguousMatch, NoMatch + + +TRANSITIONS = [ + {"id": "1421", "name": "Backlog"}, + {"id": "1501", "name": "Development: In progress"}, + {"id": "1511", "name": "Development: Done"}, + {"id": "1481", "name": "Decomposition: In progress"}, + {"id": "1381", "name": "Fixed"}, +] + + +def test_match_by_exact_numeric_id(): + assert match_transition("1501", TRANSITIONS) == "1501" + + +def test_match_by_exact_name(): + assert match_transition("Development: In progress", TRANSITIONS) == "1501" + + +def test_match_by_case_insensitive_substring(): + assert match_transition("dev in pro", TRANSITIONS) == "1501" + + +def test_match_case_insensitive_exact(): + assert match_transition("fixed", TRANSITIONS) == "1381" + + +def test_ambiguous_substring_raises(): + with pytest.raises(AmbiguousMatch) as exc: + match_transition("development", TRANSITIONS) + assert "1501" in str(exc.value) + assert "1511" in str(exc.value) + + +def test_no_match_raises(): + with pytest.raises(NoMatch): + match_transition("totallynotathing", TRANSITIONS) + + +def test_exact_name_wins_over_substring(): + assert match_transition("Backlog", TRANSITIONS) == "1421" + + +def test_numeric_id_not_in_list_raises(): + with pytest.raises(NoMatch): + match_transition("9999", TRANSITIONS) From bb788f306af10ad84fd0e67902d0ff3a82640ac8 Mon Sep 17 00:00:00 2001 From: wiliam Date: Thu, 23 Apr 2026 22:18:38 +0800 Subject: [PATCH 06/24] Add resolve_link_type with case-insensitive match --- bjira/_match.py | 10 ++++++++++ tests/test_match.py | 24 +++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/bjira/_match.py b/bjira/_match.py index 7aa0299..6fefee7 100644 --- a/bjira/_match.py +++ b/bjira/_match.py @@ -43,3 +43,13 @@ def matches_query_words(name): raise AmbiguousMatch(f"ambiguous (matches {len(substr)}: {candidates})") raise NoMatch(f"no transition matches {query!r}") + + +def resolve_link_type(query, link_types): + """Return canonical link type name (case-insensitive exact match).""" + q = query.lower() + for lt in link_types: + if lt["name"].lower() == q: + return lt["name"] + available = ", ".join(lt["name"] for lt in link_types) + raise NoMatch(f"unknown link type {query!r}; available: {available}") diff --git a/tests/test_match.py b/tests/test_match.py index 165d2f4..bd6e997 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -1,5 +1,5 @@ import pytest -from bjira._match import match_transition, AmbiguousMatch, NoMatch +from bjira._match import match_transition, resolve_link_type, AmbiguousMatch, NoMatch TRANSITIONS = [ @@ -46,3 +46,25 @@ def test_exact_name_wins_over_substring(): def test_numeric_id_not_in_list_raises(): with pytest.raises(NoMatch): match_transition("9999", TRANSITIONS) + + +LINK_TYPES = [ + {"name": "Blocks", "inward": "blocked by", "outward": "blocks"}, + {"name": "Relates", "inward": "relates to", "outward": "relates to"}, + {"name": "Duplicate", "inward": "is duplicated by", "outward": "duplicates"}, +] + + +def test_resolve_link_type_exact(): + assert resolve_link_type("Blocks", LINK_TYPES) == "Blocks" + + +def test_resolve_link_type_case_insensitive(): + assert resolve_link_type("blocks", LINK_TYPES) == "Blocks" + assert resolve_link_type("BLOCKS", LINK_TYPES) == "Blocks" + + +def test_resolve_link_type_unknown_raises(): + with pytest.raises(NoMatch) as exc: + resolve_link_type("notarealtype", LINK_TYPES) + assert "Blocks" in str(exc.value) From 6275445dc7eede196ce524d543ac978ed1d29b1c Mon Sep 17 00:00:00 2001 From: wiliam Date: Thu, 23 Apr 2026 22:23:28 +0800 Subject: [PATCH 07/24] Add _text helpers: resolve_body_source, format_comment --- bjira/_text.py | 30 +++++++++++++++++++++++++++++ tests/test_text.py | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 bjira/_text.py create mode 100644 tests/test_text.py diff --git a/bjira/_text.py b/bjira/_text.py new file mode 100644 index 0000000..cd5dfd7 --- /dev/null +++ b/bjira/_text.py @@ -0,0 +1,30 @@ +from pathlib import Path + + +def resolve_body_source(positional, from_file): + """Pick exactly one source and return the body text.""" + if positional is not None and from_file: + raise ValueError("positional body and --from-file are mutually exclusive") + if positional is not None: + if positional == "": + raise ValueError("body is empty") + return positional + if from_file: + try: + return Path(from_file).read_text() + except OSError as exc: + raise ValueError(f"cannot read {from_file}: {exc}") from exc + raise ValueError("body is required: pass as positional arg or --from-file PATH") + + +def format_comment(c): + """Format a jira.Comment or dict-like into the display form from spec.""" + created = c["created"] if isinstance(c, dict) else c.created + author = (c["author"]["displayName"] if isinstance(c, dict) else c.author.displayName) + body = c["body"] if isinstance(c, dict) else c.body + cid = c["id"] if isinstance(c, dict) else c.id + # created like "2026-04-23T20:59:12.000+0300" → "2026-04-23 20:59" + date_part, time_part = created.split("T") + hhmm = time_part[:5] + header = f"[{date_part} {hhmm}] {author} (id={cid}):" + return f"{header}\n{body}\n---\n" diff --git a/tests/test_text.py b/tests/test_text.py new file mode 100644 index 0000000..ee76120 --- /dev/null +++ b/tests/test_text.py @@ -0,0 +1,47 @@ +import pytest +from bjira._text import resolve_body_source, format_comment + + +def test_resolve_body_from_positional(tmp_path): + assert resolve_body_source(positional="hi there", from_file=None) == "hi there" + + +def test_resolve_body_from_file(tmp_path): + p = tmp_path / "body.md" + p.write_text("file body\nline 2") + assert resolve_body_source(positional=None, from_file=str(p)) == "file body\nline 2" + + +def test_resolve_body_rejects_both_sources(): + with pytest.raises(ValueError, match="mutually exclusive"): + resolve_body_source(positional="x", from_file="/tmp/x") + + +def test_resolve_body_rejects_neither_source(): + with pytest.raises(ValueError, match="required"): + resolve_body_source(positional=None, from_file=None) + + +def test_resolve_body_rejects_empty_positional(): + with pytest.raises(ValueError, match="empty"): + resolve_body_source(positional="", from_file=None) + + +def test_resolve_body_rejects_missing_file(): + with pytest.raises(ValueError, match="cannot read"): + resolve_body_source(positional=None, from_file="/no/such/path.md") + + +def test_format_comment_basic(): + c = { + "id": "11506156", + "created": "2026-04-23T20:59:12.000+0300", + "author": {"displayName": "Молтянинов Илья"}, + "body": "test body", + } + out = format_comment(c) + assert "[2026-04-23 20:59]" in out + assert "Молтянинов Илья" in out + assert "(id=11506156)" in out + assert "test body" in out + assert out.endswith("---\n") or out.endswith("---") From cc9869aed8441dffcfb7f3ef71fc378cc3ed18ea Mon Sep 17 00:00:00 2001 From: wiliam Date: Thu, 23 Apr 2026 22:35:04 +0800 Subject: [PATCH 08/24] Add bjira comment command --- bjira/operations/comment.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 bjira/operations/comment.py diff --git a/bjira/operations/comment.py b/bjira/operations/comment.py new file mode 100644 index 0000000..56a0855 --- /dev/null +++ b/bjira/operations/comment.py @@ -0,0 +1,35 @@ +import sys + +from jira import JIRAError + +from bjira.operations import BJiraOperation +from bjira._text import resolve_body_source + + +class Operation(BJiraOperation): + + def configure_arg_parser(self, subparsers): + parser = subparsers.add_parser("comment", help="add a comment to an issue") + parser.add_argument("key", help="issue key, e.g. PORTFOLIO-53307") + parser.add_argument("body", nargs="?", default=None, help="comment body") + parser.add_argument("--from-file", dest="from_file", default=None, + help="read body from file") + parser.set_defaults(func=self._run) + + def _run(self, args): + try: + body = resolve_body_source(args.body, args.from_file) + except ValueError as exc: + print(f"ERROR: comment {args.key}: {exc}", file=sys.stderr) + sys.exit(2) + + jira = self.get_jira_api() + try: + comment = jira.add_comment(args.key, body) + except JIRAError as exc: + print(f"ERROR: comment {args.key}: {exc.status_code} {exc.text}", + file=sys.stderr) + sys.exit(3) + + host = self.get_config()["host"] + print(f"comment id={comment.id} url={host}/browse/{args.key}?focusedCommentId={comment.id}") From 81b3753a9db9c3308546072c4cb1c562a4879daf Mon Sep 17 00:00:00 2001 From: wiliam Date: Thu, 23 Apr 2026 22:35:57 +0800 Subject: [PATCH 09/24] Add bjira comments command (list recent) --- bjira/operations/comments.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 bjira/operations/comments.py diff --git a/bjira/operations/comments.py b/bjira/operations/comments.py new file mode 100644 index 0000000..0cb6e61 --- /dev/null +++ b/bjira/operations/comments.py @@ -0,0 +1,29 @@ +import sys + +from jira import JIRAError + +from bjira.operations import BJiraOperation +from bjira._text import format_comment + + +class Operation(BJiraOperation): + + def configure_arg_parser(self, subparsers): + parser = subparsers.add_parser("comments", help="list recent comments") + parser.add_argument("key", help="issue key") + parser.add_argument("-n", dest="count", type=int, default=5, + help="how many newest comments to show (default 5)") + parser.set_defaults(func=self._run) + + def _run(self, args): + jira = self.get_jira_api() + try: + all_comments = jira.comments(args.key) + except JIRAError as exc: + print(f"ERROR: comments {args.key}: {exc.status_code} {exc.text}", + file=sys.stderr) + sys.exit(3) + + recent = list(reversed(all_comments))[:args.count] + for c in recent: + sys.stdout.write(format_comment(c)) From 49600cb341f8b65d8f272ff0bc561f4096295bfc Mon Sep 17 00:00:00 2001 From: wiliam Date: Thu, 23 Apr 2026 22:38:08 +0800 Subject: [PATCH 10/24] Add bjira status command with fuzzy transition match --- bjira/operations/status.py | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 bjira/operations/status.py diff --git a/bjira/operations/status.py b/bjira/operations/status.py new file mode 100644 index 0000000..f39d461 --- /dev/null +++ b/bjira/operations/status.py @@ -0,0 +1,43 @@ +import sys + +from jira import JIRAError + +from bjira.operations import BJiraOperation +from bjira._match import match_transition, AmbiguousMatch, NoMatch + + +class Operation(BJiraOperation): + + def configure_arg_parser(self, subparsers): + parser = subparsers.add_parser("status", help="transition an issue") + parser.add_argument("key", help="issue key") + parser.add_argument("transition", help="transition id, name, or substring") + parser.set_defaults(func=self._run) + + def _run(self, args): + jira = self.get_jira_api() + try: + transitions = jira.transitions(args.key) + issue = jira.issue(args.key, fields="status") + except JIRAError as exc: + print(f"ERROR: status {args.key}: {exc.status_code} {exc.text}", + file=sys.stderr) + sys.exit(3) + + try: + tid = match_transition(args.transition, transitions) + except (AmbiguousMatch, NoMatch) as exc: + print(f"ERROR: status {args.key} {args.transition!r}: {exc}", + file=sys.stderr) + sys.exit(3) + + old_status = issue.fields.status.name + try: + jira.transition_issue(args.key, tid) + except JIRAError as exc: + print(f"ERROR: status {args.key}: {exc.status_code} {exc.text}", + file=sys.stderr) + sys.exit(3) + + new_issue = jira.issue(args.key, fields="status") + print(f"{args.key}: {old_status} → {new_issue.fields.status.name}") From b496483ae5cb7569a73a5264fa1778a208b502fa Mon Sep 17 00:00:00 2001 From: wiliam Date: Thu, 23 Apr 2026 22:39:09 +0800 Subject: [PATCH 11/24] Add bjira link command --- bjira/operations/link.py | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 bjira/operations/link.py diff --git a/bjira/operations/link.py b/bjira/operations/link.py new file mode 100644 index 0000000..c0a42ab --- /dev/null +++ b/bjira/operations/link.py @@ -0,0 +1,43 @@ +import sys + +from jira import JIRAError + +from bjira.operations import BJiraOperation +from bjira._match import resolve_link_type, NoMatch + + +class Operation(BJiraOperation): + + def configure_arg_parser(self, subparsers): + parser = subparsers.add_parser("link", help="link two existing issues") + parser.add_argument("from_key", help="source issue key") + parser.add_argument("link_type", help="link type name, e.g. Blocks") + parser.add_argument("to_key", help="target issue key") + parser.set_defaults(func=self._run) + + def _run(self, args): + jira = self.get_jira_api() + try: + link_types = [ + {"name": lt.name, "inward": lt.inward, "outward": lt.outward} + for lt in jira.issue_link_types() + ] + except JIRAError as exc: + print(f"ERROR: link: {exc.status_code} {exc.text}", file=sys.stderr) + sys.exit(3) + + try: + canonical = resolve_link_type(args.link_type, link_types) + except NoMatch as exc: + print(f"ERROR: link: {exc}", file=sys.stderr) + sys.exit(3) + + outward_verb = next(lt["outward"] for lt in link_types if lt["name"] == canonical) + + try: + jira.create_issue_link(canonical, inwardIssue=args.to_key, outwardIssue=args.from_key) + except JIRAError as exc: + print(f"ERROR: link: {exc.status_code} {exc.text}", file=sys.stderr) + sys.exit(3) + + print(f"{args.from_key} {outward_verb} {args.to_key}") From 0c58efb61edd5371383a8089b00e36e136fac61f Mon Sep 17 00:00:00 2001 From: wiliam Date: Thu, 23 Apr 2026 22:41:07 +0800 Subject: [PATCH 12/24] Add bjira edit command with force-gated summary/description --- bjira/operations/edit.py | 128 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 bjira/operations/edit.py diff --git a/bjira/operations/edit.py b/bjira/operations/edit.py new file mode 100644 index 0000000..d808957 --- /dev/null +++ b/bjira/operations/edit.py @@ -0,0 +1,128 @@ +import sys + +from jira import JIRAError + +from bjira.operations import BJiraOperation +from bjira._fields import merge_labels, parse_due, should_require_force +from bjira._text import resolve_body_source + + +STORY_POINTS_FIELD = "customfield_11212" + + +class Operation(BJiraOperation): + + def configure_arg_parser(self, subparsers): + parser = subparsers.add_parser("edit", help="edit fields on an issue") + parser.add_argument("key", help="issue key") + parser.add_argument("--summary", default=None) + parser.add_argument("--description", default=None) + parser.add_argument("--description-file", dest="description_file", default=None) + parser.add_argument("--due", default=None, help="YYYY-MM-DD or 'none' to clear") + parser.add_argument("--assignee", default=None, help="login or 'none' to unassign") + parser.add_argument("--label", dest="labels", action="append", default=[], + help="repeatable; merged with existing") + parser.add_argument("--labels-clear", dest="labels_clear", action="store_true") + parser.add_argument("--sp", dest="story_points", type=float, default=None) + parser.add_argument("--force", action="store_true", + help="allow overwriting non-empty summary/description") + parser.set_defaults(func=self._run) + + def _run(self, args): + if args.description is not None and args.description_file is not None: + self._fail_args("--description and --description-file are mutually exclusive") + + wants_any = any([ + args.summary is not None, + args.description is not None, + args.description_file is not None, + args.due is not None, + args.assignee is not None, + args.labels, + args.labels_clear, + args.story_points is not None, + ]) + if not wants_any: + self._fail_args("nothing to update; pass at least one of " + "--summary/--description/--description-file/--due/" + "--assignee/--label/--labels-clear/--sp") + + if args.description_file is not None: + try: + description = resolve_body_source(None, args.description_file) + except ValueError as exc: + self._fail_args(str(exc)) + else: + description = args.description + + due_payload_set = False + due_value = None + if args.due is not None: + try: + due_value = parse_due(args.due) + except ValueError as exc: + self._fail_args(str(exc)) + due_payload_set = True + + jira = self.get_jira_api() + try: + issue = jira.issue(args.key, fields="summary,description,labels") + except JIRAError as exc: + self._fail_api(args.key, exc) + + summary_changes = (args.summary is not None + and args.summary != (issue.fields.summary or "")) + description_changes = (description is not None + and description != (issue.fields.description or "")) + + if summary_changes and should_require_force( + "summary", issue.fields.summary or "", args.force): + self._fail_args( + f"existing summary is not empty ({len(issue.fields.summary)} chars); " + "pass --force to overwrite") + if description_changes and should_require_force( + "description", issue.fields.description or "", args.force): + self._fail_args( + f"existing description is not empty ({len(issue.fields.description)} chars); " + "pass --force to overwrite") + + fields = {} + if summary_changes: + fields["summary"] = args.summary + if description_changes: + fields["description"] = description + if due_payload_set: + fields["duedate"] = due_value + if args.assignee is not None: + fields["assignee"] = None if args.assignee == "none" else {"name": args.assignee} + if args.labels or args.labels_clear: + merged = merge_labels(list(issue.fields.labels or []), args.labels, args.labels_clear) + if merged is not None: + fields["labels"] = merged + if args.story_points is not None: + fields[STORY_POINTS_FIELD] = args.story_points + + if not fields: + print(f"{args.key}: no changes") + return + + try: + issue.update(fields=fields) + except JIRAError as exc: + self._fail_api(args.key, exc) + + parts = [] + for k, v in fields.items(): + if k == "description": + parts.append(f"description=<{len(v)} chars>" if v else "description=cleared") + else: + parts.append(f"{k}={v}") + print(f"OK: {args.key} {', '.join(parts)}") + + def _fail_args(self, msg): + print(f"ERROR: edit: {msg}", file=sys.stderr) + sys.exit(2) + + def _fail_api(self, key, exc): + print(f"ERROR: edit {key}: {exc.status_code} {exc.text}", file=sys.stderr) + sys.exit(3) From c0bd922fe6a587ec72d69afd317f7e0c7788d530 Mon Sep 17 00:00:00 2001 From: wiliam Date: Thu, 23 Apr 2026 22:41:38 +0800 Subject: [PATCH 13/24] Add -v/--verbose to enable SDK debug logging --- bjira/main.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bjira/main.py b/bjira/main.py index 18de43f..0bf98ce 100755 --- a/bjira/main.py +++ b/bjira/main.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import argparse +import logging from importlib import import_module from pkgutil import walk_packages @@ -8,6 +9,8 @@ def _parse_args(): parser = argparse.ArgumentParser(description='jira helper') + parser.add_argument('-v', '--verbose', action='store_true', + help='print HTTP calls and payloads to stderr') subparsers = parser.add_subparsers(help='sub-command help', required=True) for module_info in walk_packages(bjira.operations.__path__, bjira.operations.__name__ + '.'): @@ -18,6 +21,9 @@ def _parse_args(): def main(): args = _parse_args() + if getattr(args, 'verbose', False): + logging.basicConfig(level=logging.DEBUG) + logging.getLogger('urllib3').setLevel(logging.WARNING) args.func(args) From 20234af54004fb56295d6625655948222433ae67 Mon Sep 17 00:00:00 2001 From: wiliam Date: Thu, 23 Apr 2026 22:51:36 +0800 Subject: [PATCH 14/24] Add live smoke script for project-ops commands --- tests/smoke.py | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/smoke.py diff --git a/tests/smoke.py b/tests/smoke.py new file mode 100644 index 0000000..2bd6f26 --- /dev/null +++ b/tests/smoke.py @@ -0,0 +1,61 @@ +"""Live smoke test against jira.hh.ru. Run manually after substantial changes. + +Usage: ~/.local/pipx/venvs/bjira/bin/python tests/smoke.py +""" +import json +import subprocess +from pathlib import Path + +import keyring +from jira import JIRA + +CONFIG = json.loads((Path.home() / ".bjira_config").read_text()) +USER = CONFIG["user"] +HOST = CONFIG["host"] +ISSUE = "PORTFOLIO-53307" + +jira = JIRA(server=HOST, basic_auth=(USER, keyring.get_password("bjira", USER))) + + +def run(cmd): + print(f"$ {' '.join(cmd)}") + r = subprocess.run(cmd, capture_output=True, text=True) + print(r.stdout) + if r.returncode != 0: + print(f" stderr: {r.stderr}") + return r + + +def main(): + print("=== 1. comments (read) ===") + r = run(["bjira", "comments", ISSUE, "-n", "1"]) + assert r.returncode == 0, "comments read failed" + + print("\n=== 2. comment add → delete ===") + r = run(["bjira", "comment", ISSUE, "[SMOKE] test — auto-delete"]) + assert r.returncode == 0, "comment add failed" + cid = r.stdout.split("id=")[1].split()[0] + jira.comment(ISSUE, cid).delete() + print(f" deleted comment {cid}") + + print("\n=== 3. edit --summary (no-op) ===") + current = jira.issue(ISSUE, fields="summary").fields.summary + r = run(["bjira", "edit", ISSUE, "--summary", current]) + assert r.returncode == 0, "no-op summary failed" + assert "no changes" in r.stdout, f"expected 'no changes' in output, got: {r.stdout!r}" + + print("\n=== 4. status — unknown transition check ===") + r = run(["bjira", "status", ISSUE, "nonexistent"]) + assert r.returncode == 3, f"expected exit 3, got {r.returncode}" + print(" correctly returned exit 3 for unknown transition") + + print("\n=== 5. link — unknown type check ===") + r = run(["bjira", "link", ISSUE, "NotARealType", ISSUE]) + assert r.returncode == 3, f"expected exit 3, got {r.returncode}" + print(" correctly returned exit 3 for unknown link type") + + print("\n=== all smoke checks passed ===") + + +if __name__ == "__main__": + main() From 3368a5da9b6257f7da469d5aab290618b384c0b9 Mon Sep 17 00:00:00 2001 From: wiliam Date: Thu, 23 Apr 2026 22:52:21 +0800 Subject: [PATCH 15/24] Document project-ops commands in README --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index ad5b997..6dddb01 100644 --- a/README.md +++ b/README.md @@ -107,3 +107,29 @@ bjira setpass ~ » bjira view # Открыть задачку в браузере, имя задачки взять из ветки гит-репозитория ``` + +### Project-ops commands (fork extension) + +```shell script +~ » bjira edit PORTFOLIO-53307 --due 2026-05-15 # set Due Date +~ » bjira edit PORTFOLIO-53307 --description-file ./spec.md --force # overwrite description from file +~ » bjira edit PORTFOLIO-53307 --label urgent --label q2-2026 # merge labels with existing +~ » bjira edit PORTFOLIO-53307 --labels-clear # remove all labels +~ » bjira edit PORTFOLIO-53307 --assignee i.moltyaninov # reassign +~ » bjira edit PORTFOLIO-53307 --assignee none # unassign + +~ » bjira comment PORTFOLIO-53307 "готово, ждём ревью" # add comment +~ » bjira comment PORTFOLIO-53307 --from-file ./update.md # add comment from file + +~ » bjira comments PORTFOLIO-53307 # list last 5 comments +~ » bjira comments PORTFOLIO-53307 -n 20 # list last 20 + +~ » bjira status PORTFOLIO-53307 "Development: In progress" # transition (fuzzy match) +~ » bjira status PORTFOLIO-53307 1501 # transition by id + +~ » bjira link PORTFOLIO-53307 Blocks PORTFOLIO-54000 # link existing issues +``` + +**Exit codes:** `0` success, `2` arg error, `3` API error (auth/permission/not-found, ambiguous/unknown match). Pass `-v` for SDK debug logging. + +**Safety:** `--force` is required for `edit --description` and `edit --summary` when the existing value is non-empty. All other operations are additive or trivially reversible via Jira history. From 9e5340f97a135ab4421a2510de4ed3465ce085f0 Mon Sep 17 00:00:00 2001 From: wiliam Date: Thu, 23 Apr 2026 23:57:59 +0800 Subject: [PATCH 16/24] Add --list flag to bjira link --- bjira/operations/link.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/bjira/operations/link.py b/bjira/operations/link.py index c0a42ab..716f885 100644 --- a/bjira/operations/link.py +++ b/bjira/operations/link.py @@ -10,9 +10,11 @@ class Operation(BJiraOperation): def configure_arg_parser(self, subparsers): parser = subparsers.add_parser("link", help="link two existing issues") - parser.add_argument("from_key", help="source issue key") - parser.add_argument("link_type", help="link type name, e.g. Blocks") - parser.add_argument("to_key", help="target issue key") + parser.add_argument("from_key", nargs="?", default=None, help="source issue key") + parser.add_argument("link_type", nargs="?", default=None, help="link type name, e.g. Blocks") + parser.add_argument("to_key", nargs="?", default=None, help="target issue key") + parser.add_argument("--list", dest="list_types", action="store_true", + help="list available link types and exit") parser.set_defaults(func=self._run) def _run(self, args): @@ -26,6 +28,17 @@ def _run(self, args): print(f"ERROR: link: {exc.status_code} {exc.text}", file=sys.stderr) sys.exit(3) + if args.list_types: + max_name = max(len(lt["name"]) for lt in link_types) + for lt in link_types: + print(f"{lt['name']:<{max_name}} ({lt['inward']} / {lt['outward']})") + return + + if not (args.from_key and args.link_type and args.to_key): + print("ERROR: link: FROM, TYPE, and TO are required (or pass --list)", + file=sys.stderr) + sys.exit(2) + try: canonical = resolve_link_type(args.link_type, link_types) except NoMatch as exc: From 44f3a449cc89b2ccb51dad70806566337d83dd13 Mon Sep 17 00:00:00 2001 From: wiliam Date: Fri, 24 Apr 2026 00:00:04 +0800 Subject: [PATCH 17/24] Add FIELD_SPECS map, build_fields_payload, parse_set_arg, SET_DENY --- bjira/_fields.py | 53 +++++++++++++++++++++++++++++++++++++ tests/test_fields.py | 63 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/bjira/_fields.py b/bjira/_fields.py index b374c95..2425e13 100644 --- a/bjira/_fields.py +++ b/bjira/_fields.py @@ -51,3 +51,56 @@ def should_require_force(field, current, force): if not current or not current.strip(): return False return True + + +# --- map-driven editing --- + +# flag_name -> (jira_field_id, to_payload, clear_payload) +# to_payload: raw CLI string value -> Jira payload for that field +# clear_payload: what to send when user passes "none" ([] for arrays, None for strings) +FIELD_SPECS = { + "summary": ("summary", lambda v: v, None), + "description": ("description", lambda v: v, None), + "due": ("duedate", parse_due, None), + "assignee": ("assignee", lambda v: {"name": v}, None), + "priority": ("priority", lambda v: {"name": v}, None), + "version": ("fixVersions", lambda v: [{"name": v}], []), + "team": ("customfield_34238", lambda v: [{"value": v}], []), + "sp": ("customfield_11212", float, None), +} + +SET_DENY = { + "status", "resolution", + "issuetype", "project", + "created", "updated", "creator", "reporter", + "issuelinks", "subtasks", +} + + +def build_fields_payload(specs, values): + """Build the Jira `fields` dict from a FIELD_SPECS-shaped map and CLI values. + + `values` is {flag_name: raw_string_or_None}. Skipped when None. + Value "none" triggers clear_payload. + """ + payload = {} + for flag, (jira_field, to_payload, clear) in specs.items(): + v = values.get(flag) + if v is None: + continue + payload[jira_field] = clear if v == "none" else to_payload(v) + return payload + + +def parse_set_arg(s): + """Parse a `--set key=value` argument. Returns (jira_field_id, raw_value_string).""" + if "=" not in s: + raise ValueError(f"--set expects key=value, got {s!r}") + key, value = s.split("=", 1) + key = key.strip() + if key in SET_DENY: + raise ValueError( + f"--set: field {key!r} is not editable via bjira " + f"(status/resolution → use 'bjira status'; others are managed by Jira)" + ) + return key, value diff --git a/tests/test_fields.py b/tests/test_fields.py index f62f5aa..21fedd7 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,6 +1,14 @@ import pytest -from bjira._fields import merge_labels, parse_due, should_require_force +from bjira._fields import ( + FIELD_SPECS, + SET_DENY, + build_fields_payload, + merge_labels, + parse_due, + parse_set_arg, + should_require_force, +) def test_merge_labels_adds_new(): @@ -74,3 +82,56 @@ def test_force_not_required_for_other_fields(): assert should_require_force("duedate", current="2026-01-01", force=False) is False assert should_require_force("labels", current=["a"], force=False) is False assert should_require_force("assignee", current="someone", force=False) is False + + +def test_field_specs_has_expected_flags(): + expected = {"summary", "description", "due", "assignee", "priority", + "version", "team", "sp"} + assert expected.issubset(FIELD_SPECS.keys()) + + +def test_build_fields_payload_version(): + specs = {"version": ("fixVersions", lambda v: [{"name": v}], [])} + assert build_fields_payload(specs, {"version": "2026Q2"}) == \ + {"fixVersions": [{"name": "2026Q2"}]} + + +def test_build_fields_payload_clear_value(): + specs = {"version": ("fixVersions", lambda v: [{"name": v}], [])} + assert build_fields_payload(specs, {"version": "none"}) == {"fixVersions": []} + + +def test_build_fields_payload_clear_value_with_none_sentinel(): + specs = {"due": ("duedate", lambda v: v, None)} + assert build_fields_payload(specs, {"due": "none"}) == {"duedate": None} + + +def test_build_fields_payload_skips_unset_flags(): + specs = { + "version": ("fixVersions", lambda v: [{"name": v}], []), + "team": ("customfield_34238", lambda v: [{"value": v}], []), + } + assert build_fields_payload(specs, {"version": "2026Q2", "team": None}) == \ + {"fixVersions": [{"name": "2026Q2"}]} + + +def test_parse_set_arg_simple(): + assert parse_set_arg("customfield_99=hello") == ("customfield_99", "hello") + + +def test_parse_set_arg_with_equals_in_value(): + assert parse_set_arg("customfield_99=a=b=c") == ("customfield_99", "a=b=c") + + +def test_parse_set_arg_rejects_missing_equals(): + with pytest.raises(ValueError, match="--set expects"): + parse_set_arg("customfield_99") + + +def test_parse_set_arg_rejects_denied_field(): + with pytest.raises(ValueError, match="not editable via bjira"): + parse_set_arg("status=Done") + + +def test_set_deny_includes_workflow_fields(): + assert {"status", "resolution", "issuetype", "project"}.issubset(SET_DENY) From e64c073da3e64465152c19c578d3493eb735004c Mon Sep 17 00:00:00 2001 From: wiliam Date: Fri, 24 Apr 2026 00:06:12 +0800 Subject: [PATCH 18/24] Refactor bjira edit to FIELD_SPECS map with --set escape hatch --- bjira/operations/edit.py | 111 ++++++++++++++++++++------------------- 1 file changed, 57 insertions(+), 54 deletions(-) diff --git a/bjira/operations/edit.py b/bjira/operations/edit.py index d808957..8c87c76 100644 --- a/bjira/operations/edit.py +++ b/bjira/operations/edit.py @@ -3,27 +3,33 @@ from jira import JIRAError from bjira.operations import BJiraOperation -from bjira._fields import merge_labels, parse_due, should_require_force +from bjira._fields import ( + FIELD_SPECS, + build_fields_payload, + merge_labels, + parse_set_arg, + should_require_force, +) from bjira._text import resolve_body_source -STORY_POINTS_FIELD = "customfield_11212" - - class Operation(BJiraOperation): def configure_arg_parser(self, subparsers): parser = subparsers.add_parser("edit", help="edit fields on an issue") parser.add_argument("key", help="issue key") - parser.add_argument("--summary", default=None) - parser.add_argument("--description", default=None) + + for flag in FIELD_SPECS: + parser.add_argument(f"--{flag}", default=None, + help=f"set '{flag}' (value or 'none' to clear)") + parser.add_argument("--description-file", dest="description_file", default=None) - parser.add_argument("--due", default=None, help="YYYY-MM-DD or 'none' to clear") - parser.add_argument("--assignee", default=None, help="login or 'none' to unassign") parser.add_argument("--label", dest="labels", action="append", default=[], help="repeatable; merged with existing") parser.add_argument("--labels-clear", dest="labels_clear", action="store_true") - parser.add_argument("--sp", dest="story_points", type=float, default=None) + parser.add_argument("--set", dest="set_pairs", action="append", default=[], + metavar="KEY=VALUE", + help="escape hatch for fields not in the map; repeatable") parser.add_argument("--force", action="store_true", help="allow overwriting non-empty summary/description") parser.set_defaults(func=self._run) @@ -32,37 +38,33 @@ def _run(self, args): if args.description is not None and args.description_file is not None: self._fail_args("--description and --description-file are mutually exclusive") - wants_any = any([ - args.summary is not None, - args.description is not None, - args.description_file is not None, - args.due is not None, - args.assignee is not None, - args.labels, - args.labels_clear, - args.story_points is not None, - ]) - if not wants_any: - self._fail_args("nothing to update; pass at least one of " - "--summary/--description/--description-file/--due/" - "--assignee/--label/--labels-clear/--sp") + map_values = {flag: getattr(args, flag, None) for flag in FIELD_SPECS} if args.description_file is not None: try: - description = resolve_body_source(None, args.description_file) + map_values["description"] = resolve_body_source(None, args.description_file) except ValueError as exc: self._fail_args(str(exc)) - else: - description = args.description - due_payload_set = False - due_value = None - if args.due is not None: + set_pairs = [] + for raw in args.set_pairs: try: - due_value = parse_due(args.due) + set_pairs.append(parse_set_arg(raw)) except ValueError as exc: self._fail_args(str(exc)) - due_payload_set = True + + wants_any = ( + any(v is not None for v in map_values.values()) + or args.labels + or args.labels_clear + or set_pairs + ) + if not wants_any: + self._fail_args( + "nothing to update; pass at least one of " + + ", ".join(f"--{f}" for f in FIELD_SPECS) + + ", --description-file, --label, --labels-clear, --set KEY=VALUE" + ) jira = self.get_jira_api() try: @@ -70,37 +72,38 @@ def _run(self, args): except JIRAError as exc: self._fail_api(args.key, exc) - summary_changes = (args.summary is not None - and args.summary != (issue.fields.summary or "")) - description_changes = (description is not None - and description != (issue.fields.description or "")) - - if summary_changes and should_require_force( - "summary", issue.fields.summary or "", args.force): + if map_values.get("summary") is not None \ + and map_values["summary"] != (issue.fields.summary or "") \ + and should_require_force("summary", issue.fields.summary or "", args.force): self._fail_args( f"existing summary is not empty ({len(issue.fields.summary)} chars); " - "pass --force to overwrite") - if description_changes and should_require_force( - "description", issue.fields.description or "", args.force): + "pass --force to overwrite" + ) + if map_values.get("description") is not None \ + and map_values["description"] != (issue.fields.description or "") \ + and should_require_force("description", issue.fields.description or "", args.force): self._fail_args( f"existing description is not empty ({len(issue.fields.description)} chars); " - "pass --force to overwrite") - - fields = {} - if summary_changes: - fields["summary"] = args.summary - if description_changes: - fields["description"] = description - if due_payload_set: - fields["duedate"] = due_value - if args.assignee is not None: - fields["assignee"] = None if args.assignee == "none" else {"name": args.assignee} + "pass --force to overwrite" + ) + + try: + fields = build_fields_payload(FIELD_SPECS, map_values) + except ValueError as exc: + self._fail_args(str(exc)) + + if fields.get("summary") == (issue.fields.summary or ""): + fields.pop("summary", None) + if fields.get("description") == (issue.fields.description or ""): + fields.pop("description", None) + if args.labels or args.labels_clear: merged = merge_labels(list(issue.fields.labels or []), args.labels, args.labels_clear) if merged is not None: fields["labels"] = merged - if args.story_points is not None: - fields[STORY_POINTS_FIELD] = args.story_points + + for key, value in set_pairs: + fields[key] = value if not fields: print(f"{args.key}: no changes") From c99c5b30e4e21d31d49f4e0432daaf1a31320f19 Mon Sep 17 00:00:00 2001 From: wiliam Date: Fri, 24 Apr 2026 00:07:28 +0800 Subject: [PATCH 19/24] Add bjira edit --list-fields [--json] using editmeta --- bjira/operations/edit.py | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/bjira/operations/edit.py b/bjira/operations/edit.py index 8c87c76..105fbe1 100644 --- a/bjira/operations/edit.py +++ b/bjira/operations/edit.py @@ -1,3 +1,4 @@ +import json import sys from jira import JIRAError @@ -32,9 +33,16 @@ def configure_arg_parser(self, subparsers): help="escape hatch for fields not in the map; repeatable") parser.add_argument("--force", action="store_true", help="allow overwriting non-empty summary/description") + parser.add_argument("--list-fields", dest="list_fields", action="store_true", + help="list editable fields for this issue and exit") + parser.add_argument("--json", dest="as_json", action="store_true", + help="with --list-fields, emit JSON instead of a table") parser.set_defaults(func=self._run) def _run(self, args): + if args.list_fields: + return self._list_fields(args.key, args.as_json) + if args.description is not None and args.description_file is not None: self._fail_args("--description and --description-file are mutually exclusive") @@ -122,6 +130,47 @@ def _run(self, args): parts.append(f"{k}={v}") print(f"OK: {args.key} {', '.join(parts)}") + def _list_fields(self, key, as_json): + jira = self.get_jira_api() + try: + meta = jira.editmeta(key) + except JIRAError as exc: + self._fail_api(key, exc) + + rows = [] + for fid, spec in (meta.get("fields") or {}).items(): + name = spec.get("name", fid) + schema = spec.get("schema") or {} + ftype = schema.get("type", "?") + ops = ",".join(spec.get("operations") or []) + allowed = spec.get("allowedValues") or [] + allowed_display = ", ".join( + (av.get("name") or av.get("value") or av.get("key") or str(av)) + for av in allowed[:10] + ) + if len(allowed) > 10: + allowed_display += f", ... ({len(allowed)} total)" + rows.append({"field": name, "id": fid, "type": ftype, + "ops": ops, "allowed": allowed_display}) + + if as_json: + print(json.dumps(rows, ensure_ascii=False, indent=2)) + return + + if not rows: + print(f"{key}: no editable fields (check permissions?)") + return + + widths = {col: max(len(col), max(len(str(r[col])) for r in rows)) + for col in ("field", "id", "type", "ops")} + header = (f"{'field':<{widths['field']}} {'id':<{widths['id']}} " + f"{'type':<{widths['type']}} {'ops':<{widths['ops']}} allowed") + print(header) + print("-" * len(header)) + for r in rows: + print(f"{r['field']:<{widths['field']}} {r['id']:<{widths['id']}} " + f"{r['type']:<{widths['type']}} {r['ops']:<{widths['ops']}} {r['allowed']}") + def _fail_args(self, msg): print(f"ERROR: edit: {msg}", file=sys.stderr) sys.exit(2) From c32c8ae015cac95e9d8e37f0216e80397cea76e2 Mon Sep 17 00:00:00 2001 From: wiliam Date: Fri, 24 Apr 2026 00:10:07 +0800 Subject: [PATCH 20/24] Update smoke and README for hybrid edit + --list-fields + link --list --- README.md | 7 +++++++ tests/smoke.py | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/README.md b/README.md index 6dddb01..9155019 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,13 @@ bjira setpass ~ » bjira status PORTFOLIO-53307 1501 # transition by id ~ » bjira link PORTFOLIO-53307 Blocks PORTFOLIO-54000 # link existing issues +~ » bjira link --list # show available link types + +~ » bjira edit PORTFOLIO-53307 --version 2026Q2 --team Конверсы # map-driven fields +~ » bjira edit PORTFOLIO-53307 --priority Major # named flag from FIELD_SPECS +~ » bjira edit PORTFOLIO-53307 --set customfield_99999=hello # escape hatch for rare fields +~ » bjira edit PORTFOLIO-53307 --list-fields # editable fields + allowed enum values +~ » bjira edit PORTFOLIO-53307 --list-fields --json # same, as JSON ``` **Exit codes:** `0` success, `2` arg error, `3` API error (auth/permission/not-found, ambiguous/unknown match). Pass `-v` for SDK debug logging. diff --git a/tests/smoke.py b/tests/smoke.py index 2bd6f26..1df1009 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -54,6 +54,18 @@ def main(): assert r.returncode == 3, f"expected exit 3, got {r.returncode}" print(" correctly returned exit 3 for unknown link type") + print("\n=== 6. edit --list-fields (metadata read) ===") + r = run(["bjira", "edit", ISSUE, "--list-fields"]) + assert r.returncode == 0, "list-fields failed" + assert "field" in r.stdout, "expected header in list-fields output" + assert "fixVersions" in r.stdout or "customfield" in r.stdout, \ + "expected fixVersions or a customfield row" + + print("\n=== 7. link --list ===") + r = run(["bjira", "link", "--list"]) + assert r.returncode == 0, "link --list failed" + assert "Relation" in r.stdout, "expected Relation in link types" + print("\n=== all smoke checks passed ===") From 8213a87bde4217e85944678351cab44e65bc7a29 Mon Sep 17 00:00:00 2001 From: wiliam Date: Mon, 27 Apr 2026 22:58:06 +0800 Subject: [PATCH 21/24] Add get_fieldsets to BJiraOperation --- bjira/operations/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bjira/operations/__init__.py b/bjira/operations/__init__.py index db9ab90..bbf99b9 100644 --- a/bjira/operations/__init__.py +++ b/bjira/operations/__init__.py @@ -33,6 +33,14 @@ def get_user(self): def get_team(self): return self.get_config().get('team') + def get_fieldsets(self): + """Optional `fieldsets` map from ~/.bjira_config — name -> list of field labels. + + Used by `bjira show` to expand alias names like 'blocker' into their + configured field list. Returns {} if the section is missing. + """ + return self.get_config().get('fieldsets') or {} + def get_task_url(self, task_name): host = self.get_config()['host'] return f'{host}/browse/{task_name}' From 826920ec2ba0697417f9e1d722fa263da9224dbc Mon Sep 17 00:00:00 2001 From: wiliam Date: Mon, 27 Apr 2026 23:00:11 +0800 Subject: [PATCH 22/24] Add _show.py with field resolution and value formatting --- bjira/_show.py | 111 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_show.py | 73 +++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 bjira/_show.py create mode 100644 tests/test_show.py diff --git a/bjira/_show.py b/bjira/_show.py new file mode 100644 index 0000000..43e4d3e --- /dev/null +++ b/bjira/_show.py @@ -0,0 +1,111 @@ +"""Pure helpers for `bjira show`: parse --fields, resolve names→ids, render values.""" + +# Built-in system field ids that should pass through resolve_field_id without +# requiring a name→id lookup. Case-sensitive — Jira uses these exact tokens. +_SYSTEM_FIELD_IDS = { + "summary", "description", "status", "assignee", "reporter", "creator", + "issuetype", "project", "priority", "resolution", "labels", + "duedate", "created", "updated", "resolutiondate", "lastViewed", + "issuelinks", "subtasks", "components", "fixVersions", "versions", + "attachment", "comment", "worklog", "watches", "votes", "security", + "progress", "aggregateprogress", "workratio", "timetracking", +} + + +def parse_fields_request(spec, fieldsets, default_fieldset): + """Resolve user --fields spec into a flat ordered list of field labels. + + Args: + spec: Comma-separated string from CLI, or None. + fieldsets: Mapping {alias: [labels]} from config; alias 'default' is + used when spec is None. + default_fieldset: Fallback list when spec is None and 'default' alias + is absent in fieldsets. + + Behaviour: + - spec=None → fieldsets['default'] if present else default_fieldset + - Each comma-separated entry expands if it's a fieldset key, else stays literal + - Whitespace trimmed; empty entries dropped + - Dedup preserving first-occurrence order + """ + if spec is None or spec == "": + if "default" in fieldsets: + return list(fieldsets["default"]) + return list(default_fieldset) + + raw_entries = [s.strip() for s in spec.split(",")] + raw_entries = [s for s in raw_entries if s] + + expanded = [] + for entry in raw_entries: + if entry in fieldsets: + expanded.extend(fieldsets[entry]) + else: + expanded.append(entry) + + seen = set() + out = [] + for label in expanded: + if label not in seen: + seen.add(label) + out.append(label) + return out + + +def resolve_field_id(label, name_to_id, known_ids): + """Resolve a user-typed label to a Jira field id, or None if unknown. + + Args: + label: User-supplied string — can be a field id (e.g. 'customfield_11210', + 'summary') or a human field name (e.g. 'Flagged', 'Дата блокировки'). + name_to_id: Case-insensitive map {lowercased name: field_id} from + Jira's /rest/api/2/field response. + known_ids: Set of customfield ids known on the instance (e.g. + {'customfield_11210', 'customfield_31724', ...}). + + Returns: + Resolved field id, or None if the label can't be matched. + """ + if label in _SYSTEM_FIELD_IDS: + return label + if label in known_ids: + return label + return name_to_id.get(label.lower()) + + +def format_field_value(raw, schema_type): + """Render a Jira field's raw JSON value as a human string. + + Covers the common shapes returned by /rest/api/2/issue: + - option: {"value": "X"} -> "X" + - user: {"displayName": "Name"} -> "Name" + - array of options/users: list of those -> "X, Y, Z" + - array of strings: ["a", "b"] -> "a, b" + - string/number/bool/None: passthrough via str() -> as-is + + `schema_type` is the field's `schema.type` from /rest/api/2/field + (e.g. "string", "option", "user", "array", "date", "number"). It + influences how arrays are unpacked. + """ + if raw is None: + return "" + + if isinstance(raw, list): + return ", ".join(_render_scalar(item) for item in raw) + + if isinstance(raw, dict): + return _render_scalar(raw) + + return str(raw) + + +def _render_scalar(value): + """Render a single non-list value (string, dict for option/user, etc).""" + if isinstance(value, dict): + for key in ("value", "displayName", "name", "key"): + if key in value and value[key] is not None: + return str(value[key]) + return str(value) + if value is None: + return "" + return str(value) diff --git a/tests/test_show.py b/tests/test_show.py new file mode 100644 index 0000000..da8c541 --- /dev/null +++ b/tests/test_show.py @@ -0,0 +1,73 @@ +from bjira._show import ( + format_field_value, + parse_fields_request, + resolve_field_id, +) + + +# --- parse_fields_request --- + +def test_parse_fields_default_when_empty(): + assert parse_fields_request(None, {}, ["a", "b"]) == ["a", "b"] + + +def test_parse_fields_uses_default_alias_from_config(): + fieldsets = {"default": ["x", "y"]} + assert parse_fields_request(None, fieldsets, ["fallback"]) == ["x", "y"] + + +def test_parse_fields_expands_alias(): + fieldsets = {"blocker": ["Flagged", "is_blocked"]} + assert parse_fields_request("blocker", fieldsets, []) == ["Flagged", "is_blocked"] + + +def test_parse_fields_mixes_alias_and_literal(): + fieldsets = {"blocker": ["Flagged", "is_blocked"]} + assert parse_fields_request("blocker,summary", fieldsets, []) == [ + "Flagged", "is_blocked", "summary", + ] + + +def test_parse_fields_dedups_preserve_order(): + fieldsets = {"a": ["x", "y"], "b": ["y", "z"]} + assert parse_fields_request("a,b,x", fieldsets, []) == ["x", "y", "z"] + + +def test_parse_fields_trims_whitespace(): + assert parse_fields_request(" x , y ", {}, []) == ["x", "y"] + + +# --- resolve_field_id --- + +def test_resolve_field_id_known_id_passthrough(): + assert resolve_field_id( + "customfield_11210", {}, {"customfield_11210"} + ) == "customfield_11210" + + +def test_resolve_field_id_case_insensitive_name_lookup(): + nm = {"flagged": "customfield_11210"} + assert resolve_field_id("Flagged", nm, set()) == "customfield_11210" + + +def test_resolve_field_id_unknown_returns_none(): + assert resolve_field_id("notarealthing", {}, set()) is None + + +# --- format_field_value --- + +def test_format_field_value_option(): + assert format_field_value({"value": "Impediment"}, "option") == "Impediment" + + +def test_format_field_value_array_of_options(): + val = [{"value": "A"}, {"value": "B"}] + assert format_field_value(val, "array") == "A, B" + + +def test_format_field_value_user(): + assert format_field_value({"displayName": "Молтянинов И."}, "user") == "Молтянинов И." + + +def test_format_field_value_string_passthrough(): + assert format_field_value("hello", "string") == "hello" From c0139b499b6892abb18e69ddb556849b00d5e33f Mon Sep 17 00:00:00 2001 From: wiliam Date: Mon, 27 Apr 2026 23:01:36 +0800 Subject: [PATCH 23/24] Add bjira show command --- bjira/operations/show.py | 141 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 bjira/operations/show.py diff --git a/bjira/operations/show.py b/bjira/operations/show.py new file mode 100644 index 0000000..cd46774 --- /dev/null +++ b/bjira/operations/show.py @@ -0,0 +1,141 @@ +"""bjira show KEY — selective read of issue fields with alias support. + +Reads any field on an issue (including ones not exposed by MCP). Field labels +can be: + - Field ids (e.g. 'customfield_11210', 'summary') + - Human names (e.g. 'Flagged', 'Дата блокировки') + - Fieldset aliases from ~/.bjira_config (e.g. 'blocker' -> [...]) + +Default fieldset (when --fields is not passed) comes from config 'fieldsets.default' +or a hardcoded minimum. +""" +import json +import sys + +from jira import JIRAError + +from bjira.operations import BJiraOperation +from bjira._show import ( + format_field_value, + parse_fields_request, + resolve_field_id, +) + + +HARDCODED_DEFAULT_FIELDSET = ["summary", "status", "assignee", "labels", "duedate"] + + +class Operation(BJiraOperation): + + def configure_arg_parser(self, subparsers): + parser = subparsers.add_parser("show", help="show selected fields of an issue") + parser.add_argument("key", help="issue key") + parser.add_argument( + "--fields", default=None, + help="comma-separated list of field names, ids, or fieldset aliases " + "from ~/.bjira_config 'fieldsets'", + ) + parser.add_argument( + "--json", dest="as_json", action="store_true", + help="emit JSON list instead of YAML-like text", + ) + parser.set_defaults(func=self._run) + + def _run(self, args): + jira = self.get_jira_api() + + labels = parse_fields_request( + args.fields, + self.get_fieldsets(), + HARDCODED_DEFAULT_FIELDSET, + ) + + try: + field_meta = self._fetch_field_metadata(jira) + except Exception as exc: + self._fail(f"cannot fetch field metadata: {exc}") + + name_to_id = {m["name"].lower(): m["id"] for m in field_meta} + known_ids = {m["id"] for m in field_meta} + meta_by_id = {m["id"]: m for m in field_meta} + + resolved = [] + unknown = [] + for label in labels: + fid = resolve_field_id(label, name_to_id, known_ids) + if fid is None: + unknown.append(label) + else: + resolved.append((label, fid)) + + if unknown: + self._fail( + "unknown field(s): " + ", ".join(repr(u) for u in unknown) + + ". Pass an exact id, a known field name (case-insensitive), or a " + "fieldset alias from ~/.bjira_config", + ) + + try: + issue = jira.issue(args.key, fields=",".join(fid for _, fid in resolved)) + except JIRAError as exc: + self._fail_api(args.key, exc) + + rows = [] + for label, fid in resolved: + raw = issue.raw["fields"].get(fid) + schema_type = ( + meta_by_id.get(fid, {}).get("schema", {}).get("type", "string") + ) + human_name = meta_by_id.get(fid, {}).get("name", fid) + rows.append({ + "id": fid, + "name": human_name, + "label": label, # what user typed + "type": schema_type, + "raw": raw, + "value": format_field_value(raw, schema_type), + }) + + if args.as_json: + json.dump( + [{"id": r["id"], "name": r["name"], "type": r["type"], + "value": r["raw"]} for r in rows], + sys.stdout, ensure_ascii=False, indent=2, + ) + sys.stdout.write("\n") + return + + self._print_yaml_like(args.key, rows) + + def _fetch_field_metadata(self, jira): + """Fetch /rest/api/2/field — list of {id, name, schema, custom, ...}.""" + url = f"{jira._options['server']}/rest/api/2/field" + resp = jira._session.get(url) + resp.raise_for_status() + return resp.json() + + def _print_yaml_like(self, key, rows): + print(key) + if not rows: + return + max_label = max(len(r["label"]) for r in rows) + for r in rows: + value_str = r["value"] + label = r["label"].ljust(max_label) + if "\n" in value_str: + # Multi-line: print first line on the header line, rest indented. + first, *rest = value_str.split("\n") + print(f" {label}: {first}") + indent = " " * (2 + max_label + 2) # " " + label + ": " + for line in rest: + print(f"{indent}{line}") + else: + print(f" {label}: {value_str}") + + def _fail(self, msg): + print(f"ERROR: show: {msg}", file=sys.stderr) + sys.exit(2) + + def _fail_api(self, key, exc): + print(f"ERROR: show {key}: {exc.status_code} {exc.text}", file=sys.stderr) + sys.exit(3) From 57508303dcbb6087059c5f817b2ee1ec5ffd8363 Mon Sep 17 00:00:00 2001 From: wiliam Date: Mon, 27 Apr 2026 23:03:45 +0800 Subject: [PATCH 24/24] Document bjira show in README --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 9155019..1e7b87d 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,26 @@ bjira setpass ~ » bjira edit PORTFOLIO-53307 --set customfield_99999=hello # escape hatch for rare fields ~ » bjira edit PORTFOLIO-53307 --list-fields # editable fields + allowed enum values ~ » bjira edit PORTFOLIO-53307 --list-fields --json # same, as JSON + +~ » bjira show PORTFOLIO-53307 # default fieldset +~ » bjira show PORTFOLIO-53307 --fields "summary,Flagged,Дата блокировки" # explicit fields by name or id +~ » bjira show PORTFOLIO-53307 --fields blocker # alias from ~/.bjira_config 'fieldsets' +~ » bjira show PORTFOLIO-53307 --fields blocker,timing --json # combine aliases, output JSON +``` + +### `fieldsets` in `~/.bjira_config` (optional) + +```json +{ + "host": "https://jira.hh.ru", + "user": "...", + "team": "...", + "fieldsets": { + "default": ["summary", "status", "assignee", "labels", "duedate"], + "blocker": ["Flagged", "is_blocked", "Дата блокировки", "Дата разблокировки", "BlockerTime (days)"], + "timing": ["created", "updated", "duedate", "Start date"] + } +} ``` **Exit codes:** `0` success, `2` arg error, `3` API error (auth/permission/not-found, ambiguous/unknown match). Pass `-v` for SDK debug logging.