github-actions-ingester 0.1.0__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.
- github_actions_ingester/__init__.py +9 -0
- github_actions_ingester/__main__.py +330 -0
- github_actions_ingester/app_manifest.py +97 -0
- github_actions_ingester/collector.py +338 -0
- github_actions_ingester/config.py +290 -0
- github_actions_ingester/github.py +585 -0
- github_actions_ingester/metrics.py +184 -0
- github_actions_ingester/migrations/0001_initial.sql +140 -0
- github_actions_ingester/ratelimit.py +39 -0
- github_actions_ingester/server.py +115 -0
- github_actions_ingester/store.py +580 -0
- github_actions_ingester/workflow_schedule.py +83 -0
- github_actions_ingester-0.1.0.dist-info/METADATA +522 -0
- github_actions_ingester-0.1.0.dist-info/RECORD +17 -0
- github_actions_ingester-0.1.0.dist-info/WHEEL +4 -0
- github_actions_ingester-0.1.0.dist-info/entry_points.txt +2 -0
- github_actions_ingester-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
"""GitHub REST API client — auth, pagination, rate-limit handling.
|
|
2
|
+
|
|
3
|
+
Everything the collector needs is a handful of documented endpoints:
|
|
4
|
+
|
|
5
|
+
GET /orgs/{org}/repos PAT: repositories of an org
|
|
6
|
+
GET /installation/repositories App: repositories the install sees
|
|
7
|
+
GET /repos/{owner}/{repo} explicit repositories
|
|
8
|
+
GET /repos/{owner}/{repo}/actions/workflows workflow files
|
|
9
|
+
GET /repos/{owner}/{repo}/actions/runs runs, `created` filter
|
|
10
|
+
GET /repos/{owner}/{repo}/actions/runs/{id} single run refresh
|
|
11
|
+
GET /repos/{owner}/{repo}/actions/runs/{id}/jobs
|
|
12
|
+
GET /repos/{owner}/{repo}/contents/{path} workflow YAML (schedules)
|
|
13
|
+
GET /app/installations, POST /app/installations/{id}/access_tokens
|
|
14
|
+
|
|
15
|
+
Shapes verified against https://docs.github.com/en/rest (2022-11-28).
|
|
16
|
+
|
|
17
|
+
Rate limiting has three layers here:
|
|
18
|
+
|
|
19
|
+
1. a client-side leaky bucket (``RateLimiter``) that paces every call;
|
|
20
|
+
2. the primary-limit guard: when ``X-RateLimit-Remaining`` drops under
|
|
21
|
+
``min_remaining`` the client sleeps until ``X-RateLimit-Reset``;
|
|
22
|
+
3. reactive retries on 403/429 (``Retry-After`` / reset header) and on
|
|
23
|
+
5xx with exponential backoff.
|
|
24
|
+
|
|
25
|
+
The `runs` listing is capped by GitHub at 1000 results per query
|
|
26
|
+
regardless of pagination, so ``list_runs`` splits the ``created`` window
|
|
27
|
+
recursively until each slice fits.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import time
|
|
33
|
+
from collections.abc import Callable, Iterator
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from datetime import UTC, datetime, timedelta
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
import httpx
|
|
39
|
+
import jwt
|
|
40
|
+
import structlog
|
|
41
|
+
|
|
42
|
+
from .ratelimit import RateLimiter
|
|
43
|
+
|
|
44
|
+
logger = structlog.get_logger(__name__)
|
|
45
|
+
|
|
46
|
+
API_VERSION = "2022-11-28"
|
|
47
|
+
RUNS_QUERY_CAP = 1000 # documented ceiling of the /actions/runs listing
|
|
48
|
+
PER_PAGE = 100
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class GitHubAPIError(Exception):
|
|
52
|
+
"""Non-retryable upstream failure (4xx other than rate limit)."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, endpoint: str, status: int, message: str = "") -> None:
|
|
55
|
+
self.endpoint = endpoint
|
|
56
|
+
self.status = status
|
|
57
|
+
super().__init__(f"{endpoint} → HTTP {status} {message}".strip())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class GitHubRateLimitError(GitHubAPIError):
|
|
61
|
+
"""Raised when the retry budget runs out while rate-limited."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class RateLimitState:
|
|
66
|
+
limit: int = 0
|
|
67
|
+
remaining: int = 0
|
|
68
|
+
reset_at: float = 0.0 # epoch seconds
|
|
69
|
+
used: int = 0
|
|
70
|
+
|
|
71
|
+
def update(self, headers: httpx.Headers) -> None:
|
|
72
|
+
try:
|
|
73
|
+
self.limit = int(headers.get("x-ratelimit-limit", self.limit))
|
|
74
|
+
self.remaining = int(headers.get("x-ratelimit-remaining", self.remaining))
|
|
75
|
+
self.reset_at = float(headers.get("x-ratelimit-reset", self.reset_at))
|
|
76
|
+
self.used = int(headers.get("x-ratelimit-used", self.used))
|
|
77
|
+
except ValueError:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Authentication
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TokenAuth:
|
|
87
|
+
"""Static bearer token (PAT)."""
|
|
88
|
+
|
|
89
|
+
kind = "token"
|
|
90
|
+
|
|
91
|
+
def __init__(self, token: str) -> None:
|
|
92
|
+
self._token = token
|
|
93
|
+
|
|
94
|
+
def token(self, _client: httpx.Client) -> str:
|
|
95
|
+
return self._token
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class AppAuth:
|
|
99
|
+
"""GitHub App: RS256 JWT → installation access token, cached until expiry."""
|
|
100
|
+
|
|
101
|
+
kind = "app"
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
app_id: str,
|
|
106
|
+
private_key_pem: str,
|
|
107
|
+
installation_id: str = "",
|
|
108
|
+
preferred_owner: str = "",
|
|
109
|
+
clock: Callable[[], float] = time.time,
|
|
110
|
+
) -> None:
|
|
111
|
+
self._app_id = app_id
|
|
112
|
+
self._pem = private_key_pem
|
|
113
|
+
self._installation_id = installation_id
|
|
114
|
+
self._preferred_owner = preferred_owner.lower()
|
|
115
|
+
self._clock = clock
|
|
116
|
+
self._cached: str = ""
|
|
117
|
+
self._expires_at: float = 0.0
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def installation_id(self) -> str:
|
|
121
|
+
return self._installation_id
|
|
122
|
+
|
|
123
|
+
def app_jwt(self) -> str:
|
|
124
|
+
now = int(self._clock())
|
|
125
|
+
payload = {"iat": now - 60, "exp": now + 9 * 60, "iss": self._app_id}
|
|
126
|
+
return jwt.encode(payload, self._pem, algorithm="RS256")
|
|
127
|
+
|
|
128
|
+
def token(self, client: httpx.Client) -> str:
|
|
129
|
+
if self._cached and self._clock() < self._expires_at - 120:
|
|
130
|
+
return self._cached
|
|
131
|
+
headers = {
|
|
132
|
+
"Authorization": f"Bearer {self.app_jwt()}",
|
|
133
|
+
"Accept": "application/vnd.github+json",
|
|
134
|
+
"X-GitHub-Api-Version": API_VERSION,
|
|
135
|
+
}
|
|
136
|
+
if not self._installation_id:
|
|
137
|
+
self._installation_id = self._discover_installation(client, headers)
|
|
138
|
+
resp = client.post(
|
|
139
|
+
f"/app/installations/{self._installation_id}/access_tokens", headers=headers
|
|
140
|
+
)
|
|
141
|
+
if resp.status_code != 201:
|
|
142
|
+
raise GitHubAPIError("/app/installations/*/access_tokens", resp.status_code, resp.text)
|
|
143
|
+
body = resp.json()
|
|
144
|
+
self._cached = str(body["token"])
|
|
145
|
+
expires = datetime.fromisoformat(str(body["expires_at"]).replace("Z", "+00:00"))
|
|
146
|
+
self._expires_at = expires.timestamp()
|
|
147
|
+
logger.info("github.app_token_refreshed", installation_id=self._installation_id)
|
|
148
|
+
return self._cached
|
|
149
|
+
|
|
150
|
+
def _discover_installation(self, client: httpx.Client, headers: dict[str, str]) -> str:
|
|
151
|
+
resp = client.get("/app/installations", headers=headers, params={"per_page": PER_PAGE})
|
|
152
|
+
if resp.status_code != 200:
|
|
153
|
+
raise GitHubAPIError("/app/installations", resp.status_code, resp.text)
|
|
154
|
+
installs = resp.json()
|
|
155
|
+
if not installs:
|
|
156
|
+
raise GitHubAPIError("/app/installations", 404, "the App is not installed anywhere")
|
|
157
|
+
if self._preferred_owner:
|
|
158
|
+
for inst in installs:
|
|
159
|
+
login = str(inst.get("account", {}).get("login", "")).lower()
|
|
160
|
+
if login == self._preferred_owner:
|
|
161
|
+
return str(inst["id"])
|
|
162
|
+
if len(installs) == 1:
|
|
163
|
+
return str(installs[0]["id"])
|
|
164
|
+
logins = [i.get("account", {}).get("login") for i in installs]
|
|
165
|
+
raise GitHubAPIError(
|
|
166
|
+
"/app/installations",
|
|
167
|
+
409,
|
|
168
|
+
f"several installations ({logins}); set GHA_GITHUB_APP_INSTALLATION_ID",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# Typed shapes (only the fields the store persists)
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _ts(value: Any) -> datetime | None:
|
|
178
|
+
if not value:
|
|
179
|
+
return None
|
|
180
|
+
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class Repository:
|
|
185
|
+
id: int
|
|
186
|
+
owner: str
|
|
187
|
+
name: str
|
|
188
|
+
full_name: str
|
|
189
|
+
default_branch: str
|
|
190
|
+
private: bool
|
|
191
|
+
archived: bool
|
|
192
|
+
html_url: str
|
|
193
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def from_api(cls, d: dict[str, Any]) -> Repository:
|
|
197
|
+
return cls(
|
|
198
|
+
id=int(d["id"]),
|
|
199
|
+
owner=str(d["owner"]["login"]),
|
|
200
|
+
name=str(d["name"]),
|
|
201
|
+
full_name=str(d["full_name"]),
|
|
202
|
+
default_branch=str(d.get("default_branch") or "main"),
|
|
203
|
+
private=bool(d.get("private", False)),
|
|
204
|
+
archived=bool(d.get("archived", False)),
|
|
205
|
+
html_url=str(d.get("html_url", "")),
|
|
206
|
+
raw=d,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass
|
|
211
|
+
class Workflow:
|
|
212
|
+
id: int
|
|
213
|
+
repository_id: int
|
|
214
|
+
name: str
|
|
215
|
+
path: str
|
|
216
|
+
state: str
|
|
217
|
+
html_url: str
|
|
218
|
+
created_at: datetime | None
|
|
219
|
+
updated_at: datetime | None
|
|
220
|
+
|
|
221
|
+
@classmethod
|
|
222
|
+
def from_api(cls, repository_id: int, d: dict[str, Any]) -> Workflow:
|
|
223
|
+
return cls(
|
|
224
|
+
id=int(d["id"]),
|
|
225
|
+
repository_id=repository_id,
|
|
226
|
+
name=str(d.get("name") or d.get("path") or ""),
|
|
227
|
+
path=str(d.get("path", "")),
|
|
228
|
+
state=str(d.get("state", "")),
|
|
229
|
+
html_url=str(d.get("html_url", "")),
|
|
230
|
+
created_at=_ts(d.get("created_at")),
|
|
231
|
+
updated_at=_ts(d.get("updated_at")),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass
|
|
236
|
+
class WorkflowRun:
|
|
237
|
+
id: int
|
|
238
|
+
repository_id: int
|
|
239
|
+
workflow_id: int
|
|
240
|
+
run_number: int
|
|
241
|
+
run_attempt: int
|
|
242
|
+
name: str
|
|
243
|
+
display_title: str
|
|
244
|
+
event: str
|
|
245
|
+
status: str
|
|
246
|
+
conclusion: str | None
|
|
247
|
+
head_branch: str | None
|
|
248
|
+
head_sha: str
|
|
249
|
+
actor: str
|
|
250
|
+
triggering_actor: str
|
|
251
|
+
created_at: datetime
|
|
252
|
+
updated_at: datetime | None
|
|
253
|
+
run_started_at: datetime | None
|
|
254
|
+
html_url: str
|
|
255
|
+
|
|
256
|
+
@classmethod
|
|
257
|
+
def from_api(cls, d: dict[str, Any]) -> WorkflowRun:
|
|
258
|
+
created = _ts(d.get("created_at"))
|
|
259
|
+
if created is None:
|
|
260
|
+
raise ValueError(f"run {d.get('id')} has no created_at")
|
|
261
|
+
return cls(
|
|
262
|
+
id=int(d["id"]),
|
|
263
|
+
repository_id=int(d["repository"]["id"]),
|
|
264
|
+
workflow_id=int(d["workflow_id"]),
|
|
265
|
+
run_number=int(d.get("run_number", 0)),
|
|
266
|
+
run_attempt=int(d.get("run_attempt", 1)),
|
|
267
|
+
name=str(d.get("name") or ""),
|
|
268
|
+
display_title=str(d.get("display_title") or ""),
|
|
269
|
+
event=str(d.get("event", "")),
|
|
270
|
+
status=str(d.get("status") or ""),
|
|
271
|
+
conclusion=d.get("conclusion"),
|
|
272
|
+
head_branch=d.get("head_branch"),
|
|
273
|
+
head_sha=str(d.get("head_sha", "")),
|
|
274
|
+
actor=str((d.get("actor") or {}).get("login", "")),
|
|
275
|
+
triggering_actor=str((d.get("triggering_actor") or {}).get("login", "")),
|
|
276
|
+
created_at=created,
|
|
277
|
+
updated_at=_ts(d.get("updated_at")),
|
|
278
|
+
run_started_at=_ts(d.get("run_started_at")),
|
|
279
|
+
html_url=str(d.get("html_url", "")),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def is_open(self) -> bool:
|
|
284
|
+
return self.status != "completed"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass
|
|
288
|
+
class WorkflowJob:
|
|
289
|
+
id: int
|
|
290
|
+
run_id: int
|
|
291
|
+
repository_id: int
|
|
292
|
+
run_attempt: int
|
|
293
|
+
name: str
|
|
294
|
+
status: str
|
|
295
|
+
conclusion: str | None
|
|
296
|
+
runner_name: str | None
|
|
297
|
+
runner_group_name: str | None
|
|
298
|
+
labels: list[str]
|
|
299
|
+
created_at: datetime | None
|
|
300
|
+
started_at: datetime | None
|
|
301
|
+
completed_at: datetime | None
|
|
302
|
+
steps: int
|
|
303
|
+
html_url: str
|
|
304
|
+
|
|
305
|
+
@classmethod
|
|
306
|
+
def from_api(cls, repository_id: int, d: dict[str, Any]) -> WorkflowJob:
|
|
307
|
+
return cls(
|
|
308
|
+
id=int(d["id"]),
|
|
309
|
+
run_id=int(d["run_id"]),
|
|
310
|
+
repository_id=repository_id,
|
|
311
|
+
run_attempt=int(d.get("run_attempt", 1)),
|
|
312
|
+
name=str(d.get("name", "")),
|
|
313
|
+
status=str(d.get("status") or ""),
|
|
314
|
+
conclusion=d.get("conclusion"),
|
|
315
|
+
runner_name=d.get("runner_name"),
|
|
316
|
+
runner_group_name=d.get("runner_group_name"),
|
|
317
|
+
labels=[str(x) for x in d.get("labels") or []],
|
|
318
|
+
created_at=_ts(d.get("created_at")),
|
|
319
|
+
started_at=_ts(d.get("started_at")),
|
|
320
|
+
completed_at=_ts(d.get("completed_at")),
|
|
321
|
+
steps=len(d.get("steps") or []),
|
|
322
|
+
html_url=str(d.get("html_url", "")),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
# Client
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class GitHubClient:
|
|
332
|
+
def __init__(
|
|
333
|
+
self,
|
|
334
|
+
auth: TokenAuth | AppAuth,
|
|
335
|
+
base_url: str = "https://api.github.com",
|
|
336
|
+
timeout: float = 30.0,
|
|
337
|
+
limiter: RateLimiter | None = None,
|
|
338
|
+
min_remaining: int = 200,
|
|
339
|
+
max_retries: int = 4,
|
|
340
|
+
on_request: Callable[[int], None] | None = None,
|
|
341
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
342
|
+
clock: Callable[[], float] = time.time,
|
|
343
|
+
) -> None:
|
|
344
|
+
self._auth = auth
|
|
345
|
+
self._client = httpx.Client(
|
|
346
|
+
base_url=base_url,
|
|
347
|
+
timeout=timeout,
|
|
348
|
+
headers={
|
|
349
|
+
"Accept": "application/vnd.github+json",
|
|
350
|
+
"X-GitHub-Api-Version": API_VERSION,
|
|
351
|
+
"User-Agent": "github-actions-ingester",
|
|
352
|
+
},
|
|
353
|
+
)
|
|
354
|
+
self._limiter = limiter or RateLimiter(5.0)
|
|
355
|
+
self._min_remaining = min_remaining
|
|
356
|
+
self._max_retries = max_retries
|
|
357
|
+
self._on_request = on_request
|
|
358
|
+
self._sleep = sleep
|
|
359
|
+
self._clock = clock
|
|
360
|
+
self.rate_limit = RateLimitState()
|
|
361
|
+
|
|
362
|
+
@property
|
|
363
|
+
def auth_kind(self) -> str:
|
|
364
|
+
return self._auth.kind
|
|
365
|
+
|
|
366
|
+
def close(self) -> None:
|
|
367
|
+
self._client.close()
|
|
368
|
+
|
|
369
|
+
# -- low level ---------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
def _wait_for_primary_limit(self) -> None:
|
|
372
|
+
if self.rate_limit.limit and self.rate_limit.remaining < self._min_remaining:
|
|
373
|
+
wait = self.rate_limit.reset_at - self._clock() + 2
|
|
374
|
+
if wait > 0:
|
|
375
|
+
logger.warning(
|
|
376
|
+
"github.rate_limit_guard",
|
|
377
|
+
remaining=self.rate_limit.remaining,
|
|
378
|
+
min_remaining=self._min_remaining,
|
|
379
|
+
sleep_seconds=round(wait),
|
|
380
|
+
)
|
|
381
|
+
self._sleep(min(wait, 3600))
|
|
382
|
+
# The reset moved us past the window; forget the stale count.
|
|
383
|
+
self.rate_limit.remaining = self.rate_limit.limit
|
|
384
|
+
|
|
385
|
+
def get(self, path: str, params: dict[str, Any] | None = None) -> httpx.Response:
|
|
386
|
+
"""GET with pacing, auth, rate-limit retries and 5xx backoff.
|
|
387
|
+
|
|
388
|
+
``path`` may be an absolute URL taken from a ``Link`` header; it is
|
|
389
|
+
re-anchored on the configured base first (see ``_rebase``).
|
|
390
|
+
"""
|
|
391
|
+
path = self._rebase(path)
|
|
392
|
+
attempt = 0
|
|
393
|
+
while True:
|
|
394
|
+
self._wait_for_primary_limit()
|
|
395
|
+
self._limiter.acquire()
|
|
396
|
+
headers = {"Authorization": f"Bearer {self._auth.token(self._client)}"}
|
|
397
|
+
try:
|
|
398
|
+
resp = self._client.get(path, params=params, headers=headers)
|
|
399
|
+
except httpx.HTTPError as exc:
|
|
400
|
+
attempt += 1
|
|
401
|
+
if attempt > self._max_retries:
|
|
402
|
+
raise GitHubAPIError(path, 0, f"transport error: {exc}") from exc
|
|
403
|
+
logger.warning("github.transport_retry", path=path, attempt=attempt, error=str(exc))
|
|
404
|
+
self._sleep(min(2**attempt, 30))
|
|
405
|
+
continue
|
|
406
|
+
self.rate_limit.update(resp.headers)
|
|
407
|
+
if self._on_request is not None:
|
|
408
|
+
self._on_request(resp.status_code)
|
|
409
|
+
if resp.status_code < 400:
|
|
410
|
+
return resp
|
|
411
|
+
if resp.status_code in (403, 429) and self._is_rate_limited(resp):
|
|
412
|
+
attempt += 1
|
|
413
|
+
if attempt > self._max_retries:
|
|
414
|
+
raise GitHubRateLimitError(path, resp.status_code, "rate limit retries")
|
|
415
|
+
delay = self._retry_delay(resp, attempt)
|
|
416
|
+
logger.warning(
|
|
417
|
+
"github.rate_limited", path=path, status=resp.status_code, sleep=round(delay)
|
|
418
|
+
)
|
|
419
|
+
self._sleep(delay)
|
|
420
|
+
continue
|
|
421
|
+
if resp.status_code >= 500:
|
|
422
|
+
attempt += 1
|
|
423
|
+
if attempt > self._max_retries:
|
|
424
|
+
raise GitHubAPIError(path, resp.status_code, resp.text[:200])
|
|
425
|
+
self._sleep(min(2**attempt, 30))
|
|
426
|
+
continue
|
|
427
|
+
raise GitHubAPIError(path, resp.status_code, resp.text[:200])
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
def _is_rate_limited(resp: httpx.Response) -> bool:
|
|
431
|
+
if resp.status_code == 429:
|
|
432
|
+
return True
|
|
433
|
+
if resp.headers.get("x-ratelimit-remaining") == "0":
|
|
434
|
+
return True
|
|
435
|
+
if "retry-after" in resp.headers:
|
|
436
|
+
return True
|
|
437
|
+
text = resp.text.lower()
|
|
438
|
+
return "rate limit" in text or "abuse" in text
|
|
439
|
+
|
|
440
|
+
def _retry_delay(self, resp: httpx.Response, attempt: int) -> float:
|
|
441
|
+
retry_after = resp.headers.get("retry-after")
|
|
442
|
+
if retry_after:
|
|
443
|
+
try:
|
|
444
|
+
return float(retry_after) + 1
|
|
445
|
+
except ValueError:
|
|
446
|
+
pass
|
|
447
|
+
if resp.headers.get("x-ratelimit-remaining") == "0":
|
|
448
|
+
reset = float(resp.headers.get("x-ratelimit-reset", "0") or 0)
|
|
449
|
+
wait = reset - self._clock() + 2
|
|
450
|
+
if wait > 0:
|
|
451
|
+
return min(wait, 3600)
|
|
452
|
+
return float(min(15 * 2**attempt, 300))
|
|
453
|
+
|
|
454
|
+
def paginate(
|
|
455
|
+
self, path: str, params: dict[str, Any] | None = None, key: str | None = None
|
|
456
|
+
) -> Iterator[dict[str, Any]]:
|
|
457
|
+
"""Follow ``Link: rel=next`` until exhausted.
|
|
458
|
+
|
|
459
|
+
``key`` names the list inside an envelope (``workflows``,
|
|
460
|
+
``workflow_runs``, ``jobs``, ``repositories``); bare-list responses
|
|
461
|
+
pass ``key=None``.
|
|
462
|
+
"""
|
|
463
|
+
params = dict(params or {})
|
|
464
|
+
params.setdefault("per_page", PER_PAGE)
|
|
465
|
+
url: str | None = path
|
|
466
|
+
first = True
|
|
467
|
+
while url:
|
|
468
|
+
resp = self.get(url, params=params if first else None)
|
|
469
|
+
first = False
|
|
470
|
+
body = resp.json()
|
|
471
|
+
items = body[key] if key else body
|
|
472
|
+
yield from items
|
|
473
|
+
url = resp.links.get("next", {}).get("url")
|
|
474
|
+
|
|
475
|
+
def _rebase(self, path: str) -> str:
|
|
476
|
+
"""Keep every request on the configured base URL.
|
|
477
|
+
|
|
478
|
+
``Link`` headers carry absolute URLs on GitHub's own host
|
|
479
|
+
(``https://api.github.com/repositories/{id}/...``). When the client
|
|
480
|
+
talks to something else, a GHES behind a different public name or
|
|
481
|
+
a forwarding proxy, following them verbatim would leave that host
|
|
482
|
+
behind after page 1, so only the path and query of the link are
|
|
483
|
+
kept. Relative paths and same-origin URLs pass through untouched.
|
|
484
|
+
"""
|
|
485
|
+
if not path.startswith(("http://", "https://")):
|
|
486
|
+
return path
|
|
487
|
+
link = httpx.URL(path)
|
|
488
|
+
base = self._client.base_url
|
|
489
|
+
if (link.scheme, link.host, link.port) == (base.scheme, base.host, base.port):
|
|
490
|
+
return path
|
|
491
|
+
return str(link.copy_with(scheme=base.scheme, host=base.host, port=base.port))
|
|
492
|
+
|
|
493
|
+
# -- repositories --------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
def get_repository(self, full_name: str) -> Repository:
|
|
496
|
+
return Repository.from_api(self.get(f"/repos/{full_name}").json())
|
|
497
|
+
|
|
498
|
+
def list_org_repositories(self, org: str) -> Iterator[Repository]:
|
|
499
|
+
for d in self.paginate(f"/orgs/{org}/repos", {"type": "all", "sort": "full_name"}):
|
|
500
|
+
yield Repository.from_api(d)
|
|
501
|
+
|
|
502
|
+
def list_installation_repositories(self) -> Iterator[Repository]:
|
|
503
|
+
for d in self.paginate("/installation/repositories", key="repositories"):
|
|
504
|
+
yield Repository.from_api(d)
|
|
505
|
+
|
|
506
|
+
# -- workflows -----------------------------------------------------------
|
|
507
|
+
|
|
508
|
+
def list_workflows(self, repo: Repository) -> Iterator[Workflow]:
|
|
509
|
+
for d in self.paginate(f"/repos/{repo.full_name}/actions/workflows", key="workflows"):
|
|
510
|
+
yield Workflow.from_api(repo.id, d)
|
|
511
|
+
|
|
512
|
+
def get_file_text(self, repo: Repository, path: str, ref: str) -> str | None:
|
|
513
|
+
"""Raw file content from the default branch; None when missing."""
|
|
514
|
+
try:
|
|
515
|
+
resp = self.get(
|
|
516
|
+
f"/repos/{repo.full_name}/contents/{path.lstrip('/')}",
|
|
517
|
+
{"ref": ref},
|
|
518
|
+
)
|
|
519
|
+
except GitHubAPIError as exc:
|
|
520
|
+
if exc.status == 404:
|
|
521
|
+
return None
|
|
522
|
+
raise
|
|
523
|
+
body = resp.json()
|
|
524
|
+
if body.get("encoding") == "base64" and body.get("content"):
|
|
525
|
+
import base64
|
|
526
|
+
|
|
527
|
+
return base64.b64decode(body["content"]).decode("utf-8", errors="replace")
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
# -- runs ----------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
def list_runs(
|
|
533
|
+
self, repo: Repository, since: datetime, until: datetime | None = None
|
|
534
|
+
) -> Iterator[WorkflowRun]:
|
|
535
|
+
"""Runs created in ``[since, until]``, splitting windows over the API cap."""
|
|
536
|
+
until = until or datetime.now(UTC)
|
|
537
|
+
yield from self._list_runs_window(repo, since, until, depth=0)
|
|
538
|
+
|
|
539
|
+
def _list_runs_window(
|
|
540
|
+
self, repo: Repository, since: datetime, until: datetime, depth: int
|
|
541
|
+
) -> Iterator[WorkflowRun]:
|
|
542
|
+
created = f"{_iso(since)}..{_iso(until)}"
|
|
543
|
+
path = f"/repos/{repo.full_name}/actions/runs"
|
|
544
|
+
first = self.get(path, {"created": created, "per_page": PER_PAGE, "page": 1})
|
|
545
|
+
body = first.json()
|
|
546
|
+
total = int(body.get("total_count", 0))
|
|
547
|
+
if total > RUNS_QUERY_CAP and (until - since) > timedelta(minutes=5) and depth < 12:
|
|
548
|
+
mid = since + (until - since) / 2
|
|
549
|
+
logger.info(
|
|
550
|
+
"github.runs_window_split",
|
|
551
|
+
repo=repo.full_name,
|
|
552
|
+
total=total,
|
|
553
|
+
since=_iso(since),
|
|
554
|
+
until=_iso(until),
|
|
555
|
+
)
|
|
556
|
+
yield from self._list_runs_window(repo, since, mid, depth + 1)
|
|
557
|
+
yield from self._list_runs_window(repo, mid + timedelta(seconds=1), until, depth + 1)
|
|
558
|
+
return
|
|
559
|
+
for d in body.get("workflow_runs", []):
|
|
560
|
+
yield WorkflowRun.from_api(d)
|
|
561
|
+
url: str | None = first.links.get("next", {}).get("url")
|
|
562
|
+
while url:
|
|
563
|
+
resp = self.get(url)
|
|
564
|
+
for d in resp.json().get("workflow_runs", []):
|
|
565
|
+
yield WorkflowRun.from_api(d)
|
|
566
|
+
url = resp.links.get("next", {}).get("url")
|
|
567
|
+
|
|
568
|
+
def get_run(self, repo_full_name: str, run_id: int) -> WorkflowRun:
|
|
569
|
+
return WorkflowRun.from_api(
|
|
570
|
+
self.get(f"/repos/{repo_full_name}/actions/runs/{run_id}").json()
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
def list_jobs(
|
|
574
|
+
self, repo: Repository, run_id: int, jobs_filter: str = "all"
|
|
575
|
+
) -> Iterator[WorkflowJob]:
|
|
576
|
+
for d in self.paginate(
|
|
577
|
+
f"/repos/{repo.full_name}/actions/runs/{run_id}/jobs",
|
|
578
|
+
{"filter": jobs_filter},
|
|
579
|
+
key="jobs",
|
|
580
|
+
):
|
|
581
|
+
yield WorkflowJob.from_api(repo.id, d)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _iso(dt: datetime) -> str:
|
|
585
|
+
return dt.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|