Files
magnus919_agent-skills/jira/scripts/jira
T
Magnus Hedemarkandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 3e0453b118 fix(jira): correct ConnectionError hint to cite JIRA_SERVER env var
The die() message on connection failure told users to 'use --server',
but no such flag exists; JIRA_SERVER is the only endpoint override.
Replace the false clause with a hint that matches actual behavior.

Flagship scrutiny follow-up (non-blocking issue 1 from
thicken-jira-flagship validation).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-26 06:50:46 -04:00

677 lines
25 KiB
Python
Executable File

#!/usr/bin/env python3
"""jira — Jira issue tracker from the terminal.
Interact with Atlassian Jira Cloud via the REST API v3.
Requires JIRA_EMAIL and JIRA_API_TOKEN env vars (token from
https://id.atlassian.com/manage/api-tokens) and JIRA_SERVER
(defaults to https://your-domain.atlassian.net).
"""
import argparse
import json
import os
import sys
import warnings
from typing import Any, Dict, List, Optional, Tuple
warnings.simplefilter("ignore")
import requests
# === Config ===
DEFAULT_SERVER = "https://your-domain.atlassian.net"
ENV_EMAIL = os.getenv("JIRA_EMAIL", "")
ENV_TOKEN = os.getenv("JIRA_API_TOKEN", "")
ENV_SERVER = os.getenv("JIRA_SERVER", DEFAULT_SERVER)
# === Logging ===
QUIET = False
def log(msg: str) -> None:
if not QUIET and not GLOBAL_FLAGS.get("json", False):
print(msg)
def warn(msg: str) -> None:
print(f"Warning: {msg}", file=sys.stderr)
def die(msg: str, exit_code: int = 1) -> None:
print(f"Error: {msg}", file=sys.stderr)
sys.exit(exit_code)
def emit(human: str, data: Any) -> None:
if GLOBAL_FLAGS.get("json", False):
print(json.dumps(data, default=str))
else:
print(human)
# === Global flags ===
GLOBAL_FLAGS: Dict[str, Any] = {
"json": False, "dry_run": False, "force": False,
"quiet": False, "verbose": False
}
def _preparse_global_flags(argv: List[str]) -> Tuple[Dict[str, Any], List[str]]:
GLOBAL_BOOLS = {"--json", "--dry-run", "--force", "--quiet", "--verbose"}
flags: Dict[str, Any] = {}
filtered: List[str] = [argv[0]]
i = 1
while i < len(argv):
arg = argv[i]
if arg in GLOBAL_BOOLS:
flags[arg.lstrip("-").replace("-", "_")] = True
i += 1
elif arg in ("--help", "-h"):
return flags, argv
elif arg == "--":
filtered.extend(argv[i:])
break
else:
filtered.append(arg)
i += 1
return flags, filtered
# === Jira API Client ===
class JiraClient:
"""REST API client for Jira Cloud v3."""
def __init__(self, email: str = "", token: str = "",
server: str = "", dry_run: bool = False):
self.email = email or ENV_EMAIL
self.token = token or ENV_TOKEN
self.server = (server or ENV_SERVER).rstrip("/")
self.dry_run = dry_run
def _headers(self) -> Dict[str, str]:
return {
"Accept": "application/json",
"Content-Type": "application/json",
}
def _auth(self) -> Optional[Tuple[str, str]]:
if self.email and self.token:
return (self.email, self.token)
return None
def _request(self, method: str, path: str,
params: Optional[Dict] = None,
json_data: Any = None) -> Any:
url = f"{self.server}/rest/api/3{path}"
if self.dry_run:
return {"dry_run": True, "method": method.upper(),
"url": url, "params": params, "json": json_data}
auth = self._auth()
if not auth:
die("JIRA_EMAIL and JIRA_API_TOKEN not set. "
"Get a token at https://id.atlassian.com/manage/api-tokens")
try:
resp = requests.request(
method=method, url=url, auth=auth,
params=params, json=json_data,
headers=self._headers(), timeout=30
)
except requests.ConnectionError as e:
die(f"Cannot connect to {self.server}: {e}\n"
f" Check connectivity, or set JIRA_SERVER env var to change the endpoint")
if resp.status_code == 401:
die("Auth failed (401). Check your JIRA_EMAIL and JIRA_API_TOKEN.")
if resp.status_code == 403:
die("Forbidden (403). Your account may not have access to this resource.")
if resp.status_code == 404:
return None
if resp.status_code == 429:
retry_after = resp.headers.get("Retry-After", "unknown")
reason = resp.headers.get("RateLimit-Reason", "burst or quota limit")
die("Rate limited (429): {}. Retry-After: {}s".format(reason, retry_after))
if resp.status_code >= 400:
try:
detail = resp.json()
except Exception:
detail = resp.text[:200]
msg = "API error ({})".format(resp.status_code)
if isinstance(detail, dict):
parts = []
errs = detail.get("errors")
if isinstance(errs, dict) and errs:
parts.extend("{}: {}".format(k, v) for k, v in errs.items())
msgs = detail.get("errorMessages")
if isinstance(msgs, list) and msgs:
parts.extend(str(m) for m in msgs)
if parts:
msg += ": " + "; ".join(parts)
else:
msg += ": {}".format(detail)
else:
msg += ": {}".format(detail)
die(msg)
try:
return resp.json()
except ValueError:
return {"raw": resp.text[:500]}
def _get(self, path: str, params: Optional[Dict] = None) -> Any:
return self._request("GET", path, params=params)
def _post(self, path: str, json_data: Any = None) -> Any:
return self._request("POST", path, json_data=json_data)
def _put(self, path: str, json_data: Any = None) -> Any:
return self._request("PUT", path, json_data=json_data)
# === Endpoints ===
def get_myself(self) -> Any:
return self._get("/myself")
def search_issues(self, jql: str, fields: str = "summary,status,issuetype,assignee,priority",
max_results: int = 20) -> Any:
return self._get("/search", params={
"jql": jql,
"fields": fields,
"maxResults": max_results
})
def search_issues_paged(self, jql: str, fields: str = "summary,status,issuetype,assignee,priority",
total_max: int = 100, page_size: int = 50) -> List[Dict]:
"""Fetch up to total_max issues, following legacy offset pagination.
The classic /search endpoint pages via startAt/maxResults with a
`total` field (deprecated by Atlassian but still the endpoint this CLI
uses; see references/rest-auth-and-search.md for the /search/jql
nextPageToken replacement). `total` may change between pages, so an
empty page always terminates the loop.
"""
collected: List[Dict] = []
start_at = 0
while len(collected) < total_max:
page_size_eff = min(page_size, total_max - len(collected))
data = self._get("/search", params={
"jql": jql,
"fields": fields,
"startAt": start_at,
"maxResults": page_size_eff,
})
if not data:
break
issues = data.get("issues", [])
if not issues:
break
collected.extend(issues)
start_at += len(issues)
total = data.get("total")
if total is None or start_at >= int(total):
break
return collected[:total_max]
def approximate_count(self, jql: str) -> Any:
"""POST /search/approximate-count — fast match count without fetching rows."""
return self._post("/search/approximate-count", json_data={"jql": jql})
def get_issue(self, issue_key: str) -> Any:
return self._get(f"/issue/{issue_key}")
def list_projects(self) -> Any:
return self._get("/project")
def create_issue(self, project_key: str, summary: str,
issuetype: str = "Task", description: str = "",
priority: Optional[str] = None) -> Any:
fields = {
"project": {"key": project_key},
"summary": summary,
"issuetype": {"name": issuetype},
}
if description:
fields["description"] = {
"type": "doc",
"version": 1,
"content": [{
"type": "paragraph",
"content": [{"type": "text", "text": description}]
}]
}
if priority:
fields["priority"] = {"name": priority}
return self._post("/issue", json_data={"fields": fields})
def add_comment(self, issue_key: str, body: str) -> Any:
return self._post(f"/issue/{issue_key}/comment", json_data={
"body": {
"type": "doc",
"version": 1,
"content": [{
"type": "paragraph",
"content": [{"type": "text", "text": body}]
}]
}
})
def get_transitions(self, issue_key: str) -> Any:
return self._get(f"/issue/{issue_key}/transitions")
def transition_issue(self, issue_key: str, transition_id: str,
resolution: Optional[str] = None) -> Any:
payload: Dict[str, Any] = {"transition": {"id": str(transition_id)}}
if resolution:
# Done-style transitions often require a resolution on their screen;
# omitting it yields 400 with errors.resolution naming the field.
payload["fields"] = {"resolution": {"name": resolution}}
return self._post(f"/issue/{issue_key}/transitions", json_data=payload)
# === Command Handlers ===
def cmd_me(client: JiraClient, args: List[str]) -> None:
if client.dry_run:
emit("[dry-run] Would fetch current user profile",
{"dry_run": True, "command": "me"})
return
data = client.get_myself()
if not data:
emit("Could not fetch user info.", {"error": "not found"})
return
emit(
f"👤 {data.get('displayName', '?')} ({data.get('emailAddress', '?')})\n"
f" Account: {data.get('accountId', '?')}\n"
f" Timezone: {data.get('timeZone', '?')}",
{"displayName": data.get("displayName"),
"emailAddress": data.get("emailAddress"),
"accountId": data.get("accountId"),
"timeZone": data.get("timeZone"),
"locale": data.get("locale")}
)
def cmd_list(client: JiraClient, args: List[str]) -> None:
parser = argparse.ArgumentParser(prog="jira list")
parser.add_argument("--jql", default="", help="JQL query (default: recent issues)")
parser.add_argument("--max", type=int, default=20, help="Max results (default: 20)")
parser.add_argument("--project", help="Shortcut: filter by project key")
parsed, _ = parser.parse_known_args(args)
jql = parsed.jql
if not jql and parsed.project:
jql = f"project={parsed.project} ORDER BY created DESC"
elif not jql:
jql = "ORDER BY created DESC"
if client.dry_run:
emit(f"[dry-run] Would search issues: {jql}",
{"dry_run": True, "command": "list", "jql": jql,
"max_results": parsed.max})
return
if parsed.max > 50:
# Multi-page fetch via offset pagination; single-page path stays identical.
paged = client.search_issues_paged(jql, total_max=parsed.max)
data = {"issues": paged, "total": len(paged)}
else:
data = client.search_issues(jql, max_results=parsed.max)
issues = data.get("issues", []) if data else []
if not issues:
emit("No issues found.", {"issues": []})
return
lines = []
out = []
for issue in issues:
key = issue.get("key", "?")
fields = issue.get("fields", {})
summary = fields.get("summary", "?")
status = fields.get("status", {}).get("name", "?")
assignee = fields.get("assignee", {})
assignee_name = assignee.get("displayName", "Unassigned") if assignee else "Unassigned"
lines.append(f" {key} [{status}] {summary} ({assignee_name})")
out.append({
"key": key, "summary": summary, "status": status,
"assignee": assignee_name,
"issuetype": fields.get("issuetype", {}).get("name", "?"),
"priority": fields.get("priority", {}).get("name") if fields.get("priority") else None,
})
total = data.get("total", len(issues))
emit(f"{total} issue(s):\n" + "\n".join(lines),
{"total": total, "issues": out})
def cmd_view(client: JiraClient, args: List[str]) -> None:
parser = argparse.ArgumentParser(prog="jira view")
parser.add_argument("issue_key", help="Issue key (e.g. PROJ-123)")
parsed, _ = parser.parse_known_args(args)
if client.dry_run:
emit(f"[dry-run] Would fetch issue {parsed.issue_key}",
{"dry_run": True, "command": "view",
"issue_key": parsed.issue_key})
return
data = client.get_issue(parsed.issue_key)
if not data:
emit(f"Issue {parsed.issue_key} not found.",
{"error": "not found", "issue_key": parsed.issue_key})
return
fields = data.get("fields", {})
key = data.get("key", parsed.issue_key)
summary = fields.get("summary", "?")
status = fields.get("status", {}).get("name", "?")
issuetype = fields.get("issuetype", {}).get("name", "?")
priority = fields.get("priority", {})
priority_str = priority.get("name", "None") if priority else "None"
assignee = fields.get("assignee", {})
assignee_str = assignee.get("displayName", "Unassigned") if assignee else "Unassigned"
reporter = fields.get("reporter", {})
reporter_str = reporter.get("displayName", "?") if reporter else "?"
created = fields.get("created", "?")
updated = fields.get("updated", "?")
description = fields.get("description", "")
desc_text = _extract_text(description) if description else "(no description)"
human = (
f"📋 {key}: {summary}\n"
f" Type: {issuetype} Status: {status} Priority: {priority_str}\n"
f" Assignee: {assignee_str} Reporter: {reporter_str}\n"
f" Created: {created}\n"
f" Updated: {updated}\n"
f"─── Description ───\n{desc_text}"
)
emit(human, {
"key": key, "summary": summary, "status": status,
"issuetype": issuetype, "priority": priority_str,
"assignee": assignee_str, "reporter": reporter_str,
"created": created, "updated": updated,
"description": desc_text
})
def _extract_text(adf: Any) -> str:
"""Extract plain text from Atlassian Document Format."""
if isinstance(adf, str):
return adf
if isinstance(adf, dict):
content = adf.get("content", [])
texts = []
for node in content:
if isinstance(node, dict):
node_content = node.get("content", [])
for item in node_content:
if isinstance(item, dict) and item.get("type") == "text":
texts.append(item.get("text", ""))
return "\n".join(texts) if texts else json.dumps(adf, indent=2)[:500]
return str(adf)[:500]
def cmd_projects(client: JiraClient, args: List[str]) -> None:
if client.dry_run:
emit("[dry-run] Would list projects",
{"dry_run": True, "command": "projects"})
return
data = client.list_projects()
if not data:
emit("No projects found.", {"projects": []})
return
projects = data if isinstance(data, list) else []
lines = []
out = []
for p in sorted(projects, key=lambda x: x.get("name", "")):
key = p.get("key", "?")
name = p.get("name", "?")
lead = p.get("lead", {})
lead_name = lead.get("displayName", "?") if lead else "?"
ptype = p.get("projectTypeKey", "?")
lines.append(f" {key:12} {name:40} [{ptype}] Lead: {lead_name}")
out.append({
"key": key, "name": name, "type": ptype,
"lead": lead_name
})
emit(f"{len(projects)} project(s):\n" + "\n".join(lines),
{"total": len(projects), "projects": out})
def cmd_create(client: JiraClient, args: List[str]) -> None:
parser = argparse.ArgumentParser(prog="jira create")
parser.add_argument("--project", required=True, help="Project key (e.g. PROJ)")
parser.add_argument("--summary", required=True, help="Issue summary/title")
parser.add_argument("--type", default="Task", help="Issue type (Task, Bug, Story, etc.)")
parser.add_argument("--description", default="", help="Issue description")
parser.add_argument("--priority", help="Priority (Highest, High, Medium, Low, Lowest)")
parsed, _ = parser.parse_known_args(args)
if client.dry_run:
emit("[dry-run] Would create issue",
{"dry_run": True, "command": "create",
"project": parsed.project, "summary": parsed.summary,
"type": parsed.type})
return
result = client.create_issue(
project_key=parsed.project,
summary=parsed.summary,
issuetype=parsed.type,
description=parsed.description,
priority=parsed.priority
)
key = result.get("key", "?") if result else "?"
url = result.get("self", "") if result else ""
emit(f"✅ Created {key}: {parsed.summary}\n {url}",
{"status": "created", "key": key, "url": url,
"summary": parsed.summary})
def cmd_comment(client: JiraClient, args: List[str]) -> None:
parser = argparse.ArgumentParser(prog="jira comment")
parser.add_argument("issue_key", help="Issue key (e.g. PROJ-123)")
parser.add_argument("--body", "-m", required=True, help="Comment body text")
parsed, _ = parser.parse_known_args(args)
if client.dry_run:
emit(f"[dry-run] Would add comment to {parsed.issue_key}",
{"dry_run": True, "command": "comment",
"issue_key": parsed.issue_key, "body": parsed.body})
return
result = client.add_comment(parsed.issue_key, parsed.body)
cid = result.get("id", "?") if result else "?"
emit(f"💬 Comment added to {parsed.issue_key} (id={cid})",
{"status": "comment_added", "issue_key": parsed.issue_key,
"comment_id": cid})
def cmd_transition(client: JiraClient, args: List[str]) -> None:
parser = argparse.ArgumentParser(prog="jira transition")
parser.add_argument("issue_key", help="Issue key (e.g. PROJ-123)")
parser.add_argument("--to", required=True, help="Transition by name or ID")
parser.add_argument("--resolution", help='Set a resolution during the '
'transition (often required for Done; e.g. "Fixed")')
parsed, _ = parser.parse_known_args(args)
if client.dry_run:
emit(f"[dry-run] Would transition {parsed.issue_key} to '{parsed.to}'",
{"dry_run": True, "command": "transition",
"issue_key": parsed.issue_key, "to": parsed.to,
"resolution": parsed.resolution})
return
# First, get available transitions — IDs are workflow- and status-specific,
# so names alone are ambiguous across projects.
trans_data = client.get_transitions(parsed.issue_key)
if not trans_data:
die(f"No transitions available for {parsed.issue_key}")
transitions = trans_data.get("transitions", [])
# Find the target transition by name or ID
match = None
for t in transitions:
tid = t.get("id", "")
tname = t.get("name", "").lower()
if tid == parsed.to or tname == parsed.to.lower():
match = tid
break
if not match:
available = ", ".join([f"{t.get('id')}={t.get('name')}"
for t in transitions])
die(f"Transition '{parsed.to}' not found. Available: {available}")
result = client.transition_issue(parsed.issue_key, match,
resolution=parsed.resolution)
_ = result # 204 No Content on success
res_note = f" (resolution: {parsed.resolution})" if parsed.resolution else ""
emit(f"🔄 {parsed.issue_key} transitioned to '{parsed.to}'{res_note}",
{"status": "transitioned", "issue_key": parsed.issue_key,
"transition": parsed.to, "resolution": parsed.resolution})
def cmd_transitions(client: JiraClient, args: List[str]) -> None:
"""List available transitions for an issue (discovery half of the flow)."""
parser = argparse.ArgumentParser(prog="jira transitions")
parser.add_argument("issue_key", help="Issue key (e.g. PROJ-123)")
parsed, _ = parser.parse_known_args(args)
if client.dry_run:
emit(f"[dry-run] Would list transitions for {parsed.issue_key}",
{"dry_run": True, "command": "transitions",
"issue_key": parsed.issue_key})
return
data = client.get_transitions(parsed.issue_key)
transitions = data.get("transitions", []) if data else []
if not transitions:
emit(f"No transitions available for {parsed.issue_key}.",
{"transitions": []})
return
lines = []
out = []
for t in transitions:
to_status = t.get("to", {}).get("name", "?")
category = t.get("to", {}).get("statusCategory", {}).get("key", "?")
lines.append(f" {t.get('id', '?'):>5} {t.get('name', '?'):20} -> {to_status} [{category}]")
out.append({"id": t.get("id"), "name": t.get("name"),
"to_status": to_status, "status_category": category})
emit("Available transitions:\n" + "\n".join(lines),
{"transitions": out})
def cmd_count(client: JiraClient, args: List[str]) -> None:
"""Approximate match count via /search/approximate-count."""
parser = argparse.ArgumentParser(prog="jira count")
parser.add_argument("--jql", required=True, help="JQL query to count")
parser.add_argument("--project", help="Shortcut: prefix with project=KEY AND")
parsed, _ = parser.parse_known_args(args)
jql = f"project={parsed.project} AND ({parsed.jql})" if parsed.project else parsed.jql
if client.dry_run:
emit(f"[dry-run] Would count issues matching: {jql}",
{"dry_run": True, "command": "count", "jql": jql})
return
result = client.approximate_count(jql)
count = result.get("count", 0) if isinstance(result, dict) else 0
emit(f"{count} issue(s) match", {"jql": jql, "count": count})
# === Main ===
def main() -> None:
global GLOBAL_FLAGS, QUIET
GLOBAL_FLAGS, filtered_argv = _preparse_global_flags(sys.argv)
if GLOBAL_FLAGS.get("quiet", False):
QUIET = True
if GLOBAL_FLAGS.get("json", False):
warnings.simplefilter("ignore")
parser = argparse.ArgumentParser(
prog="jira",
description="Jira issue tracker from the terminal.",
epilog="Global flags work anywhere: jira --json list --jql 'project=PROJ'"
)
sub = parser.add_subparsers(dest="command", help="Available commands")
sub.add_parser("me", help="Get current user profile")
sub.add_parser("projects", help="List projects")
p_transitions = sub.add_parser("transitions", help="List available transitions for an issue")
p_transitions.add_argument("issue_key", help="Issue key (e.g. PROJ-123)")
p_count = sub.add_parser("count", help="Count issues matching a JQL query (fast approximate count)")
p_count.add_argument("--jql", required=True, help="JQL query")
p_list = sub.add_parser("list", help="Search issues")
p_list.add_argument("--jql", default="", help="JQL query")
p_list.add_argument("--max", type=int, default=20, help="Max results")
p_list.add_argument("--project", help="Filter by project key")
p_view = sub.add_parser("view", help="View issue details")
p_view.add_argument("issue_key", help="Issue key (e.g. PROJ-123)")
p_create = sub.add_parser("create", help="Create an issue")
p_create.add_argument("--project", required=True, help="Project key")
p_create.add_argument("--summary", required=True, help="Issue summary")
p_create.add_argument("--type", default="Task", help="Issue type")
p_create.add_argument("--description", default="", help="Description")
p_create.add_argument("--priority", help="Priority (Highest, High, Medium, Low, Lowest)")
p_comment = sub.add_parser("comment", help="Add a comment")
p_comment.add_argument("issue_key", help="Issue key")
p_comment.add_argument("--body", "-m", required=True, help="Comment text")
p_trans = sub.add_parser("transition", help="Transition an issue")
p_trans.add_argument("issue_key", help="Issue key")
p_trans.add_argument("--to", required=True, help="Transition name or ID")
p_trans.add_argument("--resolution", help='Resolution to set during transition (e.g. "Fixed")')
args = parser.parse_args(filtered_argv[1:])
if not args.command:
parser.print_help()
sys.exit(1)
# Lazy auth: --help and --dry-run work without credentials
needs_auth = args.command not in ("help",) and not GLOBAL_FLAGS.get("dry_run", False)
if needs_auth and not (ENV_EMAIL and ENV_TOKEN):
die("Set JIRA_EMAIL and JIRA_API_TOKEN in your environment.\n"
" Get a token: https://id.atlassian.com/manage/api-tokens\n"
" Or use --dry-run to preview without credentials.")
client = JiraClient(dry_run=GLOBAL_FLAGS.get("dry_run", False))
# Dispatch
cmd_map = {
"me": cmd_me,
"list": cmd_list,
"view": cmd_view,
"projects": cmd_projects,
"create": cmd_create,
"comment": cmd_comment,
"transition": cmd_transition,
"transitions": cmd_transitions,
"count": cmd_count,
}
handler = cmd_map.get(args.command)
if not handler:
parser.print_help()
sys.exit(1)
# Pass remaining args to handler
remaining = filtered_argv[filtered_argv.index(args.command) + 1:]
handler(client, remaining)
if __name__ == "__main__":
main()