github-actions-ingester 0.2.0__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.0 → github_actions_ingester-0.2.2}/CHANGELOG.md +27 -0
  2. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/PKG-INFO +6 -2
  3. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/README.md +5 -1
  4. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/pyproject.toml +1 -1
  5. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/src/github_actions_ingester/__init__.py +1 -1
  6. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/src/github_actions_ingester/__main__.py +12 -1
  7. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/src/github_actions_ingester/collector.py +22 -7
  8. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/src/github_actions_ingester/github.py +4 -0
  9. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/src/github_actions_ingester/store.py +37 -0
  10. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/tests/test_collector.py +31 -0
  11. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/tests/test_github.py +18 -0
  12. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/tests/test_store.py +6 -1
  13. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/.gitignore +0 -0
  14. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/LICENSE +0 -0
  15. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/src/github_actions_ingester/app_manifest.py +0 -0
  16. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/src/github_actions_ingester/config.py +0 -0
  17. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/src/github_actions_ingester/metrics.py +0 -0
  18. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/src/github_actions_ingester/migrations/0001_initial.sql +0 -0
  19. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/src/github_actions_ingester/ratelimit.py +0 -0
  20. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/src/github_actions_ingester/server.py +0 -0
  21. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/src/github_actions_ingester/workflow_schedule.py +0 -0
  22. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/tests/__init__.py +0 -0
  23. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/tests/conftest.py +0 -0
  24. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/tests/helpers.py +0 -0
  25. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/tests/test_app_manifest.py +0 -0
  26. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/tests/test_cli.py +0 -0
  27. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/tests/test_config.py +0 -0
  28. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/tests/test_dashboard.py +0 -0
  29. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/tests/test_metrics.py +0 -0
  30. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/tests/test_ratelimit.py +0 -0
  31. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/tests/test_server.py +0 -0
  32. {github_actions_ingester-0.2.0 → github_actions_ingester-0.2.2}/tests/test_workflow_schedule.py +0 -0
@@ -4,6 +4,33 @@ 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
+
21
+ ## [0.2.1] — 2026-09-05
22
+
23
+ ### Added
24
+ - Chart: `deploymentAnnotations`, for a Reloader annotation on the
25
+ Deployment.
26
+
27
+ ### Fixed
28
+ - A read role with a name different from the schema could not resolve the
29
+ views by bare name, and the Grafana PostgreSQL datasource has no
30
+ search_path setting. After granting read access the ingester now sets
31
+ the database's default `search_path` to the schema (needs the ingester
32
+ role to own the database; skipped with a warning otherwise).
33
+
7
34
  ## [0.2.0] — 2026-09-05
8
35
 
9
36
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: github-actions-ingester
3
- Version: 0.2.0
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
@@ -162,7 +162,11 @@ table and view after each migration run, plus a default privilege so
162
162
  tables added by later migrations are readable too. Point Grafana at the
163
163
  database with one of those roles and it never needs the ingester's
164
164
  credentials. The roles themselves are created by whoever manages the
165
- database (the ingester never runs `CREATE ROLE`).
165
+ database (the ingester never runs `CREATE ROLE`). In the same step the
166
+ ingester sets the database's default `search_path` to the schema, so a
167
+ Grafana datasource (which cannot send one) resolves the views by bare
168
+ name; that statement needs the ingester's role to own the database, and
169
+ is skipped with a warning otherwise.
166
170
 
167
171
  ### Tables and views
168
172
 
@@ -99,7 +99,11 @@ table and view after each migration run, plus a default privilege so
99
99
  tables added by later migrations are readable too. Point Grafana at the
100
100
  database with one of those roles and it never needs the ingester's
101
101
  credentials. The roles themselves are created by whoever manages the
102
- database (the ingester never runs `CREATE ROLE`).
102
+ database (the ingester never runs `CREATE ROLE`). In the same step the
103
+ ingester sets the database's default `search_path` to the schema, so a
104
+ Grafana datasource (which cannot send one) resolves the views by bare
105
+ name; that statement needs the ingester's role to own the database, and
106
+ is skipped with a warning otherwise.
103
107
 
104
108
  ### Tables and views
105
109
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "github-actions-ingester"
3
- version = "0.2.0"
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.0"
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:
@@ -200,8 +200,45 @@ class Store:
200
200
  )
201
201
  granted.append(role)
202
202
  logger.info("store.read_access_granted", roles=granted, schema=self._schema)
203
+ self._set_database_search_path()
203
204
  return granted
204
205
 
206
+ def _set_database_search_path(self) -> None:
207
+ """Make the schema resolve by bare name for every session on the database.
208
+
209
+ The ingester sets ``search_path`` on its own connections, and a role
210
+ named like the schema gets it for free (``"$user"`` is first in the
211
+ default path), which is why the Compose example works without this.
212
+ A read role with a different name (``grafana`` reading schema
213
+ ``gha``) resolves ``minion_workflow_runs`` to nothing, and the
214
+ Grafana PostgreSQL datasource has no search_path setting.
215
+
216
+ ``ALTER DATABASE ... SET`` needs the database owner. When the ingester
217
+ is not the owner this logs and moves on: the grants above still hold,
218
+ and the operator can run the statement once by hand.
219
+ """
220
+ conn = self.connect()
221
+ try:
222
+ with conn.transaction(), conn.cursor() as cur:
223
+ cur.execute("SELECT current_database() AS db")
224
+ row = cur.fetchone()
225
+ database = row["db"] if row else ""
226
+ cur.execute(
227
+ sql.SQL("ALTER DATABASE {} SET search_path TO {}, public").format(
228
+ sql.Identifier(database), sql.Identifier(self._schema)
229
+ )
230
+ )
231
+ except psycopg.errors.InsufficientPrivilege:
232
+ conn.rollback()
233
+ logger.warning(
234
+ "store.search_path_not_set",
235
+ schema=self._schema,
236
+ hint="ingester role does not own the database; run "
237
+ "ALTER DATABASE <db> SET search_path TO <schema>, public as the owner",
238
+ )
239
+ return
240
+ logger.info("store.search_path_set", schema=self._schema)
241
+
205
242
  def schema_version(self) -> int | None:
206
243
  """Highest applied migration, or None when the schema was never bootstrapped."""
207
244
  conn = self.connect()
@@ -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
 
@@ -250,7 +250,10 @@ def test_grant_read_access_lets_a_role_read_views_and_future_tables(
250
250
  schema = migrated_store.schema
251
251
  reader_url = re.sub(r"//[^@]*@", f"//{role}:reader@", _with_userinfo(database_url))
252
252
  with psycopg.connect(reader_url, autocommit=True) as reader:
253
- reader.execute(f"SET search_path TO {schema}")
253
+ # No SET search_path on purpose: Grafana cannot send one, so the
254
+ # database-level default the ingester sets must resolve the views.
255
+ path = reader.execute("SHOW search_path").fetchone()[0]
256
+ assert path.split(",")[0].strip().strip('"') == schema
254
257
  assert reader.execute("SELECT count(*) FROM minion_workflow_runs").fetchone()[0] == 0
255
258
  assert reader.execute("SELECT count(*) FROM workflow_jobs").fetchone()[0] == 0
256
259
  assert reader.execute("SELECT count(*) FROM later_migration").fetchone()[0] == 0
@@ -262,6 +265,8 @@ def test_grant_read_access_lets_a_role_read_views_and_future_tables(
262
265
  with psycopg.connect(database_url, autocommit=True) as admin:
263
266
  admin.execute(f"DROP OWNED BY {role}")
264
267
  admin.execute(f"DROP ROLE {role}")
268
+ db = admin.execute("SELECT current_database()").fetchone()[0]
269
+ admin.execute(f'ALTER DATABASE "{db}" RESET search_path')
265
270
 
266
271
 
267
272
  def test_grant_read_access_requires_an_existing_role(migrated_store: Store) -> None: