jiradc-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jiradc_cli/__init__.py +5 -0
- jiradc_cli/__main__.py +5 -0
- jiradc_cli/client.py +109 -0
- jiradc_cli/config.py +138 -0
- jiradc_cli/main.py +509 -0
- jiradc_cli-0.1.0.dist-info/METADATA +107 -0
- jiradc_cli-0.1.0.dist-info/RECORD +10 -0
- jiradc_cli-0.1.0.dist-info/WHEEL +5 -0
- jiradc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- jiradc_cli-0.1.0.dist-info/top_level.txt +1 -0
jiradc_cli/__init__.py
ADDED
jiradc_cli/__main__.py
ADDED
jiradc_cli/client.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from .config import JiraConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class JiraApiError(RuntimeError):
|
|
14
|
+
status_code: int
|
|
15
|
+
message: str
|
|
16
|
+
|
|
17
|
+
def __str__(self) -> str:
|
|
18
|
+
return f"HTTP {self.status_code}: {self.message}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class JiraTransportError(RuntimeError):
|
|
23
|
+
message: str
|
|
24
|
+
|
|
25
|
+
def __str__(self) -> str:
|
|
26
|
+
return self.message
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JiraClient:
|
|
30
|
+
def __init__(self, config: JiraConfig, timeout_seconds: int = 30) -> None:
|
|
31
|
+
self.base_url = config.base_url.rstrip("/")
|
|
32
|
+
self.timeout_seconds = timeout_seconds
|
|
33
|
+
self.session = requests.Session()
|
|
34
|
+
self.session.headers.update(
|
|
35
|
+
{
|
|
36
|
+
"Accept": "application/json",
|
|
37
|
+
"Cookie": config.cookie,
|
|
38
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
39
|
+
"User-Agent": (
|
|
40
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
41
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
42
|
+
"Chrome/124.0.0.0 Safari/537.36"
|
|
43
|
+
),
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def request(
|
|
48
|
+
self,
|
|
49
|
+
method: str,
|
|
50
|
+
path: str,
|
|
51
|
+
*,
|
|
52
|
+
params: dict[str, Any] | None = None,
|
|
53
|
+
json_body: dict[str, Any] | list[Any] | None = None,
|
|
54
|
+
) -> Any:
|
|
55
|
+
url = f"{self.base_url}/rest{path}"
|
|
56
|
+
try:
|
|
57
|
+
response = self.session.request(
|
|
58
|
+
method=method.upper(),
|
|
59
|
+
url=url,
|
|
60
|
+
params=params,
|
|
61
|
+
json=json_body,
|
|
62
|
+
timeout=self.timeout_seconds,
|
|
63
|
+
)
|
|
64
|
+
except requests.RequestException as exc:
|
|
65
|
+
raise JiraTransportError(str(exc)) from exc
|
|
66
|
+
if response.status_code >= 400:
|
|
67
|
+
raise JiraApiError(response.status_code, _error_message(response))
|
|
68
|
+
if response.status_code == 204 or not response.text.strip():
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
return response.json()
|
|
72
|
+
except ValueError:
|
|
73
|
+
return response.text
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _error_message(response: requests.Response) -> str:
|
|
77
|
+
try:
|
|
78
|
+
payload = response.json()
|
|
79
|
+
except ValueError:
|
|
80
|
+
text = response.text.strip()
|
|
81
|
+
if _looks_like_html(text):
|
|
82
|
+
return _summarize_html_error(text, response.status_code)
|
|
83
|
+
return text or response.reason
|
|
84
|
+
|
|
85
|
+
if isinstance(payload, dict):
|
|
86
|
+
errors = []
|
|
87
|
+
if isinstance(payload.get("errorMessages"), list):
|
|
88
|
+
errors.extend(str(item) for item in payload["errorMessages"])
|
|
89
|
+
if isinstance(payload.get("errors"), dict):
|
|
90
|
+
errors.extend(f"{key}: {value}" for key, value in payload["errors"].items())
|
|
91
|
+
if errors:
|
|
92
|
+
return "; ".join(errors)
|
|
93
|
+
return str(payload)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _looks_like_html(text: str) -> bool:
|
|
97
|
+
lowered = text.lstrip().lower()
|
|
98
|
+
return lowered.startswith("<!doctype html") or lowered.startswith("<html")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _summarize_html_error(text: str, status_code: int) -> str:
|
|
102
|
+
referral_match = re.search(r"log-referral-id\">([^<]+)<", text, re.IGNORECASE)
|
|
103
|
+
referral = referral_match.group(1).strip() if referral_match else None
|
|
104
|
+
title_match = re.search(r"<title>([^<]+)</title>", text, re.IGNORECASE)
|
|
105
|
+
title = title_match.group(1).strip() if title_match else "HTML error page"
|
|
106
|
+
|
|
107
|
+
if referral:
|
|
108
|
+
return f"{title} (HTTP {status_code}, referral id: {referral})"
|
|
109
|
+
return f"{title} (HTTP {status_code})"
|
jiradc_cli/config.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".config" / "jiradc-cli" / "config.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigError(RuntimeError):
|
|
13
|
+
"""Raised when the local CLI configuration is missing or invalid."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class JiraConfig:
|
|
18
|
+
base_url: str
|
|
19
|
+
cookie: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def normalize_base_url(base_url: str) -> str:
|
|
23
|
+
value = base_url.strip().rstrip("/")
|
|
24
|
+
if not value.startswith("http://") and not value.startswith("https://"):
|
|
25
|
+
raise ConfigError("Base URL must start with http:// or https://")
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_cookie(cookie: str) -> str:
|
|
30
|
+
value = cookie.strip()
|
|
31
|
+
if value.lower().startswith("cookie:"):
|
|
32
|
+
value = value.split(":", 1)[1].strip()
|
|
33
|
+
if not value:
|
|
34
|
+
raise ConfigError("Cookie value cannot be empty.")
|
|
35
|
+
if "=" not in value:
|
|
36
|
+
value = f"JSESSIONID={value}"
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_cookie_pairs(cookie: str) -> dict[str, str]:
|
|
41
|
+
pairs: dict[str, str] = {}
|
|
42
|
+
normalized = normalize_cookie(cookie)
|
|
43
|
+
for part in normalized.split(";"):
|
|
44
|
+
chunk = part.strip()
|
|
45
|
+
if not chunk or "=" not in chunk:
|
|
46
|
+
continue
|
|
47
|
+
key, value = chunk.split("=", 1)
|
|
48
|
+
key = key.strip()
|
|
49
|
+
value = value.strip()
|
|
50
|
+
if key:
|
|
51
|
+
pairs[key] = value
|
|
52
|
+
return pairs
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def format_cookie_header(cookies: dict[str, str]) -> str:
|
|
56
|
+
return "; ".join(f"{key}={value}" for key, value in cookies.items())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def pick_session_cookies(cookie: str) -> str:
|
|
60
|
+
"""Keep cookies most likely required for Jira session/auth routing."""
|
|
61
|
+
all_pairs = parse_cookie_pairs(cookie)
|
|
62
|
+
if not all_pairs:
|
|
63
|
+
raise ConfigError("Cookie value does not contain valid key=value pairs.")
|
|
64
|
+
|
|
65
|
+
preferred_names: list[str] = [
|
|
66
|
+
"JSESSIONID",
|
|
67
|
+
"atlassian.xsrf.token",
|
|
68
|
+
"seraph.rememberme.cookie",
|
|
69
|
+
"AWSALB",
|
|
70
|
+
"AWSALBCORS",
|
|
71
|
+
"ROUTEID",
|
|
72
|
+
"GCP_IAP_UID",
|
|
73
|
+
"BIGipServer",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
selected: dict[str, str] = {}
|
|
77
|
+
for name in preferred_names:
|
|
78
|
+
if name in all_pairs:
|
|
79
|
+
selected[name] = all_pairs[name]
|
|
80
|
+
|
|
81
|
+
for name, value in all_pairs.items():
|
|
82
|
+
lowered = name.lower()
|
|
83
|
+
if lowered.startswith("atlassian.") or lowered.startswith("seraph."):
|
|
84
|
+
selected.setdefault(name, value)
|
|
85
|
+
if "oauth" in lowered and "proxy" in lowered:
|
|
86
|
+
selected.setdefault(name, value)
|
|
87
|
+
|
|
88
|
+
if "JSESSIONID" not in selected and "JSESSIONID" in all_pairs:
|
|
89
|
+
selected["JSESSIONID"] = all_pairs["JSESSIONID"]
|
|
90
|
+
|
|
91
|
+
if selected:
|
|
92
|
+
return format_cookie_header(selected)
|
|
93
|
+
return normalize_cookie(cookie)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cookie_variants_for_auth(cookie: str) -> Iterable[str]:
|
|
97
|
+
normalized = normalize_cookie(cookie)
|
|
98
|
+
minimized = pick_session_cookies(normalized)
|
|
99
|
+
if minimized != normalized:
|
|
100
|
+
return [minimized, normalized]
|
|
101
|
+
return [normalized]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def save_config(config: JiraConfig, path: Path = DEFAULT_CONFIG_PATH) -> None:
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
try:
|
|
107
|
+
path.parent.chmod(0o700)
|
|
108
|
+
except OSError:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
payload = {"base_url": normalize_base_url(config.base_url), "cookie": normalize_cookie(config.cookie)}
|
|
112
|
+
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
113
|
+
try:
|
|
114
|
+
path.chmod(0o600)
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> JiraConfig:
|
|
120
|
+
if not path.exists():
|
|
121
|
+
raise ConfigError(f"Config file not found at {path}")
|
|
122
|
+
try:
|
|
123
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
124
|
+
except json.JSONDecodeError as exc:
|
|
125
|
+
raise ConfigError(f"Config file is not valid JSON: {path}") from exc
|
|
126
|
+
|
|
127
|
+
base_url = data.get("base_url")
|
|
128
|
+
cookie = data.get("cookie")
|
|
129
|
+
if not isinstance(base_url, str) or not isinstance(cookie, str):
|
|
130
|
+
raise ConfigError("Config file must contain string values for 'base_url' and 'cookie'.")
|
|
131
|
+
return JiraConfig(base_url=normalize_base_url(base_url), cookie=normalize_cookie(cookie))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def clear_config(path: Path = DEFAULT_CONFIG_PATH) -> bool:
|
|
135
|
+
if not path.exists():
|
|
136
|
+
return False
|
|
137
|
+
path.unlink()
|
|
138
|
+
return True
|
jiradc_cli/main.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from .client import JiraApiError, JiraClient, JiraTransportError
|
|
12
|
+
from .config import (
|
|
13
|
+
ConfigError,
|
|
14
|
+
JiraConfig,
|
|
15
|
+
clear_config,
|
|
16
|
+
cookie_variants_for_auth,
|
|
17
|
+
load_config,
|
|
18
|
+
normalize_base_url,
|
|
19
|
+
normalize_cookie,
|
|
20
|
+
save_config,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(help="CLI for Jira Data Center using browser session cookies.")
|
|
24
|
+
project_app = typer.Typer(help="Project-related operations.")
|
|
25
|
+
issue_app = typer.Typer(help="Issue-related operations.")
|
|
26
|
+
|
|
27
|
+
app.add_typer(project_app, name="project")
|
|
28
|
+
app.add_typer(issue_app, name="issue")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _echo_json(payload: Any) -> None:
|
|
32
|
+
typer.echo(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _require_client() -> JiraClient:
|
|
36
|
+
try:
|
|
37
|
+
config = load_config()
|
|
38
|
+
except ConfigError as exc:
|
|
39
|
+
raise typer.Exit(code=_fail(f"{exc}. Run 'jiradc login' first."))
|
|
40
|
+
return JiraClient(config)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _fail(message: str) -> int:
|
|
44
|
+
typer.secho(message, fg=typer.colors.RED, err=True)
|
|
45
|
+
return 1
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _require_success(label: str, action: Callable[[], Any]) -> Any:
|
|
49
|
+
try:
|
|
50
|
+
return action()
|
|
51
|
+
except (ConfigError, JiraApiError, JiraTransportError) as exc:
|
|
52
|
+
raise typer.Exit(code=_fail(f"{label} failed: {exc}"))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _read_clipboard() -> str:
|
|
56
|
+
candidates: list[list[str]] = []
|
|
57
|
+
if sys.platform == "darwin" and shutil.which("pbpaste"):
|
|
58
|
+
candidates.append(["pbpaste"])
|
|
59
|
+
elif sys.platform.startswith("win"):
|
|
60
|
+
candidates.append(["powershell", "-NoProfile", "-Command", "Get-Clipboard"])
|
|
61
|
+
else:
|
|
62
|
+
if shutil.which("wl-paste"):
|
|
63
|
+
candidates.append(["wl-paste", "--no-newline"])
|
|
64
|
+
if shutil.which("xclip"):
|
|
65
|
+
candidates.append(["xclip", "-selection", "clipboard", "-o"])
|
|
66
|
+
if shutil.which("xsel"):
|
|
67
|
+
candidates.append(["xsel", "--clipboard", "--output"])
|
|
68
|
+
|
|
69
|
+
last_error = "No clipboard utility found."
|
|
70
|
+
for cmd in candidates:
|
|
71
|
+
try:
|
|
72
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
73
|
+
value = result.stdout.strip()
|
|
74
|
+
if value:
|
|
75
|
+
return value
|
|
76
|
+
last_error = "Clipboard is empty."
|
|
77
|
+
except Exception as exc: # pragma: no cover
|
|
78
|
+
last_error = str(exc)
|
|
79
|
+
raise ConfigError(f"Unable to read cookie from clipboard. {last_error}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _looks_like_url(value: str) -> bool:
|
|
83
|
+
lowered = value.strip().lower()
|
|
84
|
+
return lowered.startswith("http://") or lowered.startswith("https://")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _extract_username(payload: Any) -> str | None:
|
|
88
|
+
if not isinstance(payload, dict):
|
|
89
|
+
return None
|
|
90
|
+
name = payload.get("name")
|
|
91
|
+
if isinstance(name, str) and name:
|
|
92
|
+
return name
|
|
93
|
+
display_name = payload.get("displayName")
|
|
94
|
+
if isinstance(display_name, str) and display_name:
|
|
95
|
+
return display_name
|
|
96
|
+
session = payload.get("session")
|
|
97
|
+
if isinstance(session, dict):
|
|
98
|
+
session_name = session.get("name")
|
|
99
|
+
if isinstance(session_name, str) and session_name:
|
|
100
|
+
return session_name
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _verify_cookie_session(config: JiraConfig) -> tuple[JiraConfig, str]:
|
|
105
|
+
errors: list[str] = []
|
|
106
|
+
endpoints = ["/api/2/myself", "/auth/1/session"]
|
|
107
|
+
|
|
108
|
+
for candidate_cookie in cookie_variants_for_auth(config.cookie):
|
|
109
|
+
candidate = JiraConfig(base_url=config.base_url, cookie=candidate_cookie)
|
|
110
|
+
client = JiraClient(candidate)
|
|
111
|
+
for endpoint in endpoints:
|
|
112
|
+
try:
|
|
113
|
+
payload = client.request("GET", endpoint)
|
|
114
|
+
except (JiraApiError, JiraTransportError) as exc:
|
|
115
|
+
errors.append(f"{endpoint}: {exc}")
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
username = _extract_username(payload)
|
|
119
|
+
return JiraConfig(base_url=config.base_url, cookie=candidate_cookie), (username or "unknown")
|
|
120
|
+
|
|
121
|
+
joined_errors = " | ".join(errors)
|
|
122
|
+
raise ConfigError(
|
|
123
|
+
"Cookie verification failed on Jira endpoints. "
|
|
124
|
+
"Try copying only Jira session cookies (JSESSIONID, atlassian.xsrf.token, AWSALB/AWSALBCORS). "
|
|
125
|
+
f"Details: {joined_errors}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command()
|
|
130
|
+
def login(
|
|
131
|
+
base_url: str = typer.Option(
|
|
132
|
+
...,
|
|
133
|
+
"--base-url",
|
|
134
|
+
prompt="Jira base URL (example: https://jira.example.com)",
|
|
135
|
+
help="Base URL for your Jira Data Center instance.",
|
|
136
|
+
),
|
|
137
|
+
cookie: str | None = typer.Option(
|
|
138
|
+
None,
|
|
139
|
+
"--cookie",
|
|
140
|
+
help="Cookie header value copied from an already logged-in browser session (overrides clipboard).",
|
|
141
|
+
),
|
|
142
|
+
cookie_from_clipboard: bool = typer.Option(
|
|
143
|
+
True,
|
|
144
|
+
"--cookie-from-clipboard/--no-cookie-from-clipboard",
|
|
145
|
+
help="Read cookie from your system clipboard when --cookie is not provided.",
|
|
146
|
+
),
|
|
147
|
+
skip_verify: bool = typer.Option(
|
|
148
|
+
False,
|
|
149
|
+
"--skip-verify",
|
|
150
|
+
help="Save the configuration without calling Jira to verify the cookie.",
|
|
151
|
+
),
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Save Jira URL + browser cookie and verify auth session."""
|
|
154
|
+
if cookie is None:
|
|
155
|
+
if not cookie_from_clipboard:
|
|
156
|
+
raise typer.Exit(
|
|
157
|
+
code=_fail("No cookie provided. Use --cookie or enable --cookie-from-clipboard.")
|
|
158
|
+
)
|
|
159
|
+
typer.echo("Copy your logged-in Jira Cookie header value to clipboard.")
|
|
160
|
+
typer.prompt("Press Enter to read clipboard", default="", show_default=False)
|
|
161
|
+
|
|
162
|
+
attempts = 3
|
|
163
|
+
for idx in range(attempts):
|
|
164
|
+
try:
|
|
165
|
+
candidate = _read_clipboard()
|
|
166
|
+
except ConfigError as exc:
|
|
167
|
+
raise typer.Exit(code=_fail(str(exc)))
|
|
168
|
+
|
|
169
|
+
if _looks_like_url(candidate):
|
|
170
|
+
if idx == attempts - 1:
|
|
171
|
+
raise typer.Exit(
|
|
172
|
+
code=_fail(
|
|
173
|
+
"Clipboard content looks like a URL, not a Cookie header value. "
|
|
174
|
+
"Copy the cookie from browser devtools and try again."
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
typer.secho(
|
|
178
|
+
"Clipboard currently looks like a URL. Copy the Cookie header value, then press Enter to retry.",
|
|
179
|
+
fg=typer.colors.YELLOW,
|
|
180
|
+
err=True,
|
|
181
|
+
)
|
|
182
|
+
typer.prompt("Retry", default="", show_default=False)
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
cookie = candidate
|
|
186
|
+
break
|
|
187
|
+
else:
|
|
188
|
+
raise typer.Exit(code=_fail("Unable to read a valid cookie value from clipboard."))
|
|
189
|
+
try:
|
|
190
|
+
config = JiraConfig(base_url=normalize_base_url(base_url), cookie=normalize_cookie(cookie))
|
|
191
|
+
except ConfigError as exc:
|
|
192
|
+
raise typer.Exit(code=_fail(str(exc)))
|
|
193
|
+
|
|
194
|
+
if not skip_verify:
|
|
195
|
+
try:
|
|
196
|
+
verified, user = _verify_cookie_session(config)
|
|
197
|
+
except ConfigError as exc:
|
|
198
|
+
raise typer.Exit(code=_fail(str(exc)))
|
|
199
|
+
if verified.cookie != config.cookie:
|
|
200
|
+
typer.secho(
|
|
201
|
+
"Using minimized Jira-focused cookie set for CLI requests.",
|
|
202
|
+
fg=typer.colors.YELLOW,
|
|
203
|
+
)
|
|
204
|
+
config = verified
|
|
205
|
+
typer.secho(f"Verified session for user: {user}", fg=typer.colors.GREEN)
|
|
206
|
+
|
|
207
|
+
save_config(config)
|
|
208
|
+
typer.secho("Saved configuration to ~/.config/jiradc-cli/config.json", fg=typer.colors.GREEN)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.command()
|
|
212
|
+
def logout() -> None:
|
|
213
|
+
"""Delete locally stored Jira URL + cookie."""
|
|
214
|
+
removed = clear_config()
|
|
215
|
+
if removed:
|
|
216
|
+
typer.secho("Removed local Jira CLI config.", fg=typer.colors.GREEN)
|
|
217
|
+
else:
|
|
218
|
+
typer.echo("No local config found.")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@app.command("whoami")
|
|
222
|
+
def whoami(raw: bool = typer.Option(False, "--raw", help="Print full JSON response.")) -> None:
|
|
223
|
+
"""Show the currently authenticated Jira user."""
|
|
224
|
+
|
|
225
|
+
def run() -> Any:
|
|
226
|
+
return _require_client().request("GET", "/api/2/myself")
|
|
227
|
+
|
|
228
|
+
payload = _require_success("whoami", run)
|
|
229
|
+
if raw:
|
|
230
|
+
_echo_json(payload)
|
|
231
|
+
return
|
|
232
|
+
if not isinstance(payload, dict):
|
|
233
|
+
typer.echo(str(payload))
|
|
234
|
+
return
|
|
235
|
+
typer.echo(f"Name: {payload.get('displayName') or payload.get('name')}")
|
|
236
|
+
typer.echo(f"Username: {payload.get('name')}")
|
|
237
|
+
typer.echo(f"Email: {payload.get('emailAddress')}")
|
|
238
|
+
typer.echo(f"Active: {payload.get('active')}")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@project_app.command("list")
|
|
242
|
+
def project_list(raw: bool = typer.Option(False, "--raw", help="Print full JSON response.")) -> None:
|
|
243
|
+
"""List visible projects."""
|
|
244
|
+
|
|
245
|
+
def run() -> Any:
|
|
246
|
+
return _require_client().request("GET", "/api/2/project")
|
|
247
|
+
|
|
248
|
+
payload = _require_success("project list", run)
|
|
249
|
+
if raw:
|
|
250
|
+
_echo_json(payload)
|
|
251
|
+
return
|
|
252
|
+
if not isinstance(payload, list):
|
|
253
|
+
typer.echo(str(payload))
|
|
254
|
+
return
|
|
255
|
+
for project in payload:
|
|
256
|
+
if not isinstance(project, dict):
|
|
257
|
+
continue
|
|
258
|
+
key = project.get("key")
|
|
259
|
+
name = project.get("name")
|
|
260
|
+
project_type = project.get("projectTypeKey")
|
|
261
|
+
typer.echo(f"{str(key):10} {name} [{project_type}]")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@issue_app.command("get")
|
|
265
|
+
def issue_get(
|
|
266
|
+
issue_key: str = typer.Argument(..., help="Issue key (example: PROJ-123)."),
|
|
267
|
+
expand: str | None = typer.Option(None, "--expand", help="Optional fields to expand."),
|
|
268
|
+
raw: bool = typer.Option(False, "--raw", help="Print full JSON response."),
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Get issue details by key."""
|
|
271
|
+
|
|
272
|
+
def run() -> Any:
|
|
273
|
+
params = {"expand": expand} if expand else None
|
|
274
|
+
return _require_client().request("GET", f"/api/2/issue/{issue_key}", params=params)
|
|
275
|
+
|
|
276
|
+
payload = _require_success("issue get", run)
|
|
277
|
+
if raw:
|
|
278
|
+
_echo_json(payload)
|
|
279
|
+
return
|
|
280
|
+
if not isinstance(payload, dict):
|
|
281
|
+
typer.echo(str(payload))
|
|
282
|
+
return
|
|
283
|
+
fields = payload.get("fields", {}) if isinstance(payload.get("fields"), dict) else {}
|
|
284
|
+
status_name = None
|
|
285
|
+
status = fields.get("status")
|
|
286
|
+
if isinstance(status, dict):
|
|
287
|
+
status_name = status.get("name")
|
|
288
|
+
summary = fields.get("summary")
|
|
289
|
+
assignee = fields.get("assignee")
|
|
290
|
+
assignee_name = assignee.get("displayName") if isinstance(assignee, dict) else None
|
|
291
|
+
typer.echo(f"Key: {payload.get('key')}")
|
|
292
|
+
typer.echo(f"Summary: {summary}")
|
|
293
|
+
typer.echo(f"Status: {status_name}")
|
|
294
|
+
typer.echo(f"Assignee: {assignee_name}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@issue_app.command("search")
|
|
298
|
+
def issue_search(
|
|
299
|
+
jql: str = typer.Option(..., "--jql", help="JQL query."),
|
|
300
|
+
max_results: int = typer.Option(20, "--max-results", min=1, max=200),
|
|
301
|
+
start_at: int = typer.Option(0, "--start-at", min=0),
|
|
302
|
+
fields: str = typer.Option("summary,status,assignee", "--fields", help="Comma-separated Jira fields."),
|
|
303
|
+
raw: bool = typer.Option(False, "--raw", help="Print full JSON response."),
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Search issues with JQL."""
|
|
306
|
+
|
|
307
|
+
def run() -> Any:
|
|
308
|
+
params = {
|
|
309
|
+
"jql": jql,
|
|
310
|
+
"maxResults": max_results,
|
|
311
|
+
"startAt": start_at,
|
|
312
|
+
"fields": fields,
|
|
313
|
+
}
|
|
314
|
+
return _require_client().request("GET", "/api/2/search", params=params)
|
|
315
|
+
|
|
316
|
+
payload = _require_success("issue search", run)
|
|
317
|
+
if raw:
|
|
318
|
+
_echo_json(payload)
|
|
319
|
+
return
|
|
320
|
+
if not isinstance(payload, dict):
|
|
321
|
+
typer.echo(str(payload))
|
|
322
|
+
return
|
|
323
|
+
issues = payload.get("issues")
|
|
324
|
+
if not isinstance(issues, list):
|
|
325
|
+
_echo_json(payload)
|
|
326
|
+
return
|
|
327
|
+
for issue in issues:
|
|
328
|
+
if not isinstance(issue, dict):
|
|
329
|
+
continue
|
|
330
|
+
issue_key = issue.get("key")
|
|
331
|
+
issue_fields = issue.get("fields", {})
|
|
332
|
+
if not isinstance(issue_fields, dict):
|
|
333
|
+
issue_fields = {}
|
|
334
|
+
summary = issue_fields.get("summary")
|
|
335
|
+
status_name = None
|
|
336
|
+
status = issue_fields.get("status")
|
|
337
|
+
if isinstance(status, dict):
|
|
338
|
+
status_name = status.get("name")
|
|
339
|
+
assignee_name = None
|
|
340
|
+
assignee = issue_fields.get("assignee")
|
|
341
|
+
if isinstance(assignee, dict):
|
|
342
|
+
assignee_name = assignee.get("displayName") or assignee.get("name")
|
|
343
|
+
typer.echo(f"{str(issue_key):12} {status_name or '-':14} {assignee_name or '-':20} {summary}")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@issue_app.command("create")
|
|
347
|
+
def issue_create(
|
|
348
|
+
project: str = typer.Option(..., "--project", help="Project key, e.g. PROJ."),
|
|
349
|
+
summary: str = typer.Option(..., "--summary", help="Issue summary."),
|
|
350
|
+
issue_type: str = typer.Option("Task", "--issue-type", help="Jira issue type name."),
|
|
351
|
+
description: str | None = typer.Option(None, "--description", help="Issue description."),
|
|
352
|
+
assignee: str | None = typer.Option(None, "--assignee", help="Username to assign at creation."),
|
|
353
|
+
raw: bool = typer.Option(False, "--raw", help="Print full JSON response."),
|
|
354
|
+
) -> None:
|
|
355
|
+
"""Create an issue."""
|
|
356
|
+
|
|
357
|
+
def run() -> Any:
|
|
358
|
+
fields: dict[str, Any] = {
|
|
359
|
+
"project": {"key": project},
|
|
360
|
+
"summary": summary,
|
|
361
|
+
"issuetype": {"name": issue_type},
|
|
362
|
+
}
|
|
363
|
+
if description:
|
|
364
|
+
fields["description"] = description
|
|
365
|
+
if assignee:
|
|
366
|
+
fields["assignee"] = {"name": assignee}
|
|
367
|
+
return _require_client().request("POST", "/api/2/issue", json_body={"fields": fields})
|
|
368
|
+
|
|
369
|
+
payload = _require_success("issue create", run)
|
|
370
|
+
if raw:
|
|
371
|
+
_echo_json(payload)
|
|
372
|
+
return
|
|
373
|
+
if isinstance(payload, dict):
|
|
374
|
+
typer.echo(f"Created issue: {payload.get('key')} (id={payload.get('id')})")
|
|
375
|
+
else:
|
|
376
|
+
typer.echo(str(payload))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@issue_app.command("comments")
|
|
380
|
+
def issue_comments(
|
|
381
|
+
issue_key: str = typer.Argument(..., help="Issue key (example: PROJ-123)."),
|
|
382
|
+
raw: bool = typer.Option(False, "--raw", help="Print full JSON response."),
|
|
383
|
+
) -> None:
|
|
384
|
+
"""List comments for an issue."""
|
|
385
|
+
|
|
386
|
+
def run() -> Any:
|
|
387
|
+
return _require_client().request("GET", f"/api/2/issue/{issue_key}/comment")
|
|
388
|
+
|
|
389
|
+
payload = _require_success("issue comments", run)
|
|
390
|
+
if raw:
|
|
391
|
+
_echo_json(payload)
|
|
392
|
+
return
|
|
393
|
+
if not isinstance(payload, dict):
|
|
394
|
+
typer.echo(str(payload))
|
|
395
|
+
return
|
|
396
|
+
comments = payload.get("comments")
|
|
397
|
+
if not isinstance(comments, list):
|
|
398
|
+
_echo_json(payload)
|
|
399
|
+
return
|
|
400
|
+
for comment in comments:
|
|
401
|
+
if not isinstance(comment, dict):
|
|
402
|
+
continue
|
|
403
|
+
author = comment.get("author") if isinstance(comment.get("author"), dict) else {}
|
|
404
|
+
created = comment.get("created")
|
|
405
|
+
body = str(comment.get("body", "")).replace("\n", " ")
|
|
406
|
+
trimmed_body = body[:180] + ("..." if len(body) > 180 else "")
|
|
407
|
+
typer.echo(f"[{comment.get('id')}] {author.get('displayName') or author.get('name')} @ {created}")
|
|
408
|
+
typer.echo(f" {trimmed_body}")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@issue_app.command("comment-add")
|
|
412
|
+
def issue_comment_add(
|
|
413
|
+
issue_key: str = typer.Argument(..., help="Issue key (example: PROJ-123)."),
|
|
414
|
+
body: str = typer.Option(..., "--body", prompt=True, help="Comment body."),
|
|
415
|
+
raw: bool = typer.Option(False, "--raw", help="Print full JSON response."),
|
|
416
|
+
) -> None:
|
|
417
|
+
"""Add a comment to an issue."""
|
|
418
|
+
|
|
419
|
+
def run() -> Any:
|
|
420
|
+
return _require_client().request(
|
|
421
|
+
"POST",
|
|
422
|
+
f"/api/2/issue/{issue_key}/comment",
|
|
423
|
+
json_body={"body": body},
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
payload = _require_success("issue comment-add", run)
|
|
427
|
+
if raw:
|
|
428
|
+
_echo_json(payload)
|
|
429
|
+
return
|
|
430
|
+
if isinstance(payload, dict):
|
|
431
|
+
typer.echo(f"Added comment {payload.get('id')} to {issue_key}")
|
|
432
|
+
else:
|
|
433
|
+
typer.echo(str(payload))
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@issue_app.command("transitions")
|
|
437
|
+
def issue_transitions(
|
|
438
|
+
issue_key: str = typer.Argument(..., help="Issue key (example: PROJ-123)."),
|
|
439
|
+
raw: bool = typer.Option(False, "--raw", help="Print full JSON response."),
|
|
440
|
+
) -> None:
|
|
441
|
+
"""List available transitions for an issue."""
|
|
442
|
+
|
|
443
|
+
def run() -> Any:
|
|
444
|
+
return _require_client().request("GET", f"/api/2/issue/{issue_key}/transitions")
|
|
445
|
+
|
|
446
|
+
payload = _require_success("issue transitions", run)
|
|
447
|
+
if raw:
|
|
448
|
+
_echo_json(payload)
|
|
449
|
+
return
|
|
450
|
+
if not isinstance(payload, dict):
|
|
451
|
+
typer.echo(str(payload))
|
|
452
|
+
return
|
|
453
|
+
transitions = payload.get("transitions")
|
|
454
|
+
if not isinstance(transitions, list):
|
|
455
|
+
_echo_json(payload)
|
|
456
|
+
return
|
|
457
|
+
for transition in transitions:
|
|
458
|
+
if not isinstance(transition, dict):
|
|
459
|
+
continue
|
|
460
|
+
to_state = transition.get("to") if isinstance(transition.get("to"), dict) else {}
|
|
461
|
+
typer.echo(f"{transition.get('id'):>4} {transition.get('name')} -> {to_state.get('name')}")
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@issue_app.command("transition")
|
|
465
|
+
def issue_transition(
|
|
466
|
+
issue_key: str = typer.Argument(..., help="Issue key (example: PROJ-123)."),
|
|
467
|
+
transition_id: str = typer.Option(..., "--id", help="Transition id from 'issue transitions'."),
|
|
468
|
+
comment: str | None = typer.Option(None, "--comment", help="Optional transition comment."),
|
|
469
|
+
) -> None:
|
|
470
|
+
"""Perform a transition on an issue."""
|
|
471
|
+
|
|
472
|
+
def run() -> Any:
|
|
473
|
+
payload: dict[str, Any] = {"transition": {"id": transition_id}}
|
|
474
|
+
if comment:
|
|
475
|
+
payload["update"] = {"comment": [{"add": {"body": comment}}]}
|
|
476
|
+
return _require_client().request(
|
|
477
|
+
"POST",
|
|
478
|
+
f"/api/2/issue/{issue_key}/transitions",
|
|
479
|
+
json_body=payload,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
_require_success("issue transition", run)
|
|
483
|
+
typer.secho(f"Transitioned {issue_key} via id {transition_id}", fg=typer.colors.GREEN)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
@issue_app.command("assign")
|
|
487
|
+
def issue_assign(
|
|
488
|
+
issue_key: str = typer.Argument(..., help="Issue key (example: PROJ-123)."),
|
|
489
|
+
username: str = typer.Option(..., "--username", prompt=True, help="Jira username."),
|
|
490
|
+
) -> None:
|
|
491
|
+
"""Assign an issue to a user."""
|
|
492
|
+
|
|
493
|
+
def run() -> Any:
|
|
494
|
+
return _require_client().request(
|
|
495
|
+
"PUT",
|
|
496
|
+
f"/api/2/issue/{issue_key}/assignee",
|
|
497
|
+
json_body={"name": username},
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
_require_success("issue assign", run)
|
|
501
|
+
typer.secho(f"Assigned {issue_key} to {username}", fg=typer.colors.GREEN)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def main() -> None:
|
|
505
|
+
app()
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
if __name__ == "__main__":
|
|
509
|
+
main()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jiradc-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typer CLI for Jira Data Center using browser session cookies.
|
|
5
|
+
Project-URL: Homepage, https://github.com/marcosgalleterobbva/jiradc-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/marcosgalleterobbva/jiradc-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/marcosgalleterobbva/jiradc-cli/issues
|
|
8
|
+
Keywords: jira,jira-data-center,cli,typer,sso,cookie-auth
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: requests>=2.31.0
|
|
22
|
+
Requires-Dist: typer>=0.12.0
|
|
23
|
+
Provides-Extra: release
|
|
24
|
+
Requires-Dist: build>=1.2.2; extra == "release"
|
|
25
|
+
Requires-Dist: twine>=5.1.1; extra == "release"
|
|
26
|
+
|
|
27
|
+
# jiradc-cli
|
|
28
|
+
|
|
29
|
+
Typer CLI for Jira Data Center that authenticates with a browser session cookie (no PAT required).
|
|
30
|
+
|
|
31
|
+
Agent-oriented project docs:
|
|
32
|
+
- `AGENTS.md`
|
|
33
|
+
- `docs/PROJECT_OVERVIEW.md`
|
|
34
|
+
- `docs/COMMAND_REFERENCE.md`
|
|
35
|
+
- `docs/DEVELOPMENT_NOTES.md`
|
|
36
|
+
- `docs/PUBLISHING.md`
|
|
37
|
+
|
|
38
|
+
The endpoint set was selected from the OpenAPI/Postman files in `resources/` for common end-user workflows:
|
|
39
|
+
- Authentication/session validation (`/rest/auth/1/session`)
|
|
40
|
+
- User identity (`/rest/api/2/myself`)
|
|
41
|
+
- Project discovery (`/rest/api/2/project`)
|
|
42
|
+
- Issue search/read/create (`/rest/api/2/search`, `/rest/api/2/issue`, `/rest/api/2/issue/{issueIdOrKey}`)
|
|
43
|
+
- Comments (`/rest/api/2/issue/{issueIdOrKey}/comment`)
|
|
44
|
+
- Workflow transitions (`/rest/api/2/issue/{issueIdOrKey}/transitions`)
|
|
45
|
+
- Assignment (`/rest/api/2/issue/{issueIdOrKey}/assignee`)
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install -e .
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Login with Browser Cookie
|
|
54
|
+
|
|
55
|
+
Export the cookie from an already logged-in Jira browser session, then run:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
jiradc login --base-url https://jira.example.com
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The command then pauses and asks you to copy your Jira `Cookie` header value to clipboard, reads it from clipboard, and validates it against `/rest/auth/1/session`.
|
|
62
|
+
On macOS this uses `pbpaste`.
|
|
63
|
+
During login, the CLI automatically reduces large browser cookie sets to Jira-relevant session cookies first (for better compatibility with SSO/WAF setups), and falls back to the full cookie if needed.
|
|
64
|
+
|
|
65
|
+
You can also pass it directly:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
jiradc login --base-url https://jira.example.com --cookie "JSESSIONID=...; atlassian.xsrf.token=..."
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Config is saved in:
|
|
72
|
+
- `~/.config/jiradc-cli/config.json`
|
|
73
|
+
|
|
74
|
+
## Commands
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
jiradc whoami
|
|
78
|
+
jiradc project list
|
|
79
|
+
jiradc issue search --jql "assignee = currentUser() AND statusCategory != Done"
|
|
80
|
+
jiradc issue get PROJ-123
|
|
81
|
+
jiradc issue create --project PROJ --summary "New task" --issue-type Task --description "Created from CLI"
|
|
82
|
+
jiradc issue comments PROJ-123
|
|
83
|
+
jiradc issue comment-add PROJ-123 --body "Working on this now."
|
|
84
|
+
jiradc issue transitions PROJ-123
|
|
85
|
+
jiradc issue transition PROJ-123 --id 31 --comment "Moving to In Progress"
|
|
86
|
+
jiradc issue assign PROJ-123 --username alice
|
|
87
|
+
jiradc logout
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Build and publish
|
|
91
|
+
|
|
92
|
+
Project metadata includes GitHub links in `pyproject.toml`:
|
|
93
|
+
- Homepage: `https://github.com/marcosgalleterobbva/jiradc-cli`
|
|
94
|
+
- Repository: `https://github.com/marcosgalleterobbva/jiradc-cli`
|
|
95
|
+
- Issues: `https://github.com/marcosgalleterobbva/jiradc-cli/issues`
|
|
96
|
+
|
|
97
|
+
Release tooling:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install -e ".[release]"
|
|
101
|
+
make build
|
|
102
|
+
make check
|
|
103
|
+
make publish-testpypi
|
|
104
|
+
make publish-pypi
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
See `docs/PUBLISHING.md` for the full release workflow.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
jiradc_cli/__init__.py,sha256=jQ3Fq5w73t1Ie9VVrQwy--OnDi8Si1vu0EUH0tsder0,86
|
|
2
|
+
jiradc_cli/__main__.py,sha256=qs6kN4rXgXytzVlo9nDeC8tm5daCP8xPL2lhUABzuiM,63
|
|
3
|
+
jiradc_cli/client.py,sha256=RXfjOgo-Kl6YOzWUjTHuEXLmynlcWuPL6lGv6dGfiME,3472
|
|
4
|
+
jiradc_cli/config.py,sha256=_RbjTgpN_Tvtj3qTSdGw9GIIxIR-u45UbGJQgr-TJHo,4214
|
|
5
|
+
jiradc_cli/main.py,sha256=VNWdmc0GQimPujEoM4HrMx0TL5fvhTKsSFNxHZu6oek,17831
|
|
6
|
+
jiradc_cli-0.1.0.dist-info/METADATA,sha256=oOS6M9-U6XmVjZLfg1QVdVsoSyWq4zcO0Op2KA-ePRY,3765
|
|
7
|
+
jiradc_cli-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
8
|
+
jiradc_cli-0.1.0.dist-info/entry_points.txt,sha256=Q9VqQLYx-5YzbJVZl4I-_YYF42lOHnQAjQoi_vw8ciQ,48
|
|
9
|
+
jiradc_cli-0.1.0.dist-info/top_level.txt,sha256=WWx2523PAWocZpNk1ZxJJRlcYxcup-99yrOJisE_UqI,11
|
|
10
|
+
jiradc_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jiradc_cli
|