bugpipe 3.0.1__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.
bugpipe/api/parser.py ADDED
@@ -0,0 +1,720 @@
1
+ import json
2
+ import re
3
+ from datetime import UTC, datetime
4
+ from typing import Any
5
+
6
+ from ..api.models import (
7
+ CUSTOM_FIELD_IDS,
8
+ Attachment,
9
+ AttachmentRestriction,
10
+ Comment,
11
+ CommentsResult,
12
+ FieldChange,
13
+ Issue,
14
+ IssueType,
15
+ IssueUpdate,
16
+ IssueUpdatesResult,
17
+ Priority,
18
+ Results,
19
+ SearchResult,
20
+ Severity,
21
+ Status,
22
+ )
23
+
24
+ _RESPONSE_PREFIX = re.compile(r"^\)\]\}'(\\n|\r?\n)")
25
+
26
+
27
+ def __strip_response_prefix(raw_text: str) -> str:
28
+ """
29
+ Remove the )]}' anti-XSSI prefix that the API prepends to all JSON responses.
30
+
31
+ :param raw_text: Raw response body from the API.
32
+ :return: The response body with the prefix stripped, ready for json.loads().
33
+ """
34
+
35
+ return _RESPONSE_PREFIX.sub("", raw_text, count=1)
36
+
37
+
38
+ def __parse_json_response(raw_text: str) -> Any:
39
+ """
40
+ Strip the anti-XSSI prefix and parse the JSON body.
41
+
42
+ :param raw_text: Raw response body from the API.
43
+ :return: The parsed JSON (usually a nested list).
44
+ """
45
+
46
+ return json.loads(__strip_response_prefix(raw_text))
47
+
48
+
49
+ def __get(array: Any, *indices: int, default=None) -> Any:
50
+ """
51
+ Safely traverse nested arrays/lists by index.
52
+
53
+ :param array: The root array to traverse.
54
+ :param indices: One or more integer indices to follow.
55
+ :param default: Value to return if any index is out of bounds.
56
+ :return: The value at the given path, or default if not reachable.
57
+ """
58
+
59
+ current = array
60
+ for index in indices:
61
+ try:
62
+ current = current[index]
63
+ except (IndexError, TypeError, KeyError):
64
+ return default
65
+ return current
66
+
67
+
68
+ def __parse_timestamp(raw_timestamp: Any) -> datetime | None:
69
+ """
70
+ Parse a [seconds, nanos] timestamp array into a UTC datetime.
71
+
72
+ :param raw_timestamp: A list like [1657579144] or [1657579144, 285000000].
73
+ :return: A timezone-aware UTC datetime, or None if unparseable.
74
+ """
75
+
76
+ if (
77
+ not raw_timestamp
78
+ or not isinstance(raw_timestamp, list)
79
+ or len(raw_timestamp) < 1
80
+ ):
81
+ return None
82
+ try:
83
+ seconds = raw_timestamp[0]
84
+ nanos = raw_timestamp[1] if len(raw_timestamp) > 1 else 0
85
+ return datetime.fromtimestamp(seconds + nanos / 1e9, tz=UTC)
86
+ except (TypeError, ValueError, OSError):
87
+ return None
88
+
89
+
90
+ def __parse_email(user_array: Any) -> str | None:
91
+ """
92
+ Extract an email address from a user field array.
93
+
94
+ User fields look like [null, "user@example.com", 1, [...]].
95
+ We find the first string that looks like an email.
96
+
97
+ :param user_array: A list representing a user, or None.
98
+ :return: The email address, or None if not found.
99
+ """
100
+
101
+ if not user_array or not isinstance(user_array, list):
102
+ return None
103
+ for item in user_array:
104
+ if isinstance(item, str) and "@" in item:
105
+ return item
106
+ return None
107
+
108
+
109
+ def __parse_ccs(raw_ccs: Any) -> list[str]:
110
+ """
111
+ Parse a CC list where each entry is a user array like [null, "email", type].
112
+
113
+ :param raw_ccs: List of user arrays.
114
+ :return: List of email addresses.
115
+ """
116
+
117
+ if not raw_ccs or not isinstance(raw_ccs, list):
118
+ return []
119
+ return [email for entry in raw_ccs if (email := __parse_email(entry))]
120
+
121
+
122
+ def __parse_int_list(raw_list: Any) -> list[int]:
123
+ """
124
+ Extract integers from a list, ignoring non-int values.
125
+
126
+ :param raw_list: A list that should contain integers (e.g. hotlist IDs).
127
+ :return: Only the integer values from the list.
128
+ """
129
+
130
+ if not raw_list or not isinstance(raw_list, list):
131
+ return []
132
+ return [item for item in raw_list if isinstance(item, int)]
133
+
134
+
135
+ def __parse_custom_field_values(raw_field_entries: Any) -> dict[str, Any]:
136
+ """
137
+ Parse custom field value entries from the issue details array at [2][14].
138
+
139
+ Each entry is an array like::
140
+
141
+ [field_id, null, null, null, numeric_val?, label_values?,
142
+ null, enum_values?, null, display_string, ...]
143
+
144
+ The field_id is looked up in CUSTOM_FIELD_IDS to get a canonical name.
145
+ Unknown fields get a ``field_{id}`` name.
146
+
147
+ :param raw_field_entries: List of custom field arrays from the API.
148
+ :return: Mapping of field names to their parsed values. Values are
149
+ list[str] for multi-value fields, float for numeric fields,
150
+ or str for single-value text fields.
151
+ """
152
+
153
+ if not raw_field_entries or not isinstance(raw_field_entries, list):
154
+ return {}
155
+
156
+ parsed_fields: dict[str, Any] = {}
157
+ for entry in raw_field_entries:
158
+ if not isinstance(entry, list) or len(entry) < 1:
159
+ continue
160
+
161
+ field_id = entry[0]
162
+ field_name = CUSTOM_FIELD_IDS.get(field_id, f"field_{field_id}")
163
+
164
+ display_string = __get(entry, 9)
165
+
166
+ # Numeric value at index 4.
167
+ numeric_value = __get(entry, 4)
168
+ if isinstance(numeric_value, (int, float)):
169
+ parsed_fields[field_name] = numeric_value
170
+ continue
171
+
172
+ for index in (5, 7):
173
+ values = __get(entry, index)
174
+ if not isinstance(values, list):
175
+ continue
176
+ flat_values = []
177
+ for group in values:
178
+ if isinstance(group, list):
179
+ flat_values.extend(s for s in group if isinstance(s, str))
180
+ elif isinstance(group, str):
181
+ flat_values.append(group)
182
+ if flat_values:
183
+ parsed_fields[field_name] = flat_values
184
+ break
185
+ else:
186
+ if display_string and isinstance(display_string, str):
187
+ parsed_fields[field_name] = display_string
188
+
189
+ return parsed_fields
190
+
191
+
192
+ def __parse_issue_from_entry(raw_entry: list) -> Issue:
193
+ """
194
+ Parse a single issue from the 48-element array format used across all endpoints.
195
+
196
+ This is the core parser. Every endpoint (search, get, batch) ultimately
197
+ produces these 48-element arrays, just nested at different paths.
198
+
199
+ Array index map::
200
+
201
+ Top-level (48 elements):
202
+ [1] = issue ID (int)
203
+ [2] = details array (32 elements, see below)
204
+ [4] = created timestamp [seconds, nanos]
205
+ [5] = modified timestamp [seconds, nanos]
206
+ [6] = verified timestamp [seconds, nanos]
207
+ [9] = star count (int or None)
208
+ [10] = unknown constant (always 3)
209
+ [11] = comment count (int)
210
+ [13] = owner user array
211
+ [14] = custom field definitions (schema, not values)
212
+ [34] = last substantive activity timestamp [seconds, nanos]
213
+ [36] = blocking issue IDs (list of ints)
214
+ [41] = tracker ID (int)
215
+ [46] = view counts [24h, 7d, 30d] (empty list = 0 views)
216
+ [47] = last modifier user array
217
+
218
+ Details array [2] (32 elements):
219
+ [0] = component ID (int)
220
+ [1] = issue type (int, maps to IssueType enum)
221
+ [2] = status (int, maps to Status enum)
222
+ [3] = priority (int, 1-indexed: P0=1, P1=2, P2=3, P3=4, P4=5)
223
+ [4] = severity (int, 1-indexed: S0=1, S1=2, S2=3, S3=4, S4=5)
224
+ [5] = title (str)
225
+ [6] = reporter user array
226
+ [7] = verifier user array
227
+ [9] = CCs list (list of user arrays)
228
+ [13] = hotlist IDs (list of ints)
229
+ [14] = custom field values (list of field arrays)
230
+ [16] = found_in versions (list of strings)
231
+ [19] = in_prod flag (True = yes, None = no)
232
+ [21] = duplicate issue IDs (list of ints)
233
+ [30] = collaborators (list of user arrays)
234
+
235
+ :param raw_entry: The 48-element array representing one issue.
236
+ :return: A fully populated Issue dataclass.
237
+ """
238
+
239
+ issue_id = __get(raw_entry, 1, default=0)
240
+ details = __get(raw_entry, 2, default=[]) or []
241
+
242
+ # --- Details array fields ---
243
+ component_id = __get(details, 0)
244
+ issue_type_detail = __get(details, 1)
245
+ status_value = __get(details, 2, default=1)
246
+ priority_raw = __get(details, 3, default=3) # 1-indexed: P0=1, P1=2, ...
247
+ # Convert 1-indexed API priority to 0-indexed enum (P0=0, P1=1, ...)
248
+ priority_value = (priority_raw - 1) if isinstance(priority_raw, int) else 2
249
+ severity_raw = __get(details, 4) # 1-indexed: S0=1, S1=2, ...
250
+ title = __get(details, 5, default="") or ""
251
+ reporter_array = __get(details, 6)
252
+ verifier_array = __get(details, 7)
253
+ ccs_array = __get(details, 9)
254
+ hotlist_ids_array = __get(details, 13)
255
+ custom_field_entries = __get(details, 14)
256
+ found_in_raw = __get(details, 16)
257
+ in_prod_raw = __get(details, 19)
258
+ duplicate_ids_array = __get(details, 21)
259
+ collaborators_array = __get(details, 30)
260
+
261
+ # --- Top-level fields ---
262
+ created_timestamp = __get(raw_entry, 4)
263
+ modified_timestamp = __get(raw_entry, 5)
264
+ verified_timestamp = __get(raw_entry, 6)
265
+ last_activity_timestamp = __get(raw_entry, 34)
266
+ star_count = __get(raw_entry, 9, default=0)
267
+ if not isinstance(star_count, int):
268
+ star_count = 0
269
+ comment_count = __get(raw_entry, 11, default=0) or 0
270
+ owner_array = __get(raw_entry, 13)
271
+ blocking_ids_array = __get(raw_entry, 36)
272
+ body_array = __get(raw_entry, 43)
273
+ body = body_array[0] if isinstance(body_array, list) and body_array else None
274
+ tracker_id = __get(raw_entry, 41)
275
+ views_array = __get(raw_entry, 46, default=[]) or []
276
+ last_modifier_array = __get(raw_entry, 47)
277
+
278
+ # --- Parse custom fields into a mutable dict, then pop known ones ---
279
+ custom_fields = __parse_custom_field_values(custom_field_entries)
280
+
281
+ def pop_string_list(key: str) -> list[str]:
282
+ """
283
+ Pop a key from custom_fields and return it as a list of strings.
284
+ """
285
+
286
+ value = custom_fields.pop(key, None)
287
+ if value is None:
288
+ return []
289
+ if isinstance(value, list):
290
+ return value
291
+ if isinstance(value, str):
292
+ return [part.strip() for part in value.split(",") if part.strip()]
293
+ return []
294
+
295
+ def pop_string(key: str) -> str | None:
296
+ """
297
+ Pop a key from custom_fields and return it as a single string.
298
+ """
299
+
300
+ value = custom_fields.pop(key, None)
301
+ if value is None:
302
+ return None
303
+ if isinstance(value, str):
304
+ return value
305
+ if isinstance(value, list):
306
+ return ", ".join(str(v) for v in value)
307
+ return str(value)
308
+
309
+ def pop_float(key: str) -> float | None:
310
+ """
311
+ Pop a key from custom_fields and return it as a float.
312
+ """
313
+
314
+ value = custom_fields.pop(key, None)
315
+ if value is None:
316
+ return None
317
+ try:
318
+ return float(value)
319
+ except (TypeError, ValueError):
320
+ return None
321
+
322
+ # Parse severity (1-indexed like priority: S0=1, S1=2, ...)
323
+ severity_value = None
324
+ if isinstance(severity_raw, int):
325
+ severity_value = Severity(severity_raw - 1)
326
+
327
+ # Parse found_in version strings
328
+ found_in: list[str] = []
329
+ if isinstance(found_in_raw, list):
330
+ found_in = [v for v in found_in_raw if isinstance(v, str)]
331
+
332
+ # Parse in_prod flag (True = yes, None = no)
333
+ in_prod = True if in_prod_raw is True else None
334
+
335
+ # Parse view counts [24h, 7d, 30d]
336
+ views_24h, views_7d, views_30d = (
337
+ (
338
+ views_array[index]
339
+ if isinstance(views_array, list)
340
+ and len(views_array) > index
341
+ and isinstance(views_array[index], int)
342
+ else 0
343
+ )
344
+ for index in range(3)
345
+ )
346
+
347
+ return Issue(
348
+ id=issue_id,
349
+ title=title,
350
+ status=Status(status_value) if status_value else Status.NEW,
351
+ priority=Priority(priority_value),
352
+ severity=severity_value,
353
+ issue_type=IssueType(issue_type_detail) if issue_type_detail else None,
354
+ reporter=__parse_email(reporter_array),
355
+ owner=__parse_email(owner_array),
356
+ verifier=__parse_email(verifier_array),
357
+ component_id=component_id,
358
+ ccs=__parse_ccs(ccs_array),
359
+ collaborators=__parse_ccs(collaborators_array),
360
+ found_in=found_in,
361
+ in_prod=in_prod,
362
+ created_at=__parse_timestamp(created_timestamp),
363
+ modified_at=__parse_timestamp(modified_timestamp),
364
+ verified_at=__parse_timestamp(verified_timestamp),
365
+ last_activity_at=__parse_timestamp(last_activity_timestamp),
366
+ comment_count=comment_count,
367
+ star_count=star_count,
368
+ body=body,
369
+ tracker_id=tracker_id,
370
+ last_modifier=__parse_email(last_modifier_array),
371
+ hotlist_ids=__parse_int_list(hotlist_ids_array),
372
+ blocking_issue_ids=__parse_int_list(blocking_ids_array),
373
+ duplicate_issue_ids=__parse_int_list(duplicate_ids_array),
374
+ views_24h=views_24h,
375
+ views_7d=views_7d,
376
+ views_30d=views_30d,
377
+ component_tags=pop_string_list("component_tags"),
378
+ component_ancestor_tags=pop_string_list("component_ancestor_tags"),
379
+ labels=pop_string_list("chromium_labels"),
380
+ os=pop_string_list("os"),
381
+ milestone=pop_string_list("milestone"),
382
+ merge=pop_string_list("merge"),
383
+ merge_request=pop_string_list("merge_request"),
384
+ release_block=pop_string_list("release_block"),
385
+ cve=pop_string_list("cve"),
386
+ cwe_id=pop_float("cwe_id"),
387
+ vrp_reward=pop_float("vrp_reward"),
388
+ estimated_days=pop_float("estimated_days"),
389
+ build_number=pop_string("build_number"),
390
+ flaky_test=pop_string("flaky_test"),
391
+ next_action=pop_string("next_action"),
392
+ notice=pop_string("notice"),
393
+ introduced_in=pop_string("introduced_in"),
394
+ irm_link=pop_string("irm_link"),
395
+ security_release=pop_string_list("security_release"),
396
+ fixed_by_code_changes=pop_string_list("fixed_by_code_changes"),
397
+ custom_fields=custom_fields,
398
+ )
399
+
400
+
401
+ def __parse_search_response(
402
+ raw_text: str,
403
+ query: str = "",
404
+ page_size: int = 50,
405
+ ) -> SearchResult:
406
+ """
407
+ Parse a search/list response.
408
+
409
+ Response shape::
410
+
411
+ [["b.IssueSearchResponse", ..., [issues, page_token, total_count]]]
412
+
413
+ Issues at ``[0][6][0]``, pagination token at ``[0][6][1]``,
414
+ total count at ``[0][6][2]``.
415
+
416
+ :param raw_text: Raw response body from POST /action/issues/list.
417
+ :param query: The query string used (stored on the result for pagination).
418
+ :param page_size: The page size used (stored on the result for pagination).
419
+ :return: Parsed issues with pagination info.
420
+ """
421
+
422
+ data = __parse_json_response(raw_text)
423
+
424
+ response_wrapper = __get(data, 0, default=[])
425
+ result_block = __get(response_wrapper, 6, default=[])
426
+
427
+ raw_issues = __get(result_block, 0, default=[]) or []
428
+ page_token = __get(result_block, 1)
429
+ total_count = __get(result_block, 2, default=0) or 0
430
+
431
+ issues = Results(
432
+ __parse_issue_from_entry(entry)
433
+ for entry in raw_issues
434
+ if isinstance(entry, list)
435
+ )
436
+
437
+ return SearchResult(
438
+ issues=issues,
439
+ total_count=total_count,
440
+ next_page_token=page_token if page_token else None,
441
+ query=query,
442
+ page_size=page_size,
443
+ )
444
+
445
+
446
+ def __parse_issue_detail_response(raw_text: str) -> Issue:
447
+ """
448
+ Parse a getIssue response.
449
+
450
+ Response shape::
451
+
452
+ [["b.IssueFetchResponse", [<23-element payload>]]]
453
+
454
+ The 48-element issue entry is at ``data[0][1][22]``
455
+ (last element of the payload).
456
+
457
+ :param raw_text: Raw response body from POST /action/issues/{id}/getIssue.
458
+ :return: The fully parsed issue.
459
+ :raises ValueError: If the issue entry can't be located in the response.
460
+ """
461
+
462
+ data = __parse_json_response(raw_text)
463
+
464
+ response_wrapper = __get(data, 0, default=[])
465
+ payload = __get(response_wrapper, 1, default=[])
466
+
467
+ issue_entry = next(
468
+ (
469
+ candidate
470
+ for candidate in reversed(payload if isinstance(payload, list) else [])
471
+ if isinstance(candidate, list) and isinstance(__get(candidate, 1), int)
472
+ ),
473
+ None,
474
+ )
475
+
476
+ if issue_entry is None:
477
+ raise ValueError("Could not locate issue entry in getIssue response")
478
+
479
+ return __parse_issue_from_entry(issue_entry)
480
+
481
+
482
+ def __parse_batch_response(raw_text: str) -> Results[Issue]:
483
+ """
484
+ Parse a batch get response.
485
+
486
+ Response shape::
487
+
488
+ [["b.BatchGetIssuesResponse", null, [[issue1, issue2, ...]]]]
489
+
490
+ Each issue at ``data[0][2][0][i]`` is a standard 48-element array.
491
+
492
+ :param raw_text: Raw response body from POST /action/issues/batch.
493
+ :return: List of parsed issues.
494
+ """
495
+
496
+ data = __parse_json_response(raw_text)
497
+
498
+ response_wrapper = __get(data, 0, default=[])
499
+ entries_wrapper = __get(response_wrapper, 2, default=[])
500
+ raw_issues = __get(entries_wrapper, 0, default=[]) or []
501
+
502
+ return Results(
503
+ __parse_issue_from_entry(entry)
504
+ for entry in raw_issues
505
+ if isinstance(entry, list) and isinstance(__get(entry, 1), int)
506
+ )
507
+
508
+
509
+ def __parse_field_changes(raw_changes: Any) -> list[FieldChange]:
510
+ """
511
+ Parse field change entries from an update's changes array.
512
+
513
+ Each change looks like ``["field_name", null, old_value_wrapper, new_value_wrapper]``.
514
+ We currently only extract the field name.
515
+
516
+ :param raw_changes: The field changes array from an update entry.
517
+ :return: List of parsed field changes.
518
+ """
519
+
520
+ if not raw_changes or not isinstance(raw_changes, list):
521
+ return []
522
+ changes = []
523
+ for entry in raw_changes:
524
+ if not isinstance(entry, list) or len(entry) < 1:
525
+ continue
526
+ field_name = entry[0] if isinstance(entry[0], str) else str(entry[0])
527
+ changes.append(FieldChange(field=field_name))
528
+ return changes
529
+
530
+
531
+ def __parse_attachments(raw_attachments: Any, issue_id: int) -> list[Attachment] | None:
532
+ """
533
+ Parse attachment entries from an issue update.
534
+
535
+ The API returns attachment metadata as nested arrays. The attachment ID,
536
+ MIME type, size, and filename are at indices 0 through 3. The restriction
537
+ level is stored at ``[9][0][0]``.
538
+
539
+ :param raw_attachments: The raw attachment array from an update entry.
540
+ :param issue_id: The parent issue ID.
541
+ :return: Parsed attachments, or ``None`` when no attachments are present.
542
+ """
543
+
544
+ if not raw_attachments or not isinstance(raw_attachments, list):
545
+ return None
546
+
547
+ attachments: list[Attachment] = []
548
+
549
+ for raw_attachment in raw_attachments:
550
+ attachment_id = __get(raw_attachment, 0)
551
+ mime_type = __get(raw_attachment, 1)
552
+ size = __get(raw_attachment, 2)
553
+ filename = __get(raw_attachment, 3)
554
+ restriction = AttachmentRestriction(__get(raw_attachment, 9, 0, 0))
555
+
556
+ attachments.append(
557
+ Attachment(
558
+ issue_id=issue_id,
559
+ id=attachment_id,
560
+ mime_type=mime_type,
561
+ size=size,
562
+ filename=filename,
563
+ restriction=restriction,
564
+ )
565
+ )
566
+
567
+ return attachments
568
+
569
+
570
+ def __parse_comment(
571
+ raw_comment: Any, issue_id: int, number_offset: int = 1
572
+ ) -> Comment | None:
573
+ """
574
+ Parse a comment body array (19 elements) into a Comment.
575
+
576
+ Comment array index map::
577
+
578
+ [0] = comment text (str)
579
+ [2] = author user array
580
+ [3] = last-modified timestamp [seconds, nanos]
581
+ [5] = issue ID (int)
582
+ [6] = comment sequence number
583
+ [17] = last editor user array (equals author if never edited)
584
+ [18] = creation timestamp [seconds, nanos] (equals [3] if never edited)
585
+
586
+ :param raw_comment: The 19-element comment array from an update entry.
587
+ :param issue_id: The issue ID this comment belongs to.
588
+ :param number_offset: Added to the raw sequence number. The /updates
589
+ endpoint is 0-indexed (pass 1), /listComments is already 1-indexed
590
+ (pass 0).
591
+ :return: The parsed comment, or None if raw_comment is invalid.
592
+ """
593
+
594
+ if not raw_comment or not isinstance(raw_comment, list):
595
+ return None
596
+
597
+ comment_text = __get(raw_comment, 0, default="") or ""
598
+ author_array = __get(raw_comment, 2)
599
+ timestamp_array = __get(raw_comment, 3)
600
+ sequence_number = __get(raw_comment, 6, default=0) or 0
601
+ last_editor_array = __get(raw_comment, 17)
602
+ created_array = __get(raw_comment, 18)
603
+
604
+ return Comment(
605
+ issue_id=issue_id,
606
+ comment_number=sequence_number + number_offset,
607
+ author=__parse_email(author_array),
608
+ timestamp=__parse_timestamp(timestamp_array),
609
+ created_at=__parse_timestamp(created_array),
610
+ body=comment_text,
611
+ last_editor=__parse_email(last_editor_array),
612
+ )
613
+
614
+
615
+ def __parse_updates_response(raw_text: str) -> IssueUpdatesResult:
616
+ """
617
+ Parse a ListIssueUpdatesResponse (comments + field changes).
618
+
619
+ Response shape::
620
+
621
+ [["b.ListIssueUpdatesResponse", [[update, ...], page_token, total_count]]]
622
+
623
+ Each update is a 10-element array::
624
+
625
+ [0] = author user array
626
+ [1] = timestamp [seconds, nanos]
627
+ [2] = comment body (19-element array) or None
628
+ [3] = update sequence number
629
+ [5] = field changes array
630
+ [6] = comment number (descending in response order)
631
+ [9] = issue ID
632
+
633
+ Updates are returned newest-first. Use ``.comments`` on the result to get
634
+ comments in chronological order.
635
+
636
+ :param raw_text: Raw response body from POST /action/issues/{id}/updates.
637
+ :return: Parsed updates with pagination info.
638
+ """
639
+
640
+ data = __parse_json_response(raw_text)
641
+
642
+ response_wrapper = __get(data, 0, default=[])
643
+ result_block = __get(response_wrapper, 1, default=[])
644
+
645
+ raw_updates = __get(result_block, 0, default=[]) or []
646
+ page_token = __get(result_block, 1)
647
+ total_count = __get(result_block, 2, default=0) or 0
648
+
649
+ updates: Results[IssueUpdate] = Results()
650
+ for update_entry in raw_updates:
651
+ if not isinstance(update_entry, list):
652
+ continue
653
+
654
+ issue_id = __get(update_entry, 9, default=0) or 0
655
+ author_array = __get(update_entry, 0)
656
+ timestamp_array = __get(update_entry, 1)
657
+ comment_array = __get(update_entry, 2)
658
+ sequence_number = __get(update_entry, 3)
659
+ changes_array = __get(update_entry, 5)
660
+ attachments_array = __get(update_entry, 7)
661
+
662
+ comment = __parse_comment(comment_array, issue_id) if comment_array else None
663
+ attachments = __parse_attachments(attachments_array, issue_id)
664
+
665
+ updates.append(
666
+ IssueUpdate(
667
+ issue_id=issue_id,
668
+ sequence_number=sequence_number,
669
+ author=__parse_email(author_array),
670
+ timestamp=__parse_timestamp(timestamp_array),
671
+ comment=comment,
672
+ field_changes=__parse_field_changes(changes_array),
673
+ attachments=attachments,
674
+ )
675
+ )
676
+
677
+ return IssueUpdatesResult(
678
+ updates=updates,
679
+ total_count=total_count,
680
+ next_page_token=page_token if page_token else None,
681
+ )
682
+
683
+
684
+ def __parse_comments_response(raw_text: str) -> CommentsResult:
685
+ """
686
+ Parse a ListIssueCommentsResponse.
687
+
688
+ Response shape::
689
+
690
+ [["b.ListIssueCommentsResponse", [[comment, ...], page_token, total_count]]]
691
+
692
+ Each comment is a 19-element array. Unlike the ``/updates`` endpoint,
693
+ sequence numbers here are **1-indexed** (no ``+1`` adjustment needed).
694
+
695
+ :param raw_text: Raw response body from POST /action/issues/{id}/listComments.
696
+ :return: Parsed comments with pagination info.
697
+ """
698
+
699
+ data = __parse_json_response(raw_text)
700
+
701
+ response_wrapper = __get(data, 0, default=[])
702
+ result_block = __get(response_wrapper, 1, default=[])
703
+
704
+ raw_comments = __get(result_block, 0, default=[]) or []
705
+ page_token = __get(result_block, 1)
706
+ total_count = __get(result_block, 2, default=0) or 0
707
+
708
+ comments: Results[Comment] = Results()
709
+ for raw_comment in raw_comments:
710
+ # /listComments sequence numbers are already 1-indexed, so no offset.
711
+ issue_id = __get(raw_comment, 5, default=0) or 0
712
+ comment = __parse_comment(raw_comment, issue_id, number_offset=0)
713
+ if comment is not None:
714
+ comments.append(comment)
715
+
716
+ return CommentsResult(
717
+ comments=comments,
718
+ total_count=total_count,
719
+ next_page_token=page_token if page_token else None,
720
+ )