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,338 @@
|
|
|
1
|
+
"""Ingestion cycle: GitHub → PostgreSQL, incrementally.
|
|
2
|
+
|
|
3
|
+
One cycle does, in order:
|
|
4
|
+
|
|
5
|
+
1. **Inventory** (every ``repo_refresh_seconds``): list the repositories
|
|
6
|
+
in scope, then the workflow files of each, upsert both. Optionally
|
|
7
|
+
read each workflow YAML from the default branch to record its cron
|
|
8
|
+
schedules.
|
|
9
|
+
2. **Runs**, per repository: list runs created since
|
|
10
|
+
``cursor - lookback`` (``now - backfill_days`` on the first cycle),
|
|
11
|
+
upsert. Runs whose status/updated_at changed are flagged for a jobs
|
|
12
|
+
refresh by the store itself.
|
|
13
|
+
3. **Jobs**: fetch the jobs of every run flagged above and upsert them;
|
|
14
|
+
the run's ``completed_at`` is derived from the last job to finish.
|
|
15
|
+
4. **Stale open runs**: runs still open but older than the lookback
|
|
16
|
+
window (long queues, multi-hour jobs) are refreshed one by one,
|
|
17
|
+
bounded by ``max_open_run_refresh``.
|
|
18
|
+
5. **Gauges**: table counts and scheduled-workflow liveness.
|
|
19
|
+
|
|
20
|
+
Failures in one repository are logged and counted; the cycle carries on
|
|
21
|
+
with the next one. Only a rate-limit exhaustion aborts the whole cycle.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from collections.abc import Callable, Iterable
|
|
29
|
+
from datetime import UTC, datetime, timedelta
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import structlog
|
|
33
|
+
|
|
34
|
+
from .config import Settings
|
|
35
|
+
from .github import (
|
|
36
|
+
GitHubAPIError,
|
|
37
|
+
GitHubClient,
|
|
38
|
+
GitHubRateLimitError,
|
|
39
|
+
Repository,
|
|
40
|
+
WorkflowRun,
|
|
41
|
+
)
|
|
42
|
+
from .metrics import Metrics
|
|
43
|
+
from .store import Store
|
|
44
|
+
from .workflow_schedule import expected_interval_seconds, parse_schedules
|
|
45
|
+
|
|
46
|
+
logger = structlog.get_logger(__name__)
|
|
47
|
+
|
|
48
|
+
CONCLUSIONS = (
|
|
49
|
+
"success",
|
|
50
|
+
"failure",
|
|
51
|
+
"cancelled",
|
|
52
|
+
"skipped",
|
|
53
|
+
"timed_out",
|
|
54
|
+
"action_required",
|
|
55
|
+
"neutral",
|
|
56
|
+
"stale",
|
|
57
|
+
"startup_failure",
|
|
58
|
+
)
|
|
59
|
+
_RUN_BATCH = 500
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _batched(items: Iterable[WorkflowRun], size: int) -> Iterable[list[WorkflowRun]]:
|
|
63
|
+
batch: list[WorkflowRun] = []
|
|
64
|
+
for item in items:
|
|
65
|
+
batch.append(item)
|
|
66
|
+
if len(batch) >= size:
|
|
67
|
+
yield batch
|
|
68
|
+
batch = []
|
|
69
|
+
if batch:
|
|
70
|
+
yield batch
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Collector:
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
client: GitHubClient,
|
|
77
|
+
store: Store,
|
|
78
|
+
metrics: Metrics,
|
|
79
|
+
settings: Settings,
|
|
80
|
+
clock: Callable[[], datetime] | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
self._client = client
|
|
83
|
+
self._store = store
|
|
84
|
+
self._metrics = metrics
|
|
85
|
+
self._settings = settings
|
|
86
|
+
self._clock = clock or (lambda: datetime.now(UTC))
|
|
87
|
+
self._repos: list[Repository] = []
|
|
88
|
+
self._inventory_at: datetime | None = None
|
|
89
|
+
self.cycles = 0
|
|
90
|
+
|
|
91
|
+
# -- inventory ----------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _discover_repositories(self) -> list[Repository]:
|
|
94
|
+
s = self._settings
|
|
95
|
+
found: dict[int, Repository] = {}
|
|
96
|
+
orgs = {o.lower() for o in s.org_list()}
|
|
97
|
+
if orgs:
|
|
98
|
+
if s.uses_app():
|
|
99
|
+
for repo in self._client.list_installation_repositories():
|
|
100
|
+
if repo.owner.lower() in orgs:
|
|
101
|
+
found[repo.id] = repo
|
|
102
|
+
else:
|
|
103
|
+
for org in s.org_list():
|
|
104
|
+
for repo in self._client.list_org_repositories(org):
|
|
105
|
+
found[repo.id] = repo
|
|
106
|
+
for full_name in s.repo_list():
|
|
107
|
+
if any(r.full_name.lower() == full_name.lower() for r in found.values()):
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
repo = self._client.get_repository(full_name)
|
|
111
|
+
except GitHubAPIError as exc:
|
|
112
|
+
logger.warning("inventory.repo_unreachable", repo=full_name, error=str(exc))
|
|
113
|
+
self._metrics.errors_total.labels(stage="inventory").inc()
|
|
114
|
+
continue
|
|
115
|
+
found[repo.id] = repo
|
|
116
|
+
repos = []
|
|
117
|
+
for repo in found.values():
|
|
118
|
+
if s.is_excluded(repo.full_name):
|
|
119
|
+
continue
|
|
120
|
+
if repo.archived and not s.include_archived:
|
|
121
|
+
continue
|
|
122
|
+
repos.append(repo)
|
|
123
|
+
repos.sort(key=lambda r: r.full_name.lower())
|
|
124
|
+
return repos
|
|
125
|
+
|
|
126
|
+
def refresh_inventory(self) -> None:
|
|
127
|
+
repos = self._discover_repositories()
|
|
128
|
+
self._store.upsert_repositories(repos)
|
|
129
|
+
workflows_total = 0
|
|
130
|
+
for repo in repos:
|
|
131
|
+
try:
|
|
132
|
+
workflows = list(self._client.list_workflows(repo))
|
|
133
|
+
except GitHubAPIError as exc:
|
|
134
|
+
logger.warning("inventory.workflows_failed", repo=repo.full_name, error=str(exc))
|
|
135
|
+
self._metrics.errors_total.labels(stage="inventory").inc()
|
|
136
|
+
continue
|
|
137
|
+
workflows_total += self._store.upsert_workflows(workflows)
|
|
138
|
+
self._repos = repos
|
|
139
|
+
self._inventory_at = self._clock()
|
|
140
|
+
self._metrics.repositories.set(len(repos))
|
|
141
|
+
self._metrics.workflows.set(workflows_total)
|
|
142
|
+
logger.info("inventory.refreshed", repositories=len(repos), workflows=workflows_total)
|
|
143
|
+
if self._settings.sync_schedules:
|
|
144
|
+
self.sync_schedules()
|
|
145
|
+
|
|
146
|
+
def sync_schedules(self) -> None:
|
|
147
|
+
older_than = self._clock() - timedelta(seconds=self._settings.schedule_refresh_seconds)
|
|
148
|
+
rows = self._store.workflows_needing_schedule_sync(older_than)
|
|
149
|
+
synced = 0
|
|
150
|
+
for row in rows:
|
|
151
|
+
repo = Repository(
|
|
152
|
+
id=int(row["repository_id"]),
|
|
153
|
+
owner="",
|
|
154
|
+
name="",
|
|
155
|
+
full_name=str(row["full_name"]),
|
|
156
|
+
default_branch=str(row["default_branch"]),
|
|
157
|
+
private=False,
|
|
158
|
+
archived=False,
|
|
159
|
+
html_url="",
|
|
160
|
+
)
|
|
161
|
+
path = str(row["path"])
|
|
162
|
+
# Dynamic workflows (e.g. "dynamic/pages/pages-build-deployment")
|
|
163
|
+
# have no file in the tree; record an empty schedule.
|
|
164
|
+
crons: list[str] = []
|
|
165
|
+
if path.startswith(".github/workflows/"):
|
|
166
|
+
try:
|
|
167
|
+
text = self._client.get_file_text(repo, path, repo.default_branch)
|
|
168
|
+
except GitHubAPIError as exc:
|
|
169
|
+
logger.warning(
|
|
170
|
+
"schedules.read_failed", repo=repo.full_name, path=path, error=str(exc)
|
|
171
|
+
)
|
|
172
|
+
self._metrics.errors_total.labels(stage="schedules").inc()
|
|
173
|
+
continue
|
|
174
|
+
crons = parse_schedules(text) if text else []
|
|
175
|
+
interval = expected_interval_seconds(crons) if crons else None
|
|
176
|
+
self._store.set_workflow_schedules(int(row["id"]), crons, interval)
|
|
177
|
+
synced += 1
|
|
178
|
+
if rows:
|
|
179
|
+
logger.info("schedules.synced", workflows=synced, of=len(rows))
|
|
180
|
+
|
|
181
|
+
def _inventory_stale(self) -> bool:
|
|
182
|
+
if self._inventory_at is None:
|
|
183
|
+
return True
|
|
184
|
+
age = (self._clock() - self._inventory_at).total_seconds()
|
|
185
|
+
return age >= self._settings.repo_refresh_seconds
|
|
186
|
+
|
|
187
|
+
# -- runs / jobs -------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def ingest_repository(self, repo: Repository) -> int:
|
|
190
|
+
s = self._settings
|
|
191
|
+
now = self._clock()
|
|
192
|
+
cursor = self._store.get_cursor(repo.id)
|
|
193
|
+
if cursor is None:
|
|
194
|
+
since = now - timedelta(days=s.backfill_days)
|
|
195
|
+
logger.info("runs.backfill", repo=repo.full_name, since=since.isoformat())
|
|
196
|
+
else:
|
|
197
|
+
since = cursor - timedelta(minutes=s.lookback_minutes)
|
|
198
|
+
|
|
199
|
+
written = 0
|
|
200
|
+
for batch in _batched(self._client.list_runs(repo, since, now), _RUN_BATCH):
|
|
201
|
+
written += self._store.upsert_runs(batch)
|
|
202
|
+
if written:
|
|
203
|
+
self._metrics.runs_upserted_total.labels(repository=repo.full_name).inc(written)
|
|
204
|
+
|
|
205
|
+
# Open runs that fell out of the window: refresh them individually.
|
|
206
|
+
stale = self._store.open_runs_before(repo.id, since, s.max_open_run_refresh)
|
|
207
|
+
for run_id in stale:
|
|
208
|
+
try:
|
|
209
|
+
run = self._client.get_run(repo.full_name, run_id)
|
|
210
|
+
except GitHubAPIError as exc:
|
|
211
|
+
if exc.status == 404:
|
|
212
|
+
continue
|
|
213
|
+
raise
|
|
214
|
+
self._store.upsert_runs([run])
|
|
215
|
+
|
|
216
|
+
jobs_written = 0
|
|
217
|
+
for run_id in self._store.runs_needing_jobs(repo.id):
|
|
218
|
+
try:
|
|
219
|
+
jobs = list(self._client.list_jobs(repo, run_id, s.jobs_filter))
|
|
220
|
+
except GitHubAPIError as exc:
|
|
221
|
+
if exc.status == 404:
|
|
222
|
+
# Run deleted between listing and jobs fetch; mark as synced.
|
|
223
|
+
self._store.upsert_jobs(run_id, [])
|
|
224
|
+
continue
|
|
225
|
+
raise
|
|
226
|
+
jobs_written += self._store.upsert_jobs(run_id, jobs)
|
|
227
|
+
if jobs_written:
|
|
228
|
+
self._metrics.jobs_upserted_total.labels(repository=repo.full_name).inc(jobs_written)
|
|
229
|
+
|
|
230
|
+
self._store.set_cursor(repo.id, now, written)
|
|
231
|
+
logger.info(
|
|
232
|
+
"runs.ingested",
|
|
233
|
+
repo=repo.full_name,
|
|
234
|
+
runs=written,
|
|
235
|
+
jobs=jobs_written,
|
|
236
|
+
refreshed_open=len(stale),
|
|
237
|
+
since=since.isoformat(),
|
|
238
|
+
)
|
|
239
|
+
return written
|
|
240
|
+
|
|
241
|
+
# -- gauges ------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def update_gauges(self) -> None:
|
|
244
|
+
m = self._metrics
|
|
245
|
+
counts = self._store.counts()
|
|
246
|
+
m.stored_runs.set(counts.get("runs", 0))
|
|
247
|
+
m.stored_jobs.set(counts.get("jobs", 0))
|
|
248
|
+
m.open_runs.set(counts.get("open_runs", 0))
|
|
249
|
+
m.repositories.set(counts.get("repositories", 0))
|
|
250
|
+
m.workflows.set(counts.get("workflows", 0))
|
|
251
|
+
|
|
252
|
+
rows = self._store.scheduled_workflow_status()
|
|
253
|
+
m.scheduled_last_run_timestamp_seconds.clear()
|
|
254
|
+
m.scheduled_interval_seconds.clear()
|
|
255
|
+
m.scheduled_last_conclusion.clear()
|
|
256
|
+
m.scheduled_workflows.set(len(rows))
|
|
257
|
+
for row in rows:
|
|
258
|
+
labels: dict[str, Any] = {
|
|
259
|
+
"repository": row["repository"],
|
|
260
|
+
"workflow": row["path"],
|
|
261
|
+
"workflow_name": row["name"],
|
|
262
|
+
}
|
|
263
|
+
last = row.get("last_scheduled_run_at")
|
|
264
|
+
m.scheduled_last_run_timestamp_seconds.labels(**labels).set(
|
|
265
|
+
last.timestamp() if isinstance(last, datetime) else 0
|
|
266
|
+
)
|
|
267
|
+
interval = row.get("interval_seconds")
|
|
268
|
+
m.scheduled_interval_seconds.labels(**labels).set(
|
|
269
|
+
float(interval) if interval is not None else 0
|
|
270
|
+
)
|
|
271
|
+
last_conclusion = row.get("last_conclusion")
|
|
272
|
+
for c in CONCLUSIONS:
|
|
273
|
+
m.scheduled_last_conclusion.labels(**labels, conclusion=c).set(
|
|
274
|
+
1 if c == last_conclusion else 0
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def _update_rate_limit_gauges(self) -> None:
|
|
278
|
+
rl = self._client.rate_limit
|
|
279
|
+
self._metrics.github_rate_limit_remaining.set(rl.remaining)
|
|
280
|
+
self._metrics.github_rate_limit_limit.set(rl.limit)
|
|
281
|
+
self._metrics.github_rate_limit_reset_timestamp_seconds.set(rl.reset_at)
|
|
282
|
+
|
|
283
|
+
# -- cycle ---------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
def run_cycle(self) -> str:
|
|
286
|
+
"""Run one full cycle; returns ``ok``, ``partial`` or ``error``."""
|
|
287
|
+
m = self._metrics
|
|
288
|
+
started = time.monotonic()
|
|
289
|
+
result = "ok"
|
|
290
|
+
try:
|
|
291
|
+
if self._inventory_stale():
|
|
292
|
+
self.refresh_inventory()
|
|
293
|
+
failures = 0
|
|
294
|
+
for repo in self._repos:
|
|
295
|
+
t0 = time.monotonic()
|
|
296
|
+
try:
|
|
297
|
+
self.ingest_repository(repo)
|
|
298
|
+
except GitHubRateLimitError:
|
|
299
|
+
raise
|
|
300
|
+
except Exception as exc:
|
|
301
|
+
failures += 1
|
|
302
|
+
m.errors_total.labels(stage="repository").inc()
|
|
303
|
+
logger.error("runs.repository_failed", repo=repo.full_name, error=str(exc))
|
|
304
|
+
finally:
|
|
305
|
+
m.repository_cycle_duration_seconds.observe(time.monotonic() - t0)
|
|
306
|
+
self._update_rate_limit_gauges()
|
|
307
|
+
self.update_gauges()
|
|
308
|
+
if failures:
|
|
309
|
+
result = "partial"
|
|
310
|
+
except GitHubRateLimitError as exc:
|
|
311
|
+
logger.error("cycle.rate_limited", error=str(exc))
|
|
312
|
+
m.errors_total.labels(stage="rate_limit").inc()
|
|
313
|
+
result = "error"
|
|
314
|
+
except Exception as exc:
|
|
315
|
+
logger.exception("cycle.failed", error=str(exc))
|
|
316
|
+
m.errors_total.labels(stage="cycle").inc()
|
|
317
|
+
result = "error"
|
|
318
|
+
finally:
|
|
319
|
+
self._update_rate_limit_gauges()
|
|
320
|
+
elapsed = time.monotonic() - started
|
|
321
|
+
now_ts = time.time()
|
|
322
|
+
m.cycle_duration_seconds.observe(elapsed)
|
|
323
|
+
m.cycles_total.labels(result=result).inc()
|
|
324
|
+
m.last_cycle_timestamp_seconds.set(now_ts)
|
|
325
|
+
if result != "error":
|
|
326
|
+
m.last_success_timestamp_seconds.set(now_ts)
|
|
327
|
+
m.up.set(1)
|
|
328
|
+
m.ready.set(1)
|
|
329
|
+
else:
|
|
330
|
+
m.up.set(0)
|
|
331
|
+
self.cycles += 1
|
|
332
|
+
logger.info("cycle.done", result=result, seconds=round(elapsed, 2), cycle=self.cycles)
|
|
333
|
+
return result
|
|
334
|
+
|
|
335
|
+
def run_forever(self, stop: threading.Event) -> None:
|
|
336
|
+
while not stop.is_set():
|
|
337
|
+
self.run_cycle()
|
|
338
|
+
stop.wait(self._settings.poll_interval_seconds)
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Configuration — env-var driven via pydantic-settings.
|
|
2
|
+
|
|
3
|
+
Env vars are prefixed ``GHA_`` so ``GHA_GITHUB_TOKEN`` / ``GHA_DATABASE_URL``
|
|
4
|
+
/ ``GHA_POLL_INTERVAL_SECONDS`` etc. are picked up automatically.
|
|
5
|
+
|
|
6
|
+
Two credential shapes are accepted, pick ONE:
|
|
7
|
+
|
|
8
|
+
- a classic / fine-grained personal access token (``GHA_GITHUB_TOKEN``)
|
|
9
|
+
- a GitHub App (``GHA_GITHUB_APP_ID`` + ``GHA_GITHUB_APP_PRIVATE_KEY`` or
|
|
10
|
+
``..._PRIVATE_KEY_FILE``, optionally ``GHA_GITHUB_APP_INSTALLATION_ID``)
|
|
11
|
+
|
|
12
|
+
The App is the recommended shape for an organization: least privilege
|
|
13
|
+
(``Actions: read`` + ``Metadata: read`` + optional ``Contents: read`` for
|
|
14
|
+
schedule discovery), no human account in the loop, and installation
|
|
15
|
+
tokens rotate hourly on their own.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import fnmatch
|
|
21
|
+
import re
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from pydantic import Field, field_validator, model_validator
|
|
26
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
27
|
+
|
|
28
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
29
|
+
_OWNER_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _split_csv(value: str) -> list[str]:
|
|
33
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Settings(BaseSettings):
|
|
37
|
+
model_config = SettingsConfigDict(
|
|
38
|
+
env_prefix="GHA_",
|
|
39
|
+
env_file=".env",
|
|
40
|
+
env_file_encoding="utf-8",
|
|
41
|
+
case_sensitive=False,
|
|
42
|
+
extra="ignore",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# --- GitHub auth (pick one) ---
|
|
46
|
+
github_token: str = Field(
|
|
47
|
+
default="",
|
|
48
|
+
description="Personal access token (classic or fine-grained) with "
|
|
49
|
+
"`actions:read` on the target repositories. Mutually exclusive with "
|
|
50
|
+
"the GitHub App settings.",
|
|
51
|
+
)
|
|
52
|
+
github_app_id: str = Field(
|
|
53
|
+
default="",
|
|
54
|
+
description="GitHub App ID (or client ID). Requires "
|
|
55
|
+
"github_app_private_key / github_app_private_key_file.",
|
|
56
|
+
)
|
|
57
|
+
github_app_private_key: str = Field(
|
|
58
|
+
default="",
|
|
59
|
+
description="PEM-encoded private key of the GitHub App. Newlines may "
|
|
60
|
+
"be literal or escaped as `\\n` (both are accepted).",
|
|
61
|
+
)
|
|
62
|
+
github_app_private_key_file: str = Field(
|
|
63
|
+
default="",
|
|
64
|
+
description="Path to the PEM private key. Alternative to the inline "
|
|
65
|
+
"value — pair with a Secret volume mount in Kubernetes.",
|
|
66
|
+
)
|
|
67
|
+
github_app_installation_id: str = Field(
|
|
68
|
+
default="",
|
|
69
|
+
description="Installation ID of the App. When empty the ingester "
|
|
70
|
+
"lists /app/installations and picks the one matching the first "
|
|
71
|
+
"entry in `orgs` (or the only installation, if there is one).",
|
|
72
|
+
)
|
|
73
|
+
github_api_base: str = Field(
|
|
74
|
+
default="https://api.github.com",
|
|
75
|
+
description="Override the API base URL — set to "
|
|
76
|
+
"https://ghe.example.com/api/v3 for GitHub Enterprise Server.",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# --- Scope ---
|
|
80
|
+
orgs: str = Field(
|
|
81
|
+
default="",
|
|
82
|
+
description="Comma-separated organizations (or user accounts) whose "
|
|
83
|
+
"repositories are ingested. Every repository the credential can see "
|
|
84
|
+
"in each org is included unless excluded below.",
|
|
85
|
+
)
|
|
86
|
+
repos: str = Field(
|
|
87
|
+
default="",
|
|
88
|
+
description="Comma-separated explicit repositories as `owner/name`. "
|
|
89
|
+
"Combined with `orgs` (union).",
|
|
90
|
+
)
|
|
91
|
+
exclude_repos: str = Field(
|
|
92
|
+
default="",
|
|
93
|
+
description="Comma-separated glob patterns matched against "
|
|
94
|
+
"`owner/name` (fnmatch): e.g. `acme/legacy-*,acme/sandbox`.",
|
|
95
|
+
)
|
|
96
|
+
include_archived: bool = Field(
|
|
97
|
+
default=False,
|
|
98
|
+
description="Also ingest archived repositories. They rarely run "
|
|
99
|
+
"workflows, so skipping them saves API budget.",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# --- Database ---
|
|
103
|
+
database_url: str = Field(
|
|
104
|
+
...,
|
|
105
|
+
description="PostgreSQL connection URL (libpq form): "
|
|
106
|
+
"postgresql://user:pass@host:5432/dbname?sslmode=require. The role "
|
|
107
|
+
"needs CREATE on the database the first time (schema bootstrap) "
|
|
108
|
+
"and plain read/write afterwards.",
|
|
109
|
+
)
|
|
110
|
+
database_schema: str = Field(
|
|
111
|
+
default="gha",
|
|
112
|
+
description="Schema that holds every ingester table. Created on first start if missing.",
|
|
113
|
+
)
|
|
114
|
+
database_connect_timeout_seconds: int = Field(default=10, ge=1)
|
|
115
|
+
|
|
116
|
+
# --- Collection ---
|
|
117
|
+
poll_interval_seconds: int = Field(
|
|
118
|
+
default=300,
|
|
119
|
+
ge=30,
|
|
120
|
+
description="Seconds between ingestion cycles. Each cycle costs "
|
|
121
|
+
"roughly 1 request per repository (runs listing) + 1 per run that "
|
|
122
|
+
"changed since the previous cycle (jobs).",
|
|
123
|
+
)
|
|
124
|
+
backfill_days: int = Field(
|
|
125
|
+
default=30,
|
|
126
|
+
ge=1,
|
|
127
|
+
le=3660,
|
|
128
|
+
description="How far back the FIRST cycle for a repository goes. "
|
|
129
|
+
"Later cycles are incremental. Raising it later only affects "
|
|
130
|
+
"repositories that have no cursor yet.",
|
|
131
|
+
)
|
|
132
|
+
lookback_minutes: int = Field(
|
|
133
|
+
default=180,
|
|
134
|
+
ge=1,
|
|
135
|
+
description="Every cycle re-lists runs created within this window "
|
|
136
|
+
"before the cursor, so runs that changed status (queued → in "
|
|
137
|
+
"progress → completed) are refreshed even if a cycle was missed.",
|
|
138
|
+
)
|
|
139
|
+
repo_refresh_seconds: int = Field(
|
|
140
|
+
default=3600,
|
|
141
|
+
ge=60,
|
|
142
|
+
description="How often the repository + workflow inventory is "
|
|
143
|
+
"re-listed. New repositories / workflow files show up after at "
|
|
144
|
+
"most this delay.",
|
|
145
|
+
)
|
|
146
|
+
max_open_run_refresh: int = Field(
|
|
147
|
+
default=200,
|
|
148
|
+
ge=0,
|
|
149
|
+
description="Upper bound on individual `GET /runs/{id}` refreshes "
|
|
150
|
+
"per cycle for runs still open outside the lookback window (long "
|
|
151
|
+
"queues, multi-hour jobs). 0 disables.",
|
|
152
|
+
)
|
|
153
|
+
jobs_filter: str = Field(
|
|
154
|
+
default="all",
|
|
155
|
+
description="`all` ingests jobs from every run attempt (matches "
|
|
156
|
+
"GitHub billing); `latest` keeps only the last attempt.",
|
|
157
|
+
)
|
|
158
|
+
sync_schedules: bool = Field(
|
|
159
|
+
default=True,
|
|
160
|
+
description="Read each workflow file from the default branch and "
|
|
161
|
+
"record its `on.schedule` cron expressions (needs `Contents: read`). "
|
|
162
|
+
"Powers the scheduled-workflow liveness metrics.",
|
|
163
|
+
)
|
|
164
|
+
schedule_refresh_seconds: int = Field(default=21600, ge=300)
|
|
165
|
+
|
|
166
|
+
# --- API pacing ---
|
|
167
|
+
api_rate_limit_rps: float = Field(
|
|
168
|
+
default=5.0,
|
|
169
|
+
gt=0.0,
|
|
170
|
+
description="Max requests per second to the GitHub API. The "
|
|
171
|
+
"documented secondary limit is ~900 points/min for REST; 5 rps is "
|
|
172
|
+
"comfortably below it.",
|
|
173
|
+
)
|
|
174
|
+
api_min_remaining: int = Field(
|
|
175
|
+
default=200,
|
|
176
|
+
ge=0,
|
|
177
|
+
description="When the primary rate-limit `remaining` drops under "
|
|
178
|
+
"this value the ingester pauses until the limit resets instead of "
|
|
179
|
+
"burning the last requests other tools may need.",
|
|
180
|
+
)
|
|
181
|
+
api_timeout_seconds: float = Field(default=30.0, gt=0.0)
|
|
182
|
+
api_max_retries: int = Field(default=4, ge=0)
|
|
183
|
+
|
|
184
|
+
# --- Server ---
|
|
185
|
+
listen_host: str = Field(default="0.0.0.0", description="Bind address for /metrics.")
|
|
186
|
+
listen_port: int = Field(default=9619, ge=1, le=65535)
|
|
187
|
+
|
|
188
|
+
# --- Logging ---
|
|
189
|
+
log_level: str = Field(default="info")
|
|
190
|
+
log_format: str = Field(default="json", description="json or console")
|
|
191
|
+
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
# Validation
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
@field_validator("log_level")
|
|
197
|
+
@classmethod
|
|
198
|
+
def _normalize_level(cls, v: str) -> str:
|
|
199
|
+
v = v.lower()
|
|
200
|
+
if v not in {"debug", "info", "warning", "warn", "error"}:
|
|
201
|
+
raise ValueError(f"log_level must be debug/info/warning/error (got {v!r})")
|
|
202
|
+
return "warning" if v == "warn" else v
|
|
203
|
+
|
|
204
|
+
@field_validator("log_format")
|
|
205
|
+
@classmethod
|
|
206
|
+
def _normalize_format(cls, v: str) -> str:
|
|
207
|
+
v = v.lower()
|
|
208
|
+
if v not in {"json", "console"}:
|
|
209
|
+
raise ValueError(f"log_format must be json or console (got {v!r})")
|
|
210
|
+
return v
|
|
211
|
+
|
|
212
|
+
@field_validator("jobs_filter")
|
|
213
|
+
@classmethod
|
|
214
|
+
def _jobs_filter(cls, v: str) -> str:
|
|
215
|
+
v = v.lower()
|
|
216
|
+
if v not in {"all", "latest"}:
|
|
217
|
+
raise ValueError(f"jobs_filter must be all or latest (got {v!r})")
|
|
218
|
+
return v
|
|
219
|
+
|
|
220
|
+
@field_validator("database_schema")
|
|
221
|
+
@classmethod
|
|
222
|
+
def _schema_ident(cls, v: str) -> str:
|
|
223
|
+
if not re.fullmatch(r"[a-z_][a-z0-9_]{0,62}", v):
|
|
224
|
+
raise ValueError(
|
|
225
|
+
"database_schema must be a plain lowercase identifier (letters, digits, _)"
|
|
226
|
+
)
|
|
227
|
+
return v
|
|
228
|
+
|
|
229
|
+
@field_validator("github_api_base")
|
|
230
|
+
@classmethod
|
|
231
|
+
def _strip_slash(cls, v: str) -> str:
|
|
232
|
+
return v.rstrip("/")
|
|
233
|
+
|
|
234
|
+
@model_validator(mode="after")
|
|
235
|
+
def _check_auth_and_scope(self) -> Settings:
|
|
236
|
+
has_token = bool(self.github_token)
|
|
237
|
+
has_app = bool(self.github_app_id)
|
|
238
|
+
if has_token and has_app:
|
|
239
|
+
raise ValueError("set either GHA_GITHUB_TOKEN or GHA_GITHUB_APP_ID, not both")
|
|
240
|
+
if not has_token and not has_app:
|
|
241
|
+
raise ValueError("no GitHub credential: set GHA_GITHUB_TOKEN or GHA_GITHUB_APP_ID")
|
|
242
|
+
if has_app and not (self.github_app_private_key or self.github_app_private_key_file):
|
|
243
|
+
raise ValueError(
|
|
244
|
+
"GHA_GITHUB_APP_ID needs GHA_GITHUB_APP_PRIVATE_KEY or "
|
|
245
|
+
"GHA_GITHUB_APP_PRIVATE_KEY_FILE"
|
|
246
|
+
)
|
|
247
|
+
for org in self.org_list():
|
|
248
|
+
if not _OWNER_RE.match(org):
|
|
249
|
+
raise ValueError(f"orgs entry {org!r} is not a valid GitHub login")
|
|
250
|
+
for repo in self.repo_list():
|
|
251
|
+
if not _REPO_RE.match(repo):
|
|
252
|
+
raise ValueError(f"repos entry {repo!r} must look like owner/name")
|
|
253
|
+
if not self.org_list() and not self.repo_list():
|
|
254
|
+
raise ValueError("nothing to ingest: set GHA_ORGS and/or GHA_REPOS")
|
|
255
|
+
return self
|
|
256
|
+
|
|
257
|
+
# ------------------------------------------------------------------
|
|
258
|
+
# Helpers
|
|
259
|
+
# ------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def org_list(self) -> list[str]:
|
|
262
|
+
return _split_csv(self.orgs)
|
|
263
|
+
|
|
264
|
+
def repo_list(self) -> list[str]:
|
|
265
|
+
return _split_csv(self.repos)
|
|
266
|
+
|
|
267
|
+
def exclude_patterns(self) -> list[str]:
|
|
268
|
+
return _split_csv(self.exclude_repos)
|
|
269
|
+
|
|
270
|
+
def is_excluded(self, full_name: str) -> bool:
|
|
271
|
+
name = full_name.lower()
|
|
272
|
+
return any(fnmatch.fnmatchcase(name, pat.lower()) for pat in self.exclude_patterns())
|
|
273
|
+
|
|
274
|
+
def app_private_key_pem(self) -> str:
|
|
275
|
+
"""Return the PEM text, reading the file when configured.
|
|
276
|
+
|
|
277
|
+
Inline values often arrive with ``\\n`` escapes (Helm ``--set``,
|
|
278
|
+
some secret managers); normalize them back to real newlines.
|
|
279
|
+
"""
|
|
280
|
+
if self.github_app_private_key_file:
|
|
281
|
+
return Path(self.github_app_private_key_file).read_text(encoding="utf-8")
|
|
282
|
+
return self.github_app_private_key.replace("\\n", "\n")
|
|
283
|
+
|
|
284
|
+
def uses_app(self) -> bool:
|
|
285
|
+
return bool(self.github_app_id)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def load_settings(**overrides: Any) -> Settings:
|
|
289
|
+
"""Load settings, applying optional overrides on top of env."""
|
|
290
|
+
return Settings(**overrides)
|