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 ADDED
@@ -0,0 +1,5 @@
1
+ """Jira Data Center CLI package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
jiradc_cli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .main import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jiradc = jiradc_cli.main:main
@@ -0,0 +1 @@
1
+ jiradc_cli