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/__init__.py +42 -0
- bugpipe/api/AUDIT.md +331 -0
- bugpipe/api/README.md +728 -0
- bugpipe/api/__init__.py +3 -0
- bugpipe/api/client.py +328 -0
- bugpipe/api/models.py +742 -0
- bugpipe/api/parser.py +720 -0
- bugpipe/cli/__init__.py +69 -0
- bugpipe/cli/cmd.py +304 -0
- bugpipe/cli/term.py +234 -0
- bugpipe/cli/update_checker.py +207 -0
- bugpipe-3.0.1.dist-info/METADATA +52 -0
- bugpipe-3.0.1.dist-info/RECORD +15 -0
- bugpipe-3.0.1.dist-info/WHEEL +4 -0
- bugpipe-3.0.1.dist-info/entry_points.txt +3 -0
bugpipe/api/models.py
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for the Google Issue Tracker.
|
|
3
|
+
|
|
4
|
+
Every model here carries the fields the API sends, and writes itself out::
|
|
5
|
+
|
|
6
|
+
with Bugpipe() as client:
|
|
7
|
+
issues = client.issues([40060244, 486077869])
|
|
8
|
+
|
|
9
|
+
issues.to_csv("issues.csv") # one row per issue
|
|
10
|
+
issues[0].to_json("issue.json") # one object
|
|
11
|
+
issues[0].to_dict() # the fields as a plain dict
|
|
12
|
+
|
|
13
|
+
A read that hands back several items hands back a :class:`Results` list, which
|
|
14
|
+
writes itself out the same way one item does.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import csv
|
|
18
|
+
import enum
|
|
19
|
+
import json
|
|
20
|
+
import typing as t
|
|
21
|
+
from dataclasses import dataclass, field, fields, is_dataclass
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"CUSTOM_FIELD_IDS",
|
|
27
|
+
"Attachment",
|
|
28
|
+
"AttachmentRestriction",
|
|
29
|
+
"Comment",
|
|
30
|
+
"CommentsResult",
|
|
31
|
+
"CustomFieldValue",
|
|
32
|
+
"Exportable",
|
|
33
|
+
"FieldChange",
|
|
34
|
+
"Issue",
|
|
35
|
+
"IssueType",
|
|
36
|
+
"IssueUpdate",
|
|
37
|
+
"IssueUpdatesResult",
|
|
38
|
+
"Priority",
|
|
39
|
+
"Results",
|
|
40
|
+
"SearchResult",
|
|
41
|
+
"Severity",
|
|
42
|
+
"Status",
|
|
43
|
+
"UnquotedValue",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UnquotedValue(str):
|
|
48
|
+
"""
|
|
49
|
+
One field value that prints as itself, without the quotes a string carries.
|
|
50
|
+
|
|
51
|
+
An enum name and a timestamp are values, not prose, so they read better
|
|
52
|
+
unquoted: ``status=FIXED``, not ``status='FIXED'``.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __repr__(self) -> str:
|
|
56
|
+
return str(self)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _for_console(value: t.Any) -> t.Any:
|
|
60
|
+
"""
|
|
61
|
+
Swap a single field value for how it reads on screen.
|
|
62
|
+
|
|
63
|
+
An enum shows its name rather than its numeric repr, and a datetime its
|
|
64
|
+
ISO form rather than the constructor call, both unquoted.
|
|
65
|
+
|
|
66
|
+
:param value: A field value.
|
|
67
|
+
:return: The readable form of the value.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
if isinstance(value, enum.Enum):
|
|
71
|
+
return UnquotedValue(value.name)
|
|
72
|
+
if isinstance(value, datetime):
|
|
73
|
+
return UnquotedValue(value.isoformat())
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _for_file(value: t.Any) -> t.Any:
|
|
78
|
+
"""
|
|
79
|
+
Reduce a field value to something JSON and CSV can both hold.
|
|
80
|
+
|
|
81
|
+
Enums become their names, datetimes their ISO form, and nested
|
|
82
|
+
dataclasses, lists, and dicts are converted item by item.
|
|
83
|
+
|
|
84
|
+
:param value: A field value.
|
|
85
|
+
:return: The plain form of the value.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
if isinstance(value, enum.Enum):
|
|
89
|
+
return value.name
|
|
90
|
+
if isinstance(value, datetime):
|
|
91
|
+
return value.isoformat()
|
|
92
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
93
|
+
return {f.name: _for_file(getattr(value, f.name)) for f in fields(value)}
|
|
94
|
+
if isinstance(value, dict):
|
|
95
|
+
return {key: _for_file(item) for key, item in value.items()}
|
|
96
|
+
if isinstance(value, (list, tuple)):
|
|
97
|
+
return [_for_file(item) for item in value]
|
|
98
|
+
return value
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _writable_path(path: str) -> Path:
|
|
102
|
+
"""
|
|
103
|
+
Make the parent directory of an output path when it is missing.
|
|
104
|
+
|
|
105
|
+
:param path: Output file path.
|
|
106
|
+
:return: The path, ready to write to.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
target = Path(path)
|
|
110
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
return target
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _write_json(path: str, data: t.Any, indent: int) -> str:
|
|
115
|
+
"""
|
|
116
|
+
Write ``data`` to a JSON file and return the path written.
|
|
117
|
+
|
|
118
|
+
:param path: Output file path. Missing parent directories are made.
|
|
119
|
+
:param data: JSON-serialisable value.
|
|
120
|
+
:param indent: Spaces to indent by. ``0`` writes it on one line.
|
|
121
|
+
:return: The path written.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
target = _writable_path(path)
|
|
125
|
+
target.write_text(
|
|
126
|
+
json.dumps(data, indent=indent or None, ensure_ascii=False), encoding="utf-8"
|
|
127
|
+
)
|
|
128
|
+
return str(target)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Exportable:
|
|
132
|
+
"""
|
|
133
|
+
Writes a dataclass out as a dict, or to a JSON or CSV file, and prints it
|
|
134
|
+
with its enums and timestamps read as text.
|
|
135
|
+
|
|
136
|
+
Only the dataclass's own fields are covered; properties such as
|
|
137
|
+
:attr:`Issue.url` are left out.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
__dataclass_fields__: t.ClassVar[dict[str, t.Any]]
|
|
141
|
+
|
|
142
|
+
def __rich_repr__(self) -> t.Iterator[tuple[str, t.Any]]:
|
|
143
|
+
for f in fields(self):
|
|
144
|
+
yield f.name, _for_console(getattr(self, f.name))
|
|
145
|
+
|
|
146
|
+
def to_dict(self) -> dict[str, t.Any]:
|
|
147
|
+
"""
|
|
148
|
+
Return the item's fields as a plain dict.
|
|
149
|
+
|
|
150
|
+
:return: Every field, with enums, datetimes, and nested dataclasses
|
|
151
|
+
reduced to plain values.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
return {f.name: _for_file(getattr(self, f.name)) for f in fields(self)}
|
|
155
|
+
|
|
156
|
+
def to_json(self, path: str, indent: int = 4) -> str:
|
|
157
|
+
"""
|
|
158
|
+
Write the item to a JSON file as one object.
|
|
159
|
+
|
|
160
|
+
:param path: Output file path. Missing parent directories are made.
|
|
161
|
+
:param indent: Spaces to indent by. ``0`` writes it on one line.
|
|
162
|
+
:return: The path written.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
return _write_json(path=path, data=self.to_dict(), indent=indent)
|
|
166
|
+
|
|
167
|
+
def to_csv(self, path: str) -> str:
|
|
168
|
+
"""
|
|
169
|
+
Write the item to a CSV file as one row.
|
|
170
|
+
|
|
171
|
+
:param path: Output file path. Missing parent directories are made.
|
|
172
|
+
:return: The path written.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
return Results([self]).to_csv(path)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class Results[T: Exportable](list[T]):
|
|
179
|
+
"""
|
|
180
|
+
The list of items a read hands back. It writes itself out the way one item does.
|
|
181
|
+
|
|
182
|
+
It is a plain list, so it indexes, slices, and iterates as always. It just
|
|
183
|
+
also carries :meth:`to_dict`, :meth:`to_json`, and :meth:`to_csv`::
|
|
184
|
+
|
|
185
|
+
issues = client.issues([40060244, 486077869])
|
|
186
|
+
|
|
187
|
+
issues.to_csv("issues.csv")
|
|
188
|
+
issues[0].to_json("first.json")
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _as_cell(value: t.Any) -> t.Any:
|
|
193
|
+
"""
|
|
194
|
+
Make one value fit in a CSV cell.
|
|
195
|
+
|
|
196
|
+
A cell holds text, so nested lists and dicts go in as JSON rather than
|
|
197
|
+
as a Python repr, which keeps them readable by whatever opens the file next.
|
|
198
|
+
|
|
199
|
+
:param value: A field value.
|
|
200
|
+
:return: The value, or its JSON form when it nests.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
if isinstance(value, (dict, list)):
|
|
204
|
+
return json.dumps(value, ensure_ascii=False)
|
|
205
|
+
return value
|
|
206
|
+
|
|
207
|
+
def to_dict(self) -> list[dict[str, t.Any]]:
|
|
208
|
+
"""
|
|
209
|
+
Return the items as a list of plain dicts.
|
|
210
|
+
|
|
211
|
+
:return: One dict of fields per item.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
return [item.to_dict() for item in self]
|
|
215
|
+
|
|
216
|
+
def to_json(self, path: str, indent: int = 4) -> str:
|
|
217
|
+
"""
|
|
218
|
+
Write the items to a JSON file as one array.
|
|
219
|
+
|
|
220
|
+
:param path: Output file path. Missing parent directories are made.
|
|
221
|
+
:param indent: Spaces to indent by. ``0`` writes it on one line.
|
|
222
|
+
:return: The path written.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
return _write_json(path=path, data=self.to_dict(), indent=indent)
|
|
226
|
+
|
|
227
|
+
def to_csv(self, path: str) -> str:
|
|
228
|
+
"""
|
|
229
|
+
Write the items to a CSV file, one row each.
|
|
230
|
+
|
|
231
|
+
The columns are the union of every row's keys, in the order they were
|
|
232
|
+
first seen, since items of the same kind can still carry different fields.
|
|
233
|
+
|
|
234
|
+
:param path: Output file path. Missing parent directories are made.
|
|
235
|
+
:return: The path written.
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
target = _writable_path(path)
|
|
239
|
+
rows = self.to_dict()
|
|
240
|
+
|
|
241
|
+
fieldnames = list(dict.fromkeys(key for row in rows for key in row))
|
|
242
|
+
|
|
243
|
+
with target.open("w", newline="", encoding="utf-8") as file:
|
|
244
|
+
writer = csv.DictWriter(file, fieldnames=fieldnames)
|
|
245
|
+
writer.writeheader()
|
|
246
|
+
for row in rows:
|
|
247
|
+
writer.writerow(
|
|
248
|
+
{key: self._as_cell(value) for key, value in row.items()}
|
|
249
|
+
)
|
|
250
|
+
return str(target)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class _LenientIntEnum(enum.IntEnum):
|
|
254
|
+
"""
|
|
255
|
+
IntEnum that synthesises a member for values the API sends but we don't know.
|
|
256
|
+
|
|
257
|
+
The member is named ``<prefix><value>``, where subclasses set the prefix.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
_unknown_prefix = enum.nonmember("UNKNOWN_")
|
|
261
|
+
|
|
262
|
+
@classmethod
|
|
263
|
+
def _missing_(cls, value):
|
|
264
|
+
"""
|
|
265
|
+
Build a pseudo-member for an unknown value instead of raising.
|
|
266
|
+
|
|
267
|
+
:param value: Numeric value returned by the API.
|
|
268
|
+
:return: A new member named ``<prefix><value>``.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
value = t.cast(int, value)
|
|
272
|
+
# noinspection PyTypeChecker
|
|
273
|
+
obj = int.__new__(cls, value)
|
|
274
|
+
obj._name_ = f"{cls._unknown_prefix}{value}"
|
|
275
|
+
obj._value_ = value
|
|
276
|
+
return obj
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class Status(_LenientIntEnum):
|
|
280
|
+
"""
|
|
281
|
+
Issue status values used by the Google Issue Tracker.
|
|
282
|
+
|
|
283
|
+
The first three (NEW, ASSIGNED, ACCEPTED) are considered open.
|
|
284
|
+
Everything else is a closed/resolved state. Unknown values from
|
|
285
|
+
the API get an auto-generated UNKNOWN_N name instead of crashing.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
NEW = 1
|
|
289
|
+
ASSIGNED = 2
|
|
290
|
+
ACCEPTED = 3
|
|
291
|
+
FIXED = 4
|
|
292
|
+
VERIFIED = 5
|
|
293
|
+
NOT_REPRODUCIBLE = 6
|
|
294
|
+
INTENDED_BEHAVIOR = 7
|
|
295
|
+
OBSOLETE = 8
|
|
296
|
+
INFEASIBLE = 9
|
|
297
|
+
DUPLICATE = 10
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def is_open(self) -> bool:
|
|
301
|
+
"""
|
|
302
|
+
Whether this status represents an open (unresolved) issue.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
return self in (Status.NEW, Status.ASSIGNED, Status.ACCEPTED)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class Priority(_LenientIntEnum):
|
|
309
|
+
"""
|
|
310
|
+
Issue priority levels. P0 is the most urgent, P4 is the lowest.
|
|
311
|
+
|
|
312
|
+
Unknown values from the API get an auto-generated PN name.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
P0 = 0
|
|
316
|
+
P1 = 1
|
|
317
|
+
P2 = 2
|
|
318
|
+
P3 = 3
|
|
319
|
+
P4 = 4
|
|
320
|
+
|
|
321
|
+
_unknown_prefix = enum.nonmember("P")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class Severity(_LenientIntEnum):
|
|
325
|
+
"""
|
|
326
|
+
Issue severity levels. S0 is the most severe, S4 is the lowest.
|
|
327
|
+
|
|
328
|
+
Severity often matches priority but can diverge, especially on
|
|
329
|
+
security issues. Unknown values from the API get an auto-generated SN name.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
S0 = 0
|
|
333
|
+
S1 = 1
|
|
334
|
+
S2 = 2
|
|
335
|
+
S3 = 3
|
|
336
|
+
S4 = 4
|
|
337
|
+
|
|
338
|
+
_unknown_prefix = enum.nonmember("S")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class IssueType(_LenientIntEnum):
|
|
342
|
+
"""
|
|
343
|
+
Issue type categories.
|
|
344
|
+
|
|
345
|
+
Unknown values from the API get an auto-generated TYPE_N name.
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
BUG = 1
|
|
349
|
+
FEATURE_REQUEST = 2
|
|
350
|
+
CUSTOMER_ISSUE = 3
|
|
351
|
+
INTERNAL_CLEANUP = 4
|
|
352
|
+
PROCESS = 5
|
|
353
|
+
VULNERABILITY = 6
|
|
354
|
+
|
|
355
|
+
_unknown_prefix = enum.nonmember("TYPE_")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class AttachmentRestriction(_LenientIntEnum):
|
|
359
|
+
"""
|
|
360
|
+
Access restriction levels for issue attachments.
|
|
361
|
+
|
|
362
|
+
``NO_RESTRICTION`` allows users with issue-view permission to access the
|
|
363
|
+
attachment. ``RESTRICTED`` and ``RESTRICTED_PLUS`` require the respective
|
|
364
|
+
restricted-content permission.
|
|
365
|
+
|
|
366
|
+
Unknown values from the API get an auto-generated UNKNOWN_N name.
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
NO_RESTRICTION = 1
|
|
370
|
+
RESTRICTED = 2
|
|
371
|
+
RESTRICTED_PLUS = 3
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# Maps numeric custom field IDs to human-readable names.
|
|
375
|
+
# These are the 24 well-known fields in the Chromium tracker (tracker 157).
|
|
376
|
+
# Other trackers may use different field IDs; unrecognized fields go to custom_fields.
|
|
377
|
+
# The parser uses this to turn raw field IDs into named attributes.
|
|
378
|
+
CUSTOM_FIELD_IDS: dict[int, str] = {
|
|
379
|
+
1225362: "backlog_rank",
|
|
380
|
+
1223033: "build_number",
|
|
381
|
+
1223031: "chromium_labels",
|
|
382
|
+
1222907: "component_tags",
|
|
383
|
+
1223136: "cve",
|
|
384
|
+
1410892: "cwe_id",
|
|
385
|
+
1223032: "design_doc",
|
|
386
|
+
1223131: "design_summary",
|
|
387
|
+
1225337: "estimated_days",
|
|
388
|
+
1223081: "flaky_test",
|
|
389
|
+
1223087: "merge",
|
|
390
|
+
1223134: "merge_request",
|
|
391
|
+
1223085: "milestone",
|
|
392
|
+
1225154: "next_action",
|
|
393
|
+
1223083: "notice",
|
|
394
|
+
1223084: "os",
|
|
395
|
+
1223086: "release_block",
|
|
396
|
+
1223034: "respin",
|
|
397
|
+
1300460: "irm_link",
|
|
398
|
+
1223088: "security_release",
|
|
399
|
+
1223135: "vrp_reward",
|
|
400
|
+
1358989: "fixed_by_code_changes",
|
|
401
|
+
1253656: "component_ancestor_tags",
|
|
402
|
+
1544844: "introduced_in",
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@dataclass
|
|
407
|
+
class CustomFieldValue(Exportable):
|
|
408
|
+
"""
|
|
409
|
+
A single custom field value that didn't map to a known attribute.
|
|
410
|
+
|
|
411
|
+
Attributes:
|
|
412
|
+
field_id: Numeric ID of the custom field.
|
|
413
|
+
name: Human-readable name (from CUSTOM_FIELD_IDS or "field_N").
|
|
414
|
+
values: String values for multi-value fields.
|
|
415
|
+
numeric_value: Numeric value for number-type fields.
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
field_id: int
|
|
419
|
+
name: str
|
|
420
|
+
values: list[str] = field(default_factory=list)
|
|
421
|
+
numeric_value: float | None = None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@dataclass
|
|
425
|
+
class Issue(Exportable):
|
|
426
|
+
"""
|
|
427
|
+
A single issue from the Google Issue Tracker.
|
|
428
|
+
|
|
429
|
+
Basic fields (id, title, status, priority, etc.) are always populated.
|
|
430
|
+
Custom fields (os, milestone, cve, etc.) come from the tracker's
|
|
431
|
+
configurable field system and may be empty.
|
|
432
|
+
|
|
433
|
+
Attributes:
|
|
434
|
+
id: Unique numeric issue ID.
|
|
435
|
+
title: Issue title/summary.
|
|
436
|
+
status: Current status (open, fixed, etc.).
|
|
437
|
+
priority: Priority level (P0-P4).
|
|
438
|
+
severity: Severity level (S0-S4). Often matches priority but can diverge.
|
|
439
|
+
issue_type: Category (bug, feature request, etc.).
|
|
440
|
+
reporter: Email of the person who filed the issue.
|
|
441
|
+
owner: Email of the currently assigned owner.
|
|
442
|
+
verifier: Email of the person who verified the fix.
|
|
443
|
+
component_id: Numeric ID of the primary component.
|
|
444
|
+
ccs: List of CC'd email addresses.
|
|
445
|
+
collaborators: List of collaborator email addresses.
|
|
446
|
+
found_in: "Found In" version strings (e.g. ["CP21.260116.011.A1"]).
|
|
447
|
+
in_prod: Whether the issue has been observed in production.
|
|
448
|
+
created_at: When the issue was created (UTC).
|
|
449
|
+
modified_at: When the issue was last modified (UTC). Also moves on
|
|
450
|
+
automated metadata churn (hotlist/custom-field bot updates).
|
|
451
|
+
verified_at: When the fix was verified (UTC).
|
|
452
|
+
last_activity_at: When the issue last had a substantive update (a
|
|
453
|
+
comment or meaningful field change), excluding the automated
|
|
454
|
+
metadata churn that bumps modified_at. May be None.
|
|
455
|
+
comment_count: Total number of comments.
|
|
456
|
+
star_count: Number of stars (watchers/votes).
|
|
457
|
+
body: Issue description text. Only populated in batch/detail responses, not in search results.
|
|
458
|
+
tracker_id: Tracker ID (e.g. 157 for Chromium, 183 for Fuchsia).
|
|
459
|
+
last_modifier: Email of the last person to modify the issue.
|
|
460
|
+
hotlist_ids: IDs of hotlists this issue belongs to.
|
|
461
|
+
blocking_issue_ids: IDs of issues this one blocks.
|
|
462
|
+
duplicate_issue_ids: IDs of issues marked as duplicates of this one.
|
|
463
|
+
views_24h: Number of views in the last 24 hours.
|
|
464
|
+
views_7d: Number of views in the last 7 days.
|
|
465
|
+
views_30d: Number of views in the last 30 days.
|
|
466
|
+
component_tags: Component tags (e.g. ["Blink>JavaScript"]).
|
|
467
|
+
component_ancestor_tags: Full component ancestry.
|
|
468
|
+
labels: Tracker-specific labels.
|
|
469
|
+
os: Affected operating systems (e.g. ["Linux", "Mac", "Windows"]).
|
|
470
|
+
milestone: Affected milestones.
|
|
471
|
+
merge: Merge status labels.
|
|
472
|
+
merge_request: Merge request labels.
|
|
473
|
+
release_block: Release-blocking labels.
|
|
474
|
+
cve: CVE identifiers.
|
|
475
|
+
cwe_id: CWE weakness ID.
|
|
476
|
+
vrp_reward: Bug bounty (VRP) reward amount.
|
|
477
|
+
estimated_days: Estimated engineer-days to complete.
|
|
478
|
+
build_number: Affected build number string.
|
|
479
|
+
flaky_test: Flaky test identifier.
|
|
480
|
+
next_action: Next expected action or deadline.
|
|
481
|
+
notice: Notice text.
|
|
482
|
+
introduced_in: Milestone the vulnerability was first introduced.
|
|
483
|
+
irm_link: Link to related IRM incident.
|
|
484
|
+
security_release: Security release labels.
|
|
485
|
+
fixed_by_code_changes: Gerrit URLs of fixing code changes.
|
|
486
|
+
custom_fields: Catch-all dict for any fields not mapped to attributes above.
|
|
487
|
+
"""
|
|
488
|
+
|
|
489
|
+
id: int
|
|
490
|
+
title: str
|
|
491
|
+
status: Status = Status.NEW
|
|
492
|
+
priority: Priority = Priority.P2
|
|
493
|
+
severity: Severity | None = None
|
|
494
|
+
issue_type: IssueType | None = None
|
|
495
|
+
reporter: str | None = None
|
|
496
|
+
owner: str | None = None
|
|
497
|
+
verifier: str | None = None
|
|
498
|
+
component_id: int | None = None
|
|
499
|
+
ccs: list[str] = field(default_factory=list)
|
|
500
|
+
collaborators: list[str] = field(default_factory=list)
|
|
501
|
+
found_in: list[str] = field(default_factory=list)
|
|
502
|
+
in_prod: bool | None = None
|
|
503
|
+
created_at: datetime | None = None
|
|
504
|
+
modified_at: datetime | None = None
|
|
505
|
+
verified_at: datetime | None = None
|
|
506
|
+
last_activity_at: datetime | None = None
|
|
507
|
+
comment_count: int = 0
|
|
508
|
+
star_count: int = 0
|
|
509
|
+
body: str | None = None
|
|
510
|
+
tracker_id: int | None = None
|
|
511
|
+
last_modifier: str | None = None
|
|
512
|
+
hotlist_ids: list[int] = field(default_factory=list)
|
|
513
|
+
blocking_issue_ids: list[int] = field(default_factory=list)
|
|
514
|
+
duplicate_issue_ids: list[int] = field(default_factory=list)
|
|
515
|
+
views_24h: int = 0
|
|
516
|
+
views_7d: int = 0
|
|
517
|
+
views_30d: int = 0
|
|
518
|
+
component_tags: list[str] = field(default_factory=list)
|
|
519
|
+
component_ancestor_tags: list[str] = field(default_factory=list)
|
|
520
|
+
labels: list[str] = field(default_factory=list)
|
|
521
|
+
os: list[str] = field(default_factory=list)
|
|
522
|
+
milestone: list[str] = field(default_factory=list)
|
|
523
|
+
merge: list[str] = field(default_factory=list)
|
|
524
|
+
merge_request: list[str] = field(default_factory=list)
|
|
525
|
+
release_block: list[str] = field(default_factory=list)
|
|
526
|
+
cve: list[str] = field(default_factory=list)
|
|
527
|
+
cwe_id: float | None = None
|
|
528
|
+
vrp_reward: float | None = None
|
|
529
|
+
estimated_days: float | None = None
|
|
530
|
+
build_number: str | None = None
|
|
531
|
+
flaky_test: str | None = None
|
|
532
|
+
next_action: str | None = None
|
|
533
|
+
notice: str | None = None
|
|
534
|
+
introduced_in: str | None = None
|
|
535
|
+
irm_link: str | None = None
|
|
536
|
+
security_release: list[str] = field(default_factory=list)
|
|
537
|
+
fixed_by_code_changes: list[str] = field(default_factory=list)
|
|
538
|
+
custom_fields: dict[str, t.Any] = field(default_factory=dict)
|
|
539
|
+
|
|
540
|
+
@property
|
|
541
|
+
def url(self) -> str:
|
|
542
|
+
"""
|
|
543
|
+
Direct link to this issue on issuetracker.google.com.
|
|
544
|
+
"""
|
|
545
|
+
|
|
546
|
+
return f"https://issuetracker.google.com/issues/{self.id}"
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@dataclass
|
|
550
|
+
class Comment(Exportable):
|
|
551
|
+
"""
|
|
552
|
+
A single comment on an issue.
|
|
553
|
+
|
|
554
|
+
Attributes:
|
|
555
|
+
issue_id: The issue this comment belongs to.
|
|
556
|
+
comment_number: 1-indexed comment number.
|
|
557
|
+
author: Email of the comment author.
|
|
558
|
+
timestamp: When the comment was last modified (UTC). Equals
|
|
559
|
+
created_at when the comment has never been edited.
|
|
560
|
+
created_at: When the comment was originally posted (UTC).
|
|
561
|
+
body: The comment text.
|
|
562
|
+
last_editor: Email of the last person to edit the comment. Equals
|
|
563
|
+
author when the comment has never been edited.
|
|
564
|
+
"""
|
|
565
|
+
|
|
566
|
+
issue_id: int
|
|
567
|
+
comment_number: int
|
|
568
|
+
author: str | None = None
|
|
569
|
+
timestamp: datetime | None = None
|
|
570
|
+
created_at: datetime | None = None
|
|
571
|
+
body: str = ""
|
|
572
|
+
last_editor: str | None = None
|
|
573
|
+
|
|
574
|
+
@property
|
|
575
|
+
def is_edited(self) -> bool:
|
|
576
|
+
"""
|
|
577
|
+
Whether the comment has been edited since it was posted.
|
|
578
|
+
|
|
579
|
+
True when the last-modified timestamp is later than the creation
|
|
580
|
+
time (catches self-edits, where the author edits their own comment
|
|
581
|
+
and stays the last_editor), or when a known last_editor differs
|
|
582
|
+
from the author.
|
|
583
|
+
"""
|
|
584
|
+
|
|
585
|
+
if self.created_at and self.timestamp and self.created_at != self.timestamp:
|
|
586
|
+
return True
|
|
587
|
+
return self.last_editor is not None and self.last_editor != self.author
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@dataclass
|
|
591
|
+
class Attachment(Exportable):
|
|
592
|
+
"""
|
|
593
|
+
A file attached to an issue update.
|
|
594
|
+
|
|
595
|
+
Attributes:
|
|
596
|
+
issue_id: The issue this attachment belongs to.
|
|
597
|
+
id: The attachment ID.
|
|
598
|
+
mime_type: The attachment MIME type.
|
|
599
|
+
size: File size in bytes, or ``None`` after deletion.
|
|
600
|
+
filename: The attachment filename.
|
|
601
|
+
restriction: Access restriction level for the attachment.
|
|
602
|
+
|
|
603
|
+
The API returns ``size=None`` after the attachment has been deleted.
|
|
604
|
+
"""
|
|
605
|
+
|
|
606
|
+
issue_id: int
|
|
607
|
+
id: int
|
|
608
|
+
mime_type: str
|
|
609
|
+
size: int | None
|
|
610
|
+
filename: str
|
|
611
|
+
restriction: AttachmentRestriction
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@dataclass
|
|
615
|
+
class CommentsResult:
|
|
616
|
+
"""
|
|
617
|
+
Result from fetching comments via the listComments endpoint.
|
|
618
|
+
|
|
619
|
+
Attributes:
|
|
620
|
+
comments: The comments for this page.
|
|
621
|
+
total_count: Total number of text comments on this issue.
|
|
622
|
+
next_page_token: Token for fetching the next page, if there are more.
|
|
623
|
+
"""
|
|
624
|
+
|
|
625
|
+
comments: Results[Comment]
|
|
626
|
+
total_count: int
|
|
627
|
+
next_page_token: str | None = None
|
|
628
|
+
|
|
629
|
+
@property
|
|
630
|
+
def has_more(self) -> bool:
|
|
631
|
+
"""
|
|
632
|
+
Whether there are more comments beyond this page.
|
|
633
|
+
"""
|
|
634
|
+
|
|
635
|
+
return self.next_page_token is not None
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
@dataclass
|
|
639
|
+
class FieldChange(Exportable):
|
|
640
|
+
"""
|
|
641
|
+
A single field change within an issue update.
|
|
642
|
+
|
|
643
|
+
Attributes:
|
|
644
|
+
field: Name of the changed field (e.g. "status", "priority").
|
|
645
|
+
old_value: Previous value (not always available from the API).
|
|
646
|
+
new_value: New value (not always available from the API).
|
|
647
|
+
"""
|
|
648
|
+
|
|
649
|
+
field: str
|
|
650
|
+
old_value: str | None = None
|
|
651
|
+
new_value: str | None = None
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
@dataclass
|
|
655
|
+
class IssueUpdate(Exportable):
|
|
656
|
+
"""
|
|
657
|
+
An issue update entry. May contain a comment, field changes, or both.
|
|
658
|
+
|
|
659
|
+
Attributes:
|
|
660
|
+
issue_id: The issue this update belongs to.
|
|
661
|
+
sequence_number: Ordering number for this update.
|
|
662
|
+
author: Email of the person who made this update.
|
|
663
|
+
timestamp: When the update happened (UTC).
|
|
664
|
+
comment: The comment attached to this update, if any.
|
|
665
|
+
field_changes: List of field changes in this update.
|
|
666
|
+
attachments: Attachments associated with this update, if any.
|
|
667
|
+
"""
|
|
668
|
+
|
|
669
|
+
issue_id: int
|
|
670
|
+
sequence_number: int | None = None
|
|
671
|
+
author: str | None = None
|
|
672
|
+
timestamp: datetime | None = None
|
|
673
|
+
comment: Comment | None = None
|
|
674
|
+
field_changes: list[FieldChange] = field(default_factory=list)
|
|
675
|
+
attachments: list[Attachment] | None = None
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
@dataclass
|
|
679
|
+
class IssueUpdatesResult:
|
|
680
|
+
"""
|
|
681
|
+
Result from fetching issue updates (comments + field changes).
|
|
682
|
+
|
|
683
|
+
The API returns updates in reverse chronological order (newest first).
|
|
684
|
+
Use the .comments property to get just the comments in chronological order.
|
|
685
|
+
|
|
686
|
+
Attributes:
|
|
687
|
+
updates: All updates, newest first.
|
|
688
|
+
total_count: Total number of updates for this issue.
|
|
689
|
+
next_page_token: Token for fetching the next page, if there are more.
|
|
690
|
+
"""
|
|
691
|
+
|
|
692
|
+
updates: Results[IssueUpdate]
|
|
693
|
+
total_count: int
|
|
694
|
+
next_page_token: str | None = None
|
|
695
|
+
|
|
696
|
+
@property
|
|
697
|
+
def comments(self) -> Results[Comment]:
|
|
698
|
+
"""
|
|
699
|
+
Only the updates that have comments, in chronological order (oldest first).
|
|
700
|
+
"""
|
|
701
|
+
|
|
702
|
+
return Results(
|
|
703
|
+
update.comment
|
|
704
|
+
for update in reversed(self.updates)
|
|
705
|
+
if update.comment is not None
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
@property
|
|
709
|
+
def has_more(self) -> bool:
|
|
710
|
+
"""
|
|
711
|
+
Whether there are more updates beyond this page.
|
|
712
|
+
"""
|
|
713
|
+
|
|
714
|
+
return self.next_page_token is not None
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
@dataclass
|
|
718
|
+
class SearchResult:
|
|
719
|
+
"""
|
|
720
|
+
Result from searching/listing issues.
|
|
721
|
+
|
|
722
|
+
Attributes:
|
|
723
|
+
issues: The matching issues for this page.
|
|
724
|
+
total_count: Total number of matching issues (across all pages).
|
|
725
|
+
next_page_token: Token for fetching the next page, if there are more.
|
|
726
|
+
query: The query string used for this search (stored for pagination).
|
|
727
|
+
page_size: The page size used for this search (stored for pagination).
|
|
728
|
+
"""
|
|
729
|
+
|
|
730
|
+
issues: Results[Issue]
|
|
731
|
+
total_count: int
|
|
732
|
+
next_page_token: str | None = None
|
|
733
|
+
query: str = ""
|
|
734
|
+
page_size: int = 50
|
|
735
|
+
|
|
736
|
+
@property
|
|
737
|
+
def has_more(self) -> bool:
|
|
738
|
+
"""
|
|
739
|
+
Whether there are more results beyond this page.
|
|
740
|
+
"""
|
|
741
|
+
|
|
742
|
+
return self.next_page_token is not None
|