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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jiradc-cli
3
- Version: 0.3.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
 
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.3.0"
5
+ __version__ = "0.5.0"
@@ -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
- request_headers.setdefault("X-Atlassian-Token", "no-check")
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
- return _require_client().request(
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.get("issueTypes")
543
- if not isinstance(issue_types, list):
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
- return _require_client().request(
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.get("fields")
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.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.3.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.3.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