github-actions-ingester 0.2.1__tar.gz → 0.2.2__tar.gz

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.
Files changed (32) hide show
  1. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/CHANGELOG.md +14 -0
  2. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/PKG-INFO +1 -1
  3. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/pyproject.toml +1 -1
  4. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/src/github_actions_ingester/__init__.py +1 -1
  5. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/src/github_actions_ingester/__main__.py +12 -1
  6. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/src/github_actions_ingester/collector.py +22 -7
  7. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/src/github_actions_ingester/github.py +4 -0
  8. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/tests/test_collector.py +31 -0
  9. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/tests/test_github.py +18 -0
  10. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/.gitignore +0 -0
  11. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/LICENSE +0 -0
  12. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/README.md +0 -0
  13. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/src/github_actions_ingester/app_manifest.py +0 -0
  14. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/src/github_actions_ingester/config.py +0 -0
  15. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/src/github_actions_ingester/metrics.py +0 -0
  16. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/src/github_actions_ingester/migrations/0001_initial.sql +0 -0
  17. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/src/github_actions_ingester/ratelimit.py +0 -0
  18. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/src/github_actions_ingester/server.py +0 -0
  19. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/src/github_actions_ingester/store.py +0 -0
  20. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/src/github_actions_ingester/workflow_schedule.py +0 -0
  21. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/tests/__init__.py +0 -0
  22. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/tests/conftest.py +0 -0
  23. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/tests/helpers.py +0 -0
  24. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/tests/test_app_manifest.py +0 -0
  25. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/tests/test_cli.py +0 -0
  26. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/tests/test_config.py +0 -0
  27. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/tests/test_dashboard.py +0 -0
  28. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/tests/test_metrics.py +0 -0
  29. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/tests/test_ratelimit.py +0 -0
  30. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/tests/test_server.py +0 -0
  31. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/tests/test_store.py +0 -0
  32. {github_actions_ingester-0.2.1 → github_actions_ingester-0.2.2}/tests/test_workflow_schedule.py +0 -0
@@ -4,6 +4,20 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
5
5
  adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.2.2] — 2026-09-05
8
+
9
+ ### Fixed
10
+ - The GitHub rate-limit gauges were refreshed once per repository, so during
11
+ a long first backfill of a busy repository they could lag by twenty
12
+ minutes while the real budget ran out. They now follow every response,
13
+ as the README already said they did.
14
+ - `runs_upserted_total` and `jobs_upserted_total` move as rows are written
15
+ instead of once when the repository finishes.
16
+
17
+ ### Added
18
+ - `jobs.progress` log line every 250 runs during the jobs phase, which was
19
+ silent for the whole duration of a large backfill.
20
+
7
21
  ## [0.2.1] — 2026-09-05
8
22
 
9
23
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: github-actions-ingester
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Standalone GitHub Actions ingester — pulls workflows, runs and jobs from the GitHub API into PostgreSQL for Grafana dashboards, FinOps and alerting. Prometheus /metrics for ingester health and scheduled-workflow liveness.
5
5
  Project-URL: Homepage, https://github.com/danielgines/github-actions-ingester
6
6
  Project-URL: Repository, https://github.com/danielgines/github-actions-ingester
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "github-actions-ingester"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Standalone GitHub Actions ingester — pulls workflows, runs and jobs from the GitHub API into PostgreSQL for Grafana dashboards, FinOps and alerting. Prometheus /metrics for ingester health and scheduled-workflow liveness."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -6,4 +6,4 @@ its own, and exposes Prometheus metrics about the ingestion itself and
6
6
  about the liveness of scheduled workflows.
7
7
  """
8
8
 
9
- __version__ = "0.2.1"
9
+ __version__ = "0.2.2"
@@ -26,7 +26,7 @@ from . import __version__
26
26
  from .app_manifest import convert_code, manifest_form_url, manifest_json, write_private_key
27
27
  from .collector import Collector
28
28
  from .config import Settings, load_settings
29
- from .github import AppAuth, GitHubAPIError, GitHubClient, TokenAuth
29
+ from .github import AppAuth, GitHubAPIError, GitHubClient, RateLimitState, TokenAuth
30
30
  from .metrics import Metrics
31
31
  from .ratelimit import RateLimiter
32
32
  from .server import MetricsServer
@@ -77,6 +77,16 @@ def _build_client(settings: Settings, metrics: Metrics | None = None) -> GitHubC
77
77
  if metrics is not None:
78
78
  metrics.github_requests_total.labels(status=str(status)).inc()
79
79
 
80
+ # Refresh the budget gauges on every response, not once per repository:
81
+ # a busy repository keeps the collector inside one ingest_repository()
82
+ # call for many minutes, and an alert on the remaining budget must not
83
+ # wait for it to finish.
84
+ def on_rate_limit(rl: RateLimitState) -> None:
85
+ if metrics is not None:
86
+ metrics.github_rate_limit_remaining.set(rl.remaining)
87
+ metrics.github_rate_limit_limit.set(rl.limit)
88
+ metrics.github_rate_limit_reset_timestamp_seconds.set(rl.reset_at)
89
+
80
90
  return GitHubClient(
81
91
  auth=auth,
82
92
  base_url=settings.github_api_base,
@@ -85,6 +95,7 @@ def _build_client(settings: Settings, metrics: Metrics | None = None) -> GitHubC
85
95
  min_remaining=settings.api_min_remaining,
86
96
  max_retries=settings.api_max_retries,
87
97
  on_request=on_request,
98
+ on_rate_limit=on_rate_limit,
88
99
  )
89
100
 
90
101
 
@@ -56,6 +56,7 @@ CONCLUSIONS = (
56
56
  "stale",
57
57
  "startup_failure",
58
58
  )
59
+ _JOBS_PROGRESS_EVERY = 250
59
60
  _RUN_BATCH = 500
60
61
 
61
62
 
@@ -198,9 +199,10 @@ class Collector:
198
199
 
199
200
  written = 0
200
201
  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)
202
+ n = self._store.upsert_runs(batch)
203
+ written += n
204
+ if n:
205
+ self._metrics.runs_upserted_total.labels(repository=repo.full_name).inc(n)
204
206
 
205
207
  # Open runs that fell out of the window: refresh them individually.
206
208
  stale = self._store.open_runs_before(repo.id, since, s.max_open_run_refresh)
@@ -213,8 +215,12 @@ class Collector:
213
215
  raise
214
216
  self._store.upsert_runs([run])
215
217
 
218
+ # One request per run: on a first backfill of a busy repository this
219
+ # loop runs for many minutes, so it reports progress on the way and
220
+ # moves the counters as it goes instead of once at the end.
216
221
  jobs_written = 0
217
- for run_id in self._store.runs_needing_jobs(repo.id):
222
+ pending = self._store.runs_needing_jobs(repo.id)
223
+ for done, run_id in enumerate(pending, start=1):
218
224
  try:
219
225
  jobs = list(self._client.list_jobs(repo, run_id, s.jobs_filter))
220
226
  except GitHubAPIError as exc:
@@ -223,9 +229,18 @@ class Collector:
223
229
  self._store.upsert_jobs(run_id, [])
224
230
  continue
225
231
  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)
232
+ n = self._store.upsert_jobs(run_id, jobs)
233
+ jobs_written += n
234
+ if n:
235
+ self._metrics.jobs_upserted_total.labels(repository=repo.full_name).inc(n)
236
+ if done % _JOBS_PROGRESS_EVERY == 0 and done < len(pending):
237
+ logger.info(
238
+ "jobs.progress",
239
+ repo=repo.full_name,
240
+ runs_done=done,
241
+ runs_total=len(pending),
242
+ jobs=jobs_written,
243
+ )
229
244
 
230
245
  self._store.set_cursor(repo.id, now, written)
231
246
  logger.info(
@@ -338,6 +338,7 @@ class GitHubClient:
338
338
  min_remaining: int = 200,
339
339
  max_retries: int = 4,
340
340
  on_request: Callable[[int], None] | None = None,
341
+ on_rate_limit: Callable[[RateLimitState], None] | None = None,
341
342
  sleep: Callable[[float], None] = time.sleep,
342
343
  clock: Callable[[], float] = time.time,
343
344
  ) -> None:
@@ -355,6 +356,7 @@ class GitHubClient:
355
356
  self._min_remaining = min_remaining
356
357
  self._max_retries = max_retries
357
358
  self._on_request = on_request
359
+ self._on_rate_limit = on_rate_limit
358
360
  self._sleep = sleep
359
361
  self._clock = clock
360
362
  self.rate_limit = RateLimitState()
@@ -404,6 +406,8 @@ class GitHubClient:
404
406
  self._sleep(min(2**attempt, 30))
405
407
  continue
406
408
  self.rate_limit.update(resp.headers)
409
+ if self._on_rate_limit is not None:
410
+ self._on_rate_limit(self.rate_limit)
407
411
  if self._on_request is not None:
408
412
  self._on_request(resp.status_code)
409
413
  if resp.status_code < 400:
@@ -195,6 +195,37 @@ def test_first_cycle_backfills_everything(migrated_store: Store, clock: list[dat
195
195
  assert _gauge(m, "gha_scheduled_workflow_last_conclusion", **labels, conclusion="failure") == 0
196
196
 
197
197
 
198
+ def test_jobs_phase_reports_progress_and_moves_counters(
199
+ migrated_store: Store, clock: list[datetime], monkeypatch: pytest.MonkeyPatch
200
+ ) -> None:
201
+ """A long jobs phase logs every N runs and increments the counter per run,
202
+ so a stalled backfill is visible before ingest_repository() returns."""
203
+ import github_actions_ingester.collector as mod
204
+
205
+ gh = FakeGitHub()
206
+ gh.runs[1] = [run(300 + i, 1, 10) for i in range(5)]
207
+ for i in range(5):
208
+ gh.jobs[300 + i] = [job(3000 + i, 300 + i, 1)]
209
+ monkeypatch.setattr(mod, "_JOBS_PROGRESS_EVERY", 2)
210
+ events: list[dict[str, Any]] = []
211
+ seen_counter: list[float | None] = []
212
+ c, m = make(migrated_store, gh, clock)
213
+
214
+ def spy(event: str, **kw: Any) -> None:
215
+ if event == "jobs.progress":
216
+ events.append(kw)
217
+ seen_counter.append(
218
+ _gauge(m, "gha_ingester_jobs_upserted_total", repository="acme/web")
219
+ )
220
+
221
+ monkeypatch.setattr(mod.logger, "info", spy)
222
+ c.run_cycle()
223
+ assert [(e["runs_done"], e["runs_total"]) for e in events] == [(2, 5), (4, 5)]
224
+ # The counter had already moved when the progress line was written.
225
+ assert seen_counter == [2.0, 4.0]
226
+ assert _gauge(m, "gha_ingester_jobs_upserted_total", repository="acme/web") == 5.0
227
+
228
+
198
229
  def test_second_cycle_is_incremental(migrated_store: Store, clock: list[datetime]) -> None:
199
230
  gh = FakeGitHub()
200
231
  c, _ = make(migrated_store, gh, clock)
@@ -330,6 +330,24 @@ def test_on_request_hook_receives_status(httpx_mock: HTTPXMock) -> None:
330
330
  assert seen == [404]
331
331
 
332
332
 
333
+ def test_on_rate_limit_hook_fires_on_every_response(httpx_mock: HTTPXMock) -> None:
334
+ seen: list[int] = []
335
+ httpx_mock.add_response(
336
+ url=f"{API}/a",
337
+ json={},
338
+ headers={"X-RateLimit-Limit": "5000", "X-RateLimit-Remaining": "4000"},
339
+ )
340
+ httpx_mock.add_response(
341
+ url=f"{API}/b",
342
+ json={},
343
+ headers={"X-RateLimit-Limit": "5000", "X-RateLimit-Remaining": "3999"},
344
+ )
345
+ c = _client(on_rate_limit=lambda rl: seen.append(rl.remaining))
346
+ c.get("/a")
347
+ c.get("/b")
348
+ assert seen == [4000, 3999]
349
+
350
+
333
351
  # -- pagination / listings ---------------------------------------------------------
334
352
 
335
353