Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a170c27
Bootstrap tests/ package for project-ops work
wiliam Apr 23, 2026
c8f41e5
Add merge_labels helper with set semantics and clear
wiliam Apr 23, 2026
5bba2f0
Add parse_due helper for --due flag
wiliam Apr 23, 2026
9b50e94
Add should_require_force for --force gate on summary/description
wiliam Apr 23, 2026
60b1244
Add match_transition with id/exact/substring resolution
wiliam Apr 23, 2026
bb788f3
Add resolve_link_type with case-insensitive match
wiliam Apr 23, 2026
6275445
Add _text helpers: resolve_body_source, format_comment
wiliam Apr 23, 2026
cc9869a
Add bjira comment command
wiliam Apr 23, 2026
81b3753
Add bjira comments command (list recent)
wiliam Apr 23, 2026
49600cb
Add bjira status command with fuzzy transition match
wiliam Apr 23, 2026
b496483
Add bjira link command
wiliam Apr 23, 2026
0c58efb
Add bjira edit command with force-gated summary/description
wiliam Apr 23, 2026
c0bd922
Add -v/--verbose to enable SDK debug logging
wiliam Apr 23, 2026
20234af
Add live smoke script for project-ops commands
wiliam Apr 23, 2026
3368a5d
Document project-ops commands in README
wiliam Apr 23, 2026
9e5340f
Add --list flag to bjira link
wiliam Apr 23, 2026
44f3a44
Add FIELD_SPECS map, build_fields_payload, parse_set_arg, SET_DENY
wiliam Apr 23, 2026
e64c073
Refactor bjira edit to FIELD_SPECS map with --set escape hatch
wiliam Apr 23, 2026
c99c5b3
Add bjira edit --list-fields [--json] using editmeta
wiliam Apr 23, 2026
c32c8ae
Update smoke and README for hybrid edit + --list-fields + link --list
wiliam Apr 23, 2026
8213a87
Add get_fieldsets to BJiraOperation
wiliam Apr 27, 2026
826920e
Add _show.py with field resolution and value formatting
wiliam Apr 27, 2026
c0139b4
Add bjira show command
wiliam Apr 27, 2026
5750830
Document bjira show in README
wiliam Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,56 @@ 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
~ » 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

~ » 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.

**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.
106 changes: 106 additions & 0 deletions bjira/_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import datetime


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


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


_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


# --- 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
55 changes: 55 additions & 0 deletions bjira/_match.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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}")


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}")
111 changes: 111 additions & 0 deletions bjira/_show.py
Original file line number Diff line number Diff line change
@@ -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)
30 changes: 30 additions & 0 deletions bjira/_text.py
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 6 additions & 0 deletions bjira/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import argparse
import logging
from importlib import import_module
from pkgutil import walk_packages

Expand All @@ -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__ + '.'):
Expand All @@ -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)


Expand Down
Loading