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.
@@ -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")