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.
@@ -0,0 +1,3 @@
1
+ from . import client, models, parser
2
+
3
+ __all__ = ["client", "models", "parser"]
bugpipe/api/client.py ADDED
@@ -0,0 +1,328 @@
1
+ from __future__ import annotations
2
+
3
+ import typing as t
4
+
5
+ import httpx
6
+
7
+ from .parser import (
8
+ __parse_batch_response as parse_batch_response,
9
+ __parse_comments_response as parse_comments_response,
10
+ __parse_issue_detail_response as parse_issue_detail_response,
11
+ __parse_search_response as parse_search_response,
12
+ __parse_updates_response as parse_updates_response,
13
+ )
14
+
15
+ if t.TYPE_CHECKING:
16
+ from httpx import Response
17
+
18
+ from .models import CommentsResult, Issue, IssueUpdatesResult, Results, SearchResult
19
+
20
+ __all__ = ["TRACKERS", "Bugpipe"]
21
+
22
+ TRACKERS: list[dict[str, str | int]] = [
23
+ {
24
+ "id": 1,
25
+ "slug": "pigweed",
26
+ "name": "Pigweed",
27
+ "url": "https://issues.pigweed.dev",
28
+ },
29
+ {
30
+ "id": 27,
31
+ "slug": "gerrit",
32
+ "name": "Gerrit",
33
+ "url": "https://issues.gerritcodereview.com",
34
+ },
35
+ {
36
+ "id": 53,
37
+ "slug": "git",
38
+ "name": "Git",
39
+ "url": "https://git.issues.gerritcodereview.com",
40
+ },
41
+ {"id": 79, "slug": "skia", "name": "Skia", "url": "https://issues.skia.org"},
42
+ {"id": 105, "slug": "webrtc", "name": "WebRTC", "url": "https://issues.webrtc.org"},
43
+ {
44
+ "id": 131,
45
+ "slug": "libyuv",
46
+ "name": "libyuv",
47
+ "url": "https://libyuv.issues.chromium.org",
48
+ },
49
+ {
50
+ "id": 157,
51
+ "slug": "chromium",
52
+ "name": "Chromium",
53
+ "url": "https://issues.chromium.org",
54
+ },
55
+ {
56
+ "id": 183,
57
+ "slug": "fuchsia",
58
+ "name": "Fuchsia",
59
+ "url": "https://issues.fuchsia.dev",
60
+ },
61
+ {
62
+ "id": 235,
63
+ "slug": "angle",
64
+ "name": "ANGLE",
65
+ "url": "https://issues.angleproject.org",
66
+ },
67
+ {
68
+ "id": 261,
69
+ "slug": "aomedia",
70
+ "name": "AOMedia",
71
+ "url": "https://aomedia.issues.chromium.org",
72
+ },
73
+ {
74
+ "id": 287,
75
+ "slug": "webm",
76
+ "name": "WebM",
77
+ "url": "https://issues.webmproject.org",
78
+ },
79
+ {"id": 339, "slug": "gn", "name": "GN", "url": "https://gn.issues.chromium.org"},
80
+ {
81
+ "id": 365,
82
+ "slug": "project-zero",
83
+ "name": "Project Zero",
84
+ "url": "https://project-zero.issues.chromium.org",
85
+ },
86
+ {
87
+ "id": 391,
88
+ "slug": "oss-fuzz",
89
+ "name": "OSS Fuzz",
90
+ "url": "https://issues.oss-fuzz.com",
91
+ },
92
+ ]
93
+
94
+ USER_AGENT = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
95
+
96
+
97
+ class Bugpipe:
98
+ """
99
+ Python client for the Google Issue Tracker.
100
+
101
+ Wraps the non-public JSON-array API at issuetracker.google.com. Supports
102
+ searching issues, fetching individual issues, batch fetching, and
103
+ reading comments/updates.
104
+
105
+ Can be used as a context manager::
106
+
107
+ with Bugpipe() as client:
108
+ result = client.search("priority:p1")
109
+
110
+ :param trackers: Trackers to query. Accepts names (e.g. ``["chromium"]``)
111
+ or numeric ID strings (e.g. ``["157"]``). Names are resolved via
112
+ ``TRACKERS``. Pass multiple to search across specific trackers.
113
+ Defaults to None (search all public trackers).
114
+ :param timeout: HTTP request timeout in seconds. Defaults to 30.
115
+ :param proxy: Proxy URL to route all requests through, e.g.
116
+ ``"http://localhost:8080"``. Defaults to None, which honours the
117
+ ``HTTP_PROXY``/``HTTPS_PROXY`` environment variables.
118
+ """
119
+
120
+ def __init__(
121
+ self,
122
+ trackers: list[str | int] | None = None,
123
+ timeout: float = 30.0,
124
+ proxy: str | None = None,
125
+ ):
126
+ """
127
+ Configure the underlying :class:`httpx.Client` and resolve any
128
+ tracker slugs to their numeric IDs. See the class docstring for
129
+ parameter details.
130
+ """
131
+
132
+ self.base_endpoint: str = "https://issuetracker.google.com/action"
133
+
134
+ self.tracker_ids: list[str | int] | None = None
135
+ if trackers:
136
+ tracker_by_slug = {tracker["slug"]: tracker["id"] for tracker in TRACKERS}
137
+ self.tracker_ids = [tracker_by_slug.get(name, name) for name in trackers]
138
+
139
+ self._http = httpx.Client(
140
+ headers={
141
+ "Content-Type": "application/json",
142
+ "Origin": "https://issuetracker.google.com",
143
+ "Referer": "https://issuetracker.google.com/",
144
+ "User-Agent": USER_AGENT,
145
+ },
146
+ timeout=timeout,
147
+ proxy=proxy,
148
+ )
149
+
150
+ def close(self):
151
+ """
152
+ Close the client and release its resources.
153
+
154
+ Should be called when the client is no longer needed if not using
155
+ it as a context manager.
156
+ """
157
+
158
+ self._http.close()
159
+
160
+ def __enter__(self):
161
+ """
162
+ Context-manager entry. Returns ``self`` unchanged.
163
+ """
164
+
165
+ return self
166
+
167
+ def __exit__(self, *args):
168
+ """
169
+ Context-manager exit. Closes the underlying HTTP client.
170
+ """
171
+
172
+ self.close()
173
+
174
+ def echo(self) -> str:
175
+ """
176
+ Ping the issue tracker backend and return its raw response.
177
+
178
+ :return: The raw response text, which is ``"yes"`` when the backend
179
+ is reachable and healthy. Returns ``"no"`` when the backend is
180
+ unreachable or responds with a non-200 status.
181
+ """
182
+
183
+ url = f"{self.base_endpoint}/yes"
184
+ try:
185
+ response: Response = self._http.get(url)
186
+ if response.status_code == 200:
187
+ return response.text.strip()
188
+ return "no"
189
+ except httpx.HTTPError:
190
+ return "no"
191
+
192
+ def search(
193
+ self,
194
+ query: str,
195
+ page_size: int = 50,
196
+ page_token: str | None = None,
197
+ ) -> SearchResult:
198
+ """
199
+ Search for issues in the Google Issue Tracker.
200
+
201
+ :param query: Search query string (e.g. "status:open", "component:Blink").
202
+ :param page_size: Number of results per page (25, 50, 100, or 250).
203
+ :param page_token: Pagination token from a previous SearchResult.next_page_token.
204
+ :return: Matching issues, total count, and pagination token.
205
+ """
206
+
207
+ if not query or not query.strip():
208
+ raise ValueError("Search query cannot be empty")
209
+
210
+ query_payload: list = (
211
+ [query, None, page_size, page_token]
212
+ if page_token
213
+ else [query, None, page_size]
214
+ )
215
+ tracker_filter = self.tracker_ids if self.tracker_ids else None
216
+ request_body: list = [
217
+ None,
218
+ None,
219
+ None,
220
+ None,
221
+ None,
222
+ tracker_filter,
223
+ query_payload,
224
+ ]
225
+ url: str = f"{self.base_endpoint}/issues/list"
226
+
227
+ response: Response = self._http.post(url, json=request_body)
228
+ response.raise_for_status()
229
+ return parse_search_response(
230
+ raw_text=response.text, query=query, page_size=page_size
231
+ )
232
+
233
+ def next_page(self, result: SearchResult) -> SearchResult | None:
234
+ """
235
+ Fetch the next page of a search result.
236
+
237
+ :param result: A previous SearchResult.
238
+ :return: The next page of results, or None if there are no more pages.
239
+ """
240
+
241
+ if not result.has_more:
242
+ return None
243
+
244
+ return self.search(
245
+ query=result.query,
246
+ page_size=result.page_size,
247
+ page_token=result.next_page_token,
248
+ )
249
+
250
+ def issue(self, issue_id: int) -> Issue:
251
+ """
252
+ Fetch a single issue by its numeric ID.
253
+
254
+ :param issue_id: The issue ID (e.g. 40060244).
255
+ :return: The fully populated issue.
256
+ """
257
+
258
+ request_body: list = [issue_id, 2, 1]
259
+ url: str = f"{self.base_endpoint}/issues/{issue_id}/getIssue"
260
+
261
+ response: Response = self._http.post(url=url, json=request_body)
262
+ response.raise_for_status()
263
+ return parse_issue_detail_response(raw_text=response.text)
264
+
265
+ def issues(self, issue_ids: list[int]) -> Results[Issue]:
266
+ """
267
+ Fetch multiple issues by ID in a single request.
268
+
269
+ :param issue_ids: List of issue IDs to fetch.
270
+ :return: The fetched issues (order may not match input), as a list that
271
+ writes itself out via ``to_json()``/``to_csv()``.
272
+ """
273
+
274
+ request_body: list = ["b.BatchGetIssuesRequest", None, None, [issue_ids, 2, 2]]
275
+ url: str = f"{self.base_endpoint}/issues/batch"
276
+
277
+ response: Response = self._http.post(url, json=request_body)
278
+ response.raise_for_status()
279
+ return parse_batch_response(raw_text=response.text)
280
+
281
+ def issue_updates(self, issue_id: int) -> IssueUpdatesResult:
282
+ """
283
+ Fetch all updates (comments and field changes) for an issue.
284
+
285
+ Returns updates in reverse chronological order (newest first).
286
+ Use ``result.comments`` to get just the comments in chronological order.
287
+
288
+ :param issue_id: The issue ID to fetch updates for.
289
+ :return: Updates, total count, and pagination token.
290
+ """
291
+
292
+ url: str = f"{self.base_endpoint}/issues/{issue_id}/updates"
293
+ # currentTrackerId appears unnecessary, issue IDs are unique across all trackers,
294
+ # and resolve correctly regardless of what tracker is sent to the server.
295
+ # params=QueryParams({"currentTrackerId": self.tracker_ids[0] if self.tracker_ids else None}),
296
+ response: Response = self._http.post(url=url, json=[issue_id])
297
+ response.raise_for_status()
298
+
299
+ return parse_updates_response(raw_text=response.text)
300
+
301
+ def comments(
302
+ self,
303
+ issue_id: int,
304
+ sort_order: str = "ASC",
305
+ page_size: int = 500,
306
+ page_token: str | None = None,
307
+ ) -> CommentsResult:
308
+ """
309
+ Fetch comments for an issue.
310
+
311
+ Returns only text comments (no field-change-only updates).
312
+ Use :meth:`issue_updates` if you need field changes.
313
+
314
+ :param issue_id: The issue ID to fetch comments for.
315
+ :param sort_order: ``"ASC"`` for oldest-first, ``"DESC"`` for newest-first.
316
+ :param page_size: Number of comments per page (max 500).
317
+ :param page_token: Pagination token from a previous CommentsResult.
318
+ :return: Comments and pagination info.
319
+ """
320
+
321
+ url: str = f"{self.base_endpoint}/issues/{issue_id}/listComments"
322
+ request_body: list = [issue_id, sort_order, page_size]
323
+ if page_token:
324
+ request_body.append(page_token)
325
+
326
+ response: Response = self._http.post(url, json=request_body)
327
+ response.raise_for_status()
328
+ return parse_comments_response(raw_text=response.text)