jiradc-cli 0.3.0__tar.gz → 0.5.0__tar.gz
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-0.3.0 → jiradc_cli-0.5.0}/PKG-INFO +4 -1
- {jiradc_cli-0.3.0 → jiradc_cli-0.5.0}/README.md +3 -0
- {jiradc_cli-0.3.0 → jiradc_cli-0.5.0}/jiradc_cli/__init__.py +1 -1
- {jiradc_cli-0.3.0 → jiradc_cli-0.5.0}/jiradc_cli/client.py +17 -1
- {jiradc_cli-0.3.0 → jiradc_cli-0.5.0}/jiradc_cli/main.py +182 -5
- {jiradc_cli-0.3.0 → jiradc_cli-0.5.0}/jiradc_cli.egg-info/PKG-INFO +4 -1
- {jiradc_cli-0.3.0 → jiradc_cli-0.5.0}/pyproject.toml +2 -2
- {jiradc_cli-0.3.0 → jiradc_cli-0.5.0}/jiradc_cli/__main__.py +0 -0
- {jiradc_cli-0.3.0 → jiradc_cli-0.5.0}/jiradc_cli/config.py +0 -0
- {jiradc_cli-0.3.0 → jiradc_cli-0.5.0}/jiradc_cli.egg-info/SOURCES.txt +0 -0
- {jiradc_cli-0.3.0 → jiradc_cli-0.5.0}/jiradc_cli.egg-info/dependency_links.txt +0 -0
- {jiradc_cli-0.3.0 → jiradc_cli-0.5.0}/jiradc_cli.egg-info/entry_points.txt +0 -0
- {jiradc_cli-0.3.0 → jiradc_cli-0.5.0}/jiradc_cli.egg-info/requires.txt +0 -0
- {jiradc_cli-0.3.0 → jiradc_cli-0.5.0}/jiradc_cli.egg-info/top_level.txt +0 -0
- {jiradc_cli-0.3.0 → jiradc_cli-0.5.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jiradc-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Typer CLI for Jira Data Center using browser session cookies.
|
|
5
5
|
Project-URL: Homepage, https://github.com/marcosgalleterobbva/jiradc-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/marcosgalleterobbva/jiradc-cli
|
|
@@ -110,6 +110,9 @@ jiradc project versions PROJ
|
|
|
110
110
|
jiradc issue search --jql "assignee = currentUser() AND statusCategory != Done"
|
|
111
111
|
jiradc issue get PROJ-123
|
|
112
112
|
jiradc issue create --project PROJ --summary "New task" --issue-type Task
|
|
113
|
+
jiradc issue create --project PROJ --summary "Task with custom field" \
|
|
114
|
+
--description "Required description" \
|
|
115
|
+
--custom-field 'customfield_13300={"inputValues":["Team Backlog Value"]}'
|
|
113
116
|
jiradc issue editmeta PROJ-123
|
|
114
117
|
jiradc issue edit PROJ-123 --summary "Updated summary" --priority High
|
|
115
118
|
|
|
@@ -83,6 +83,9 @@ jiradc project versions PROJ
|
|
|
83
83
|
jiradc issue search --jql "assignee = currentUser() AND statusCategory != Done"
|
|
84
84
|
jiradc issue get PROJ-123
|
|
85
85
|
jiradc issue create --project PROJ --summary "New task" --issue-type Task
|
|
86
|
+
jiradc issue create --project PROJ --summary "Task with custom field" \
|
|
87
|
+
--description "Required description" \
|
|
88
|
+
--custom-field 'customfield_13300={"inputValues":["Team Backlog Value"]}'
|
|
86
89
|
jiradc issue editmeta PROJ-123
|
|
87
90
|
jiradc issue edit PROJ-123 --summary "Updated summary" --priority High
|
|
88
91
|
|
|
@@ -30,6 +30,7 @@ class JiraClient:
|
|
|
30
30
|
def __init__(self, config: JiraConfig, timeout_seconds: int = 30) -> None:
|
|
31
31
|
self.base_url = config.base_url.rstrip("/")
|
|
32
32
|
self.timeout_seconds = timeout_seconds
|
|
33
|
+
self.xsrf_token = _extract_xsrf_token(config.cookie)
|
|
33
34
|
self.session = requests.Session()
|
|
34
35
|
self.session.headers.update(
|
|
35
36
|
{
|
|
@@ -60,7 +61,14 @@ class JiraClient:
|
|
|
60
61
|
request_headers = dict(self.session.headers)
|
|
61
62
|
if method_upper in {"POST", "PUT", "PATCH", "DELETE"}:
|
|
62
63
|
# Jira Data Center instances behind SSO/WAF frequently enforce XSRF checks on mutations.
|
|
63
|
-
|
|
64
|
+
if self.xsrf_token:
|
|
65
|
+
request_headers["X-Atlassian-Token"] = self.xsrf_token
|
|
66
|
+
else:
|
|
67
|
+
request_headers.setdefault("X-Atlassian-Token", "no-check")
|
|
68
|
+
request_headers.setdefault("Origin", self.base_url)
|
|
69
|
+
request_headers.setdefault(
|
|
70
|
+
"Referer", f"{self.base_url}/secure/Dashboard.jspa"
|
|
71
|
+
)
|
|
64
72
|
if headers:
|
|
65
73
|
request_headers.update(headers)
|
|
66
74
|
try:
|
|
@@ -111,6 +119,14 @@ def _looks_like_html(text: str) -> bool:
|
|
|
111
119
|
return lowered.startswith("<!doctype html") or lowered.startswith("<html")
|
|
112
120
|
|
|
113
121
|
|
|
122
|
+
def _extract_xsrf_token(cookie: str) -> str | None:
|
|
123
|
+
for part in cookie.split(";"):
|
|
124
|
+
chunk = part.strip()
|
|
125
|
+
if chunk.lower().startswith("atlassian.xsrf.token="):
|
|
126
|
+
return chunk.split("=", 1)[1].strip() or None
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
114
130
|
def _summarize_html_error(text: str, status_code: int) -> str:
|
|
115
131
|
referral_match = re.search(r"log-referral-id\">([^<]+)<", text, re.IGNORECASE)
|
|
116
132
|
referral = referral_match.group(1).strip() if referral_match else None
|
|
@@ -175,6 +175,149 @@ def _print_issue_rows(rows: list[dict[str, Any]]) -> None:
|
|
|
175
175
|
typer.echo(f"{str(issue_key):12} {status_name or '-':14} {assignee_name or '-':20} {summary}")
|
|
176
176
|
|
|
177
177
|
|
|
178
|
+
def _load_json_object_option(raw_value: str, option_name: str) -> dict[str, Any]:
|
|
179
|
+
source = raw_value
|
|
180
|
+
if raw_value.startswith("@"):
|
|
181
|
+
path = Path(raw_value[1:]).expanduser()
|
|
182
|
+
try:
|
|
183
|
+
source = path.read_text(encoding="utf-8")
|
|
184
|
+
except OSError as exc:
|
|
185
|
+
raise typer.Exit(code=_fail(f"{option_name}: unable to read file {path}: {exc}"))
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
payload = json.loads(source)
|
|
189
|
+
except json.JSONDecodeError as exc:
|
|
190
|
+
raise typer.Exit(code=_fail(f"{option_name}: invalid JSON ({exc.msg})."))
|
|
191
|
+
|
|
192
|
+
if not isinstance(payload, dict):
|
|
193
|
+
raise typer.Exit(code=_fail(f"{option_name}: expected a JSON object."))
|
|
194
|
+
return payload
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_custom_field_assignments(assignments: list[str]) -> dict[str, Any]:
|
|
198
|
+
fields: dict[str, Any] = {}
|
|
199
|
+
for assignment in assignments:
|
|
200
|
+
if "=" not in assignment:
|
|
201
|
+
raise typer.Exit(
|
|
202
|
+
code=_fail(
|
|
203
|
+
"--custom-field must use FIELD_ID=VALUE format "
|
|
204
|
+
"(VALUE can be plain text or JSON)."
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
field_id, raw_value = assignment.split("=", 1)
|
|
209
|
+
field_id = field_id.strip()
|
|
210
|
+
value_text = raw_value.strip()
|
|
211
|
+
|
|
212
|
+
if not field_id:
|
|
213
|
+
raise typer.Exit(code=_fail("--custom-field must include a field id before '='."))
|
|
214
|
+
if not value_text:
|
|
215
|
+
raise typer.Exit(code=_fail(f"--custom-field {field_id}: value cannot be empty."))
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
parsed_value = json.loads(value_text)
|
|
219
|
+
except json.JSONDecodeError:
|
|
220
|
+
parsed_value = value_text
|
|
221
|
+
fields[field_id] = parsed_value
|
|
222
|
+
return fields
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _collect_issue_create_extra_fields(
|
|
226
|
+
custom_fields: list[str],
|
|
227
|
+
fields_json: str | None,
|
|
228
|
+
) -> dict[str, Any]:
|
|
229
|
+
merged = _parse_custom_field_assignments(custom_fields)
|
|
230
|
+
if fields_json is not None:
|
|
231
|
+
merged.update(_load_json_object_option(fields_json, "--fields-json"))
|
|
232
|
+
return merged
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _extract_issue_types(payload: Any) -> list[dict[str, Any]]:
|
|
236
|
+
candidates: list[dict[str, Any]] = []
|
|
237
|
+
if not isinstance(payload, dict):
|
|
238
|
+
return candidates
|
|
239
|
+
|
|
240
|
+
direct = payload.get("issueTypes")
|
|
241
|
+
if isinstance(direct, list):
|
|
242
|
+
candidates.extend(item for item in direct if isinstance(item, dict))
|
|
243
|
+
|
|
244
|
+
values = payload.get("values")
|
|
245
|
+
if isinstance(values, list):
|
|
246
|
+
candidates.extend(item for item in values if isinstance(item, dict))
|
|
247
|
+
|
|
248
|
+
projects = payload.get("projects")
|
|
249
|
+
if isinstance(projects, list):
|
|
250
|
+
for project in projects:
|
|
251
|
+
if not isinstance(project, dict):
|
|
252
|
+
continue
|
|
253
|
+
issue_types = project.get("issuetypes")
|
|
254
|
+
if not isinstance(issue_types, list):
|
|
255
|
+
issue_types = project.get("issueTypes")
|
|
256
|
+
if isinstance(issue_types, list):
|
|
257
|
+
candidates.extend(item for item in issue_types if isinstance(item, dict))
|
|
258
|
+
|
|
259
|
+
deduped: list[dict[str, Any]] = []
|
|
260
|
+
seen: set[str] = set()
|
|
261
|
+
for issue_type in candidates:
|
|
262
|
+
key = str(issue_type.get("id") or issue_type.get("name") or "")
|
|
263
|
+
if key in seen:
|
|
264
|
+
continue
|
|
265
|
+
seen.add(key)
|
|
266
|
+
deduped.append(issue_type)
|
|
267
|
+
return deduped
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _extract_createmeta_fields(payload: Any, issue_type_id: str | None = None) -> dict[str, Any] | None:
|
|
271
|
+
if not isinstance(payload, dict):
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
fields = payload.get("fields")
|
|
275
|
+
if isinstance(fields, dict):
|
|
276
|
+
return fields
|
|
277
|
+
|
|
278
|
+
values = payload.get("values")
|
|
279
|
+
if isinstance(values, list):
|
|
280
|
+
fallback: dict[str, Any] | None = None
|
|
281
|
+
for value in values:
|
|
282
|
+
if not isinstance(value, dict):
|
|
283
|
+
continue
|
|
284
|
+
value_fields = value.get("fields")
|
|
285
|
+
if not isinstance(value_fields, dict):
|
|
286
|
+
continue
|
|
287
|
+
if issue_type_id and str(value.get("id")) == issue_type_id:
|
|
288
|
+
return value_fields
|
|
289
|
+
if fallback is None:
|
|
290
|
+
fallback = value_fields
|
|
291
|
+
if fallback is not None:
|
|
292
|
+
return fallback
|
|
293
|
+
|
|
294
|
+
projects = payload.get("projects")
|
|
295
|
+
if isinstance(projects, list):
|
|
296
|
+
fallback = None
|
|
297
|
+
for project in projects:
|
|
298
|
+
if not isinstance(project, dict):
|
|
299
|
+
continue
|
|
300
|
+
issue_types = project.get("issuetypes")
|
|
301
|
+
if not isinstance(issue_types, list):
|
|
302
|
+
issue_types = project.get("issueTypes")
|
|
303
|
+
if not isinstance(issue_types, list):
|
|
304
|
+
continue
|
|
305
|
+
for issue_type in issue_types:
|
|
306
|
+
if not isinstance(issue_type, dict):
|
|
307
|
+
continue
|
|
308
|
+
issue_fields = issue_type.get("fields")
|
|
309
|
+
if not isinstance(issue_fields, dict):
|
|
310
|
+
continue
|
|
311
|
+
if issue_type_id and str(issue_type.get("id")) == issue_type_id:
|
|
312
|
+
return issue_fields
|
|
313
|
+
if fallback is None:
|
|
314
|
+
fallback = issue_fields
|
|
315
|
+
if fallback is not None:
|
|
316
|
+
return fallback
|
|
317
|
+
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
|
|
178
321
|
def _worklog_adjustment_params(
|
|
179
322
|
*,
|
|
180
323
|
adjust_estimate: str | None,
|
|
@@ -489,6 +632,19 @@ def issue_create(
|
|
|
489
632
|
issue_type: str = typer.Option("Task", "--issue-type", help="Jira issue type name."),
|
|
490
633
|
description: str | None = typer.Option(None, "--description", help="Issue description."),
|
|
491
634
|
assignee: str | None = typer.Option(None, "--assignee", help="Username to assign at creation."),
|
|
635
|
+
custom_field: list[str] = typer.Option(
|
|
636
|
+
[],
|
|
637
|
+
"--custom-field",
|
|
638
|
+
help=(
|
|
639
|
+
"Custom field assignment in FIELD_ID=VALUE format. "
|
|
640
|
+
"VALUE may be plain text or JSON. Repeat option for multiple fields."
|
|
641
|
+
),
|
|
642
|
+
),
|
|
643
|
+
fields_json: str | None = typer.Option(
|
|
644
|
+
None,
|
|
645
|
+
"--fields-json",
|
|
646
|
+
help="JSON object with additional fields, or @<path> to load JSON from file.",
|
|
647
|
+
),
|
|
492
648
|
raw: bool = typer.Option(False, "--raw", help="Print full JSON response."),
|
|
493
649
|
) -> None:
|
|
494
650
|
"""Create an issue."""
|
|
@@ -503,6 +659,7 @@ def issue_create(
|
|
|
503
659
|
fields["description"] = description
|
|
504
660
|
if assignee:
|
|
505
661
|
fields["assignee"] = {"name": assignee}
|
|
662
|
+
fields.update(_collect_issue_create_extra_fields(custom_field, fields_json))
|
|
506
663
|
return _require_client().request("POST", "/api/2/issue", json_body={"fields": fields})
|
|
507
664
|
|
|
508
665
|
payload = _require_success("issue create", run)
|
|
@@ -525,12 +682,20 @@ def issue_create_meta_types(
|
|
|
525
682
|
"""List issue types available when creating issues in a project."""
|
|
526
683
|
|
|
527
684
|
def run() -> Any:
|
|
685
|
+
client = _require_client()
|
|
528
686
|
params = {"maxResults": max_results, "startAt": start_at}
|
|
529
|
-
|
|
687
|
+
payload = client.request(
|
|
530
688
|
"GET",
|
|
531
689
|
f"/api/2/issue/createmeta/{project}/issuetypes",
|
|
532
690
|
params=params,
|
|
533
691
|
)
|
|
692
|
+
if _extract_issue_types(payload):
|
|
693
|
+
return payload
|
|
694
|
+
return client.request(
|
|
695
|
+
"GET",
|
|
696
|
+
"/api/2/issue/createmeta",
|
|
697
|
+
params={"projectKeys": project, "expand": "projects.issuetypes"},
|
|
698
|
+
)
|
|
534
699
|
|
|
535
700
|
payload = _require_success("issue createmeta-types", run)
|
|
536
701
|
if raw:
|
|
@@ -539,8 +704,8 @@ def issue_create_meta_types(
|
|
|
539
704
|
if not isinstance(payload, dict):
|
|
540
705
|
typer.echo(str(payload))
|
|
541
706
|
return
|
|
542
|
-
issue_types = payload
|
|
543
|
-
if not
|
|
707
|
+
issue_types = _extract_issue_types(payload)
|
|
708
|
+
if not issue_types:
|
|
544
709
|
_echo_json(payload)
|
|
545
710
|
return
|
|
546
711
|
for issue_type in issue_types:
|
|
@@ -563,12 +728,24 @@ def issue_create_meta_fields(
|
|
|
563
728
|
"""List field metadata for issue creation in a project/issue type."""
|
|
564
729
|
|
|
565
730
|
def run() -> Any:
|
|
731
|
+
client = _require_client()
|
|
566
732
|
params = {"maxResults": max_results, "startAt": start_at}
|
|
567
|
-
|
|
733
|
+
payload = client.request(
|
|
568
734
|
"GET",
|
|
569
735
|
f"/api/2/issue/createmeta/{project}/issuetypes/{issue_type_id}",
|
|
570
736
|
params=params,
|
|
571
737
|
)
|
|
738
|
+
if _extract_createmeta_fields(payload, issue_type_id) is not None:
|
|
739
|
+
return payload
|
|
740
|
+
return client.request(
|
|
741
|
+
"GET",
|
|
742
|
+
"/api/2/issue/createmeta",
|
|
743
|
+
params={
|
|
744
|
+
"projectKeys": project,
|
|
745
|
+
"issuetypeIds": issue_type_id,
|
|
746
|
+
"expand": "projects.issuetypes.fields",
|
|
747
|
+
},
|
|
748
|
+
)
|
|
572
749
|
|
|
573
750
|
payload = _require_success("issue createmeta-fields", run)
|
|
574
751
|
if raw:
|
|
@@ -577,7 +754,7 @@ def issue_create_meta_fields(
|
|
|
577
754
|
if not isinstance(payload, dict):
|
|
578
755
|
typer.echo(str(payload))
|
|
579
756
|
return
|
|
580
|
-
fields = payload
|
|
757
|
+
fields = _extract_createmeta_fields(payload, issue_type_id)
|
|
581
758
|
if not isinstance(fields, dict):
|
|
582
759
|
_echo_json(payload)
|
|
583
760
|
return
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jiradc-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Typer CLI for Jira Data Center using browser session cookies.
|
|
5
5
|
Project-URL: Homepage, https://github.com/marcosgalleterobbva/jiradc-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/marcosgalleterobbva/jiradc-cli
|
|
@@ -110,6 +110,9 @@ jiradc project versions PROJ
|
|
|
110
110
|
jiradc issue search --jql "assignee = currentUser() AND statusCategory != Done"
|
|
111
111
|
jiradc issue get PROJ-123
|
|
112
112
|
jiradc issue create --project PROJ --summary "New task" --issue-type Task
|
|
113
|
+
jiradc issue create --project PROJ --summary "Task with custom field" \
|
|
114
|
+
--description "Required description" \
|
|
115
|
+
--custom-field 'customfield_13300={"inputValues":["Team Backlog Value"]}'
|
|
113
116
|
jiradc issue editmeta PROJ-123
|
|
114
117
|
jiradc issue edit PROJ-123 --summary "Updated summary" --priority High
|
|
115
118
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "jiradc-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Typer CLI for Jira Data Center using browser session cookies."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -45,7 +45,7 @@ jiradc = "jiradc_cli.main:main"
|
|
|
45
45
|
include = ["jiradc_cli*"]
|
|
46
46
|
|
|
47
47
|
[tool.bumpversion]
|
|
48
|
-
current_version = "0.
|
|
48
|
+
current_version = "0.5.0"
|
|
49
49
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
50
50
|
serialize = ["{major}.{minor}.{patch}"]
|
|
51
51
|
search = "{current_version}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|