imbi-plugin-github 2.29.4__tar.gz → 2.30.0__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 (27) hide show
  1. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/pyproject.toml +2 -2
  3. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/commits.py +107 -16
  4. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/tests/test_commits.py +156 -3
  5. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/.gitignore +0 -0
  6. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/README.md +0 -0
  7. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/moon.yml +0 -0
  8. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/README.md +0 -0
  9. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/__init__.py +0 -0
  10. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/_app_auth.py +0 -0
  11. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/_hosts.py +0 -0
  12. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/_repos.py +0 -0
  13. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/deployment.py +0 -0
  14. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/doctor.py +0 -0
  15. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/identity.py +0 -0
  16. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/lifecycle.py +0 -0
  17. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/plugin.py +0 -0
  18. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/pull_requests.py +0 -0
  19. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/py.typed +0 -0
  20. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/tests/__init__.py +0 -0
  21. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/tests/test_deployment.py +0 -0
  22. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/tests/test_doctor.py +0 -0
  23. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/tests/test_hosts.py +0 -0
  24. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/tests/test_identity.py +0 -0
  25. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/tests/test_lifecycle.py +0 -0
  26. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/tests/test_pull_requests.py +0 -0
  27. {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.30.0}/tests/test_repos.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: imbi-plugin-github
3
- Version: 2.29.4
3
+ Version: 2.30.0
4
4
  Summary: GitHub identity plugin for Imbi (github.com / GHEC / GHES)
5
5
  Author-email: "Gavin M. Roy" <gavinr@aweber.com>
6
6
  License-Expression: BSD-3-Clause
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.14
13
13
  Requires-Python: >=3.14
14
14
  Requires-Dist: httpx>=0.27
15
- Requires-Dist: imbi-common[databases]==2.29.4
15
+ Requires-Dist: imbi-common[databases]==2.30.0
16
16
  Requires-Dist: pydantic>=2
17
17
  Requires-Dist: pyjwt[crypto]>=2.8
18
18
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "imbi-plugin-github"
3
- version = "2.29.4"
3
+ version = "2.30.0"
4
4
  description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -18,7 +18,7 @@ classifiers = [
18
18
  ]
19
19
  dependencies = [
20
20
  "httpx>=0.27",
21
- "imbi-common[databases]==2.29.4",
21
+ "imbi-common[databases]==2.30.0",
22
22
  "pydantic>=2",
23
23
  "pyjwt[crypto]>=2.8",
24
24
  ]
@@ -85,11 +85,24 @@ _MAX_HISTORY_PAGES = 100
85
85
  # ``_MAX_HISTORY_PAGES``: this path exists to pick up the handful of
86
86
  # commits a release build pushed, not to stand in for a full backfill.
87
87
  _MAX_SINCE_PAGES = 10
88
- # CI status is hydrated with one ``/check-runs`` call per commit, which is
89
- # prohibitively expensive across a full-history backfill. Bound it to the
90
- # most-recent commits (the only ones whose CI is still meaningful and
91
- # unexpired); older commits keep the ``'unknown'`` default.
92
- _BACKFILL_CI_LIMIT = 25
88
+ # CI status is hydrated with one ``/check-runs`` call per commit (more
89
+ # when it pages), which is prohibitively expensive across a full-history
90
+ # backfill. Bound it to the most-recent commits; older commits keep the
91
+ # ``'unknown'`` default.
92
+ #
93
+ # 200 is where the gap actually closes -- the ``recent-commits`` endpoint
94
+ # caps ``limit`` at 200 and the deployments tab asks for exactly that
95
+ # (``COMMIT_WINDOW``), so hydrating past 200 cannot change what a reader
96
+ # sees. This deliberately stops at half of it: backfills are already
97
+ # slow, and every commit here costs at least one ``/check-runs`` call, so
98
+ # 25 -> 200 would have octupled the request count in one step. 100 buys
99
+ # most of the visible window at 4x rather than 8x; rows 100-199 keep the
100
+ # ``'unknown'`` default until this is raised or made configurable.
101
+ _BACKFILL_CI_LIMIT = 100
102
+ # Pages of ``/check-runs`` to walk per commit before giving up. 100 per
103
+ # page * 5 = 500 runs, well past the busiest observed commit (50), so the
104
+ # cap only bounds a pathological repo rather than shaping normal reads.
105
+ _MAX_CHECK_RUN_PAGES = 5
93
106
  # Page size for ``sync_new_commits``' last-resort walk, used when nothing
94
107
  # is stored *and* no ``since`` was supplied. Sized to cover a release
95
108
  # build's handful of pushed commits, not to approximate a backfill.
@@ -577,6 +590,29 @@ async def _fetch_recent_commits(
577
590
  return typing.cast('list[dict[str, typing.Any]]', resp.json())
578
591
 
579
592
 
593
+ async def _fetch_single_commit(
594
+ client: httpx.AsyncClient, sha: str, *, max_wait: float
595
+ ) -> list[dict[str, typing.Any]]:
596
+ """``GET /commits/{sha}`` as a one-item list, ``[]`` when absent.
597
+
598
+ For a delivery that names a head commit but carries no previous
599
+ head -- a ``workflow_run`` / ``check_suite`` style event rather than
600
+ a ``push`` -- the only commit the payload attests to is *sha*
601
+ itself. The response shape matches a ``commits[]`` entry from the
602
+ compare API, so :func:`_commit_record` maps it unchanged and the
603
+ caller's CI hydration and author resolution work on the list as-is.
604
+ """
605
+ resp = await _request(client, 'GET', f'/commits/{sha}', max_wait=max_wait)
606
+ if resp.status_code == 404:
607
+ LOGGER.warning(
608
+ 'github-commit-sync: commit %s not found; nothing to sync',
609
+ _short_sha(sha),
610
+ )
611
+ return []
612
+ resp.raise_for_status()
613
+ return [typing.cast('dict[str, typing.Any]', resp.json())]
614
+
615
+
580
616
  async def _ci_status(
581
617
  client: httpx.AsyncClient,
582
618
  sha: str,
@@ -595,22 +631,54 @@ async def _ci_status(
595
631
  non-200, a parse error, a network error, or a rate-limit pause -- CI
596
632
  status is best-effort metadata and must never fail the sync, mirroring
597
633
  the swallow-and-continue contract the rest of this module follows.
634
+
635
+ The whole run set has to be in hand before rolling up, so this pages
636
+ to the end rather than reading the first page: the endpoint defaults
637
+ to 30 per page and a busy commit carries far more (one observed
638
+ commit reports 50), which on a partial read drops whichever runs fall
639
+ past the cut -- silently turning a ``fail`` into a ``pass`` when the
640
+ failing run is the one left off.
598
641
  """
599
642
  if _checks_disabled(credentials, base, owner, repo):
600
643
  return 'unknown'
644
+ runs: list[dict[str, typing.Any]] = []
645
+ params: dict[str, str] = {'per_page': '100'}
601
646
  try:
602
- resp = await _request(
603
- client, 'GET', f'/commits/{sha}/check-runs', max_wait=max_wait
604
- )
605
- if resp.status_code == 403:
606
- _record_checks_disabled(credentials, base, owner, repo)
607
- return 'unknown'
608
- if resp.status_code != 200:
609
- return 'unknown'
610
- payload = typing.cast('dict[str, typing.Any]', resp.json())
647
+ for page in range(1, _MAX_CHECK_RUN_PAGES + 1):
648
+ resp = await _request(
649
+ client,
650
+ 'GET',
651
+ f'/commits/{sha}/check-runs',
652
+ params=params,
653
+ max_wait=max_wait,
654
+ )
655
+ if resp.status_code == 403:
656
+ _record_checks_disabled(credentials, base, owner, repo)
657
+ return 'unknown'
658
+ if resp.status_code != 200:
659
+ return 'unknown'
660
+ payload = typing.cast('dict[str, typing.Any]', resp.json())
661
+ runs.extend(payload.get('check_runs') or [])
662
+ next_url = _next_page_url(resp.headers.get('link'))
663
+ next_page = (
664
+ _query_param(next_url, 'page')
665
+ if next_url is not None
666
+ else None
667
+ )
668
+ if next_page is None:
669
+ break
670
+ params['page'] = next_page
671
+ if page == _MAX_CHECK_RUN_PAGES:
672
+ LOGGER.warning(
673
+ 'github-commit-sync truncated check-runs for %s at %d '
674
+ 'pages (%d runs); ci_status may be incomplete',
675
+ _short_sha(sha),
676
+ _MAX_CHECK_RUN_PAGES,
677
+ len(runs),
678
+ )
611
679
  except Exception: # noqa: BLE001 - CI status is best-effort metadata
612
680
  return 'unknown'
613
- return _check_runs_to_status(payload)
681
+ return _check_runs_to_status({'check_runs': runs})
614
682
 
615
683
 
616
684
  async def _hydrate_ci(
@@ -826,6 +894,11 @@ class SyncCommitsConfig(pydantic.BaseModel):
826
894
 
827
895
  Selectors resolve against the event context, so the push body lives
828
896
  under ``/payload`` (e.g. ``/payload/after``).
897
+
898
+ A rule bound to a non-push event points ``after_selector`` at that
899
+ body's head SHA and leaves ``before_selector`` alone: the default
900
+ ``/payload/before`` does not resolve there, which is what selects
901
+ :func:`sync_commits`' single-commit path.
829
902
  """
830
903
 
831
904
  before_selector: JsonPointer = pydantic.Field(
@@ -886,7 +959,21 @@ async def sync_commits(
886
959
  action_config: SyncCommitsConfig,
887
960
  event: object,
888
961
  ) -> None:
889
- """Sync the commits in a ``push`` delivery into the ``commits`` table.
962
+ """Sync the commits a delivery attests to into the ``commits`` table.
963
+
964
+ The range synced is chosen from what ``before_selector`` resolves to,
965
+ which separates three cases that must not be collapsed:
966
+
967
+ * **a usable SHA** -- an ordinary push; sync ``before...after``.
968
+ * **nothing at all** (the pointer does not resolve) -- the delivery
969
+ has no notion of a previous head, so the only commit it attests to
970
+ is ``after``; sync that one commit. This is the ``workflow_run``
971
+ shape, where falling through to the ``_last_known_sha`` compare
972
+ below yields an empty ``head...head`` range and silently syncs
973
+ nothing.
974
+ * **the zero SHA** -- a push that *does* carry a before, naming no
975
+ prior commit (a new branch). Heal from the last commit stored for
976
+ the project, else fall back to a bounded recent-history walk.
890
977
 
891
978
  Branch gating is the rule's CEL ``filter_expression``'s job; this
892
979
  action does not filter refs itself.
@@ -911,6 +998,10 @@ async def sync_commits(
911
998
  raw = await _fetch_compare_commits(
912
999
  client, before, after, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
913
1000
  )
1001
+ elif before is None:
1002
+ raw = await _fetch_single_commit(
1003
+ client, after, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
1004
+ )
914
1005
  else:
915
1006
  last = await _last_known_sha(ctx.project_id)
916
1007
  if last:
@@ -87,6 +87,27 @@ def _push(
87
87
  }
88
88
 
89
89
 
90
+ def _workflow_run(
91
+ *,
92
+ head: str = 'b' * 40,
93
+ branch: str = 'main',
94
+ full_name: str = 'octo/demo',
95
+ repo_url: str = 'https://api.github.com/repos/octo/demo',
96
+ ) -> dict[str, object]:
97
+ """A ``workflow_run`` body: a head SHA, and no ``before`` anywhere."""
98
+ return {
99
+ 'action': 'completed',
100
+ 'workflow': {'name': 'CI/CD'},
101
+ 'workflow_run': {
102
+ 'head_sha': head,
103
+ 'head_branch': branch,
104
+ 'event': 'push',
105
+ 'conclusion': 'success',
106
+ },
107
+ 'repository': {'full_name': full_name, 'url': repo_url},
108
+ }
109
+
110
+
90
111
  def _event(body: dict[str, typing.Any]) -> dict[str, typing.Any]:
91
112
  """Wrap a push body in the event context handlers now receive."""
92
113
  return {
@@ -132,6 +153,10 @@ def _check_runs_url(sha: str) -> str:
132
153
  return f'https://api.github.com/repos/octo/demo/commits/{sha}/check-runs'
133
154
 
134
155
 
156
+ # Any commit's ``/check-runs``, with or without the paging query string.
157
+ _CHECK_RUNS_RE = r'.+/commits/.+/check-runs(\?.*)?$'
158
+
159
+
135
160
  class ConfigTestCase(unittest.TestCase):
136
161
  def test_sync_commits_defaults(self) -> None:
137
162
  cfg = commits.SyncCommitsConfig()
@@ -223,7 +248,20 @@ class ApiBaseResolutionTestCase(unittest.TestCase):
223
248
  self.assertIsNone(self._base(ctx=_ctx(), repo_url='not-a-repo-url'))
224
249
 
225
250
 
251
+ _WORKFLOW_RUN_CONFIG = commits.SyncCommitsConfig(
252
+ after_selector='/payload/workflow_run/head_sha',
253
+ ref_selector='/payload/workflow_run/head_branch',
254
+ )
255
+
256
+
226
257
  class SyncCommitsTestCase(unittest.IsolatedAsyncioTestCase):
258
+ def setUp(self) -> None:
259
+ # Same process-wide /check-runs 403 cache SyncCommitsCiStatus
260
+ # clears: without this a 403 recorded by another test suppresses
261
+ # hydration here and the ci_status assertions below read
262
+ # 'unknown' depending on execution order.
263
+ deployment._CHECKS_DISABLED_TOKENS.clear()
264
+
227
265
  @respx.mock
228
266
  async def test_normal_compare_inserts_records(self) -> None:
229
267
  base, head = 'a' * 40, 'b' * 40
@@ -326,6 +364,119 @@ class SyncCommitsTestCase(unittest.IsolatedAsyncioTestCase):
326
364
  self.assertTrue(route.called)
327
365
  insert.assert_awaited_once()
328
366
 
367
+ @respx.mock
368
+ async def test_absent_before_syncs_the_single_head_commit(self) -> None:
369
+ head = 'b' * 40
370
+ route = respx.get(
371
+ f'https://api.github.com/repos/octo/demo/commits/{head}'
372
+ ).mock(return_value=httpx.Response(200, json=_commit(head)))
373
+ respx.get(_check_runs_url(head)).mock(
374
+ return_value=httpx.Response(200, json=_check_runs('success'))
375
+ )
376
+ with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
377
+ await commits.sync_commits(
378
+ ctx=_ctx(),
379
+ credentials=_CREDS,
380
+ external_identifier='',
381
+ action_config=_WORKFLOW_RUN_CONFIG,
382
+ event=_event(_workflow_run(head=head)),
383
+ )
384
+ self.assertTrue(route.called)
385
+ insert.assert_awaited_once()
386
+ table, records = _await_args(insert)
387
+ self.assertEqual('commits', table)
388
+ self.assertEqual(1, len(records))
389
+ self.assertEqual(head, records[0].sha)
390
+ self.assertEqual('main', records[0].ref)
391
+ self.assertEqual('pass', records[0].ci_status)
392
+
393
+ @respx.mock
394
+ async def test_absent_before_never_consults_last_known_sha(self) -> None:
395
+ """The single-commit path must not fall through to a compare.
396
+
397
+ Regression guard: resolving ``_last_known_sha`` yields the head
398
+ itself once a prior delivery stored it, and ``head...head`` is an
399
+ empty range -- the commit is then silently never refreshed.
400
+ """
401
+ head = 'b' * 40
402
+ respx.get(
403
+ f'https://api.github.com/repos/octo/demo/commits/{head}'
404
+ ).mock(return_value=httpx.Response(200, json=_commit(head)))
405
+ respx.get(_check_runs_url(head)).mock(
406
+ return_value=httpx.Response(200, json=_check_runs('success'))
407
+ )
408
+ with (
409
+ mock.patch(_QUERY, new=mock.AsyncMock()) as query,
410
+ mock.patch(_INSERT, new=mock.AsyncMock()) as insert,
411
+ ):
412
+ await commits.sync_commits(
413
+ ctx=_ctx(),
414
+ credentials=_CREDS,
415
+ external_identifier='',
416
+ action_config=_WORKFLOW_RUN_CONFIG,
417
+ event=_event(_workflow_run(head=head)),
418
+ )
419
+ query.assert_not_awaited()
420
+ insert.assert_awaited_once()
421
+
422
+ @respx.mock
423
+ async def test_ci_status_pages_past_the_first_check_runs_page(
424
+ self,
425
+ ) -> None:
426
+ """A failure past page 1 must still roll the commit up to ``fail``.
427
+
428
+ The endpoint defaults to 30 runs per page; reading only the first
429
+ page turns a failing commit into a passing one whenever the
430
+ failing run sorts past the cut.
431
+ """
432
+ head = 'b' * 40
433
+ url = _check_runs_url(head)
434
+ respx.get(
435
+ f'https://api.github.com/repos/octo/demo/commits/{head}'
436
+ ).mock(return_value=httpx.Response(200, json=_commit(head)))
437
+ route = respx.get(url).mock(
438
+ side_effect=[
439
+ httpx.Response(
440
+ 200,
441
+ json=_check_runs('success'),
442
+ headers={'link': f'<{url}?page=2>; rel="next"'},
443
+ ),
444
+ httpx.Response(200, json=_check_runs('failure')),
445
+ ]
446
+ )
447
+ with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
448
+ await commits.sync_commits(
449
+ ctx=_ctx(),
450
+ credentials=_CREDS,
451
+ external_identifier='',
452
+ action_config=_WORKFLOW_RUN_CONFIG,
453
+ event=_event(_workflow_run(head=head)),
454
+ )
455
+ self.assertEqual(2, route.call_count)
456
+ self.assertEqual(
457
+ '100', route.calls[0].request.url.params.get('per_page')
458
+ )
459
+ insert.assert_awaited_once()
460
+ _, records = _await_args(insert)
461
+ self.assertEqual('fail', records[0].ci_status)
462
+
463
+ @respx.mock
464
+ async def test_absent_before_unknown_commit_inserts_nothing(self) -> None:
465
+ head = 'b' * 40
466
+ respx.get(
467
+ f'https://api.github.com/repos/octo/demo/commits/{head}'
468
+ ).mock(return_value=httpx.Response(404, json={}))
469
+ with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
470
+ with self.assertLogs(commits.LOGGER, level='WARNING'):
471
+ await commits.sync_commits(
472
+ ctx=_ctx(),
473
+ credentials=_CREDS,
474
+ external_identifier='',
475
+ action_config=_WORKFLOW_RUN_CONFIG,
476
+ event=_event(_workflow_run(head=head)),
477
+ )
478
+ insert.assert_not_awaited()
479
+
329
480
  @respx.mock
330
481
  async def test_compare_pagination_collects_all_pages(self) -> None:
331
482
  base, head = 'a' * 40, 'b' * 40
@@ -484,7 +635,7 @@ class SyncCommitsCiStatusTestCase(unittest.IsolatedAsyncioTestCase):
484
635
  @respx.mock
485
636
  async def test_403_degrades_and_caches(self) -> None:
486
637
  self._mock_compare('c' * 40, 'd' * 40)
487
- route = respx.get(url__regex=r'.+/commits/.+/check-runs$').mock(
638
+ route = respx.get(url__regex=_CHECK_RUNS_RE).mock(
488
639
  return_value=httpx.Response(403)
489
640
  )
490
641
  records = await self._run()
@@ -1307,7 +1458,7 @@ class SyncAllHistoryTestCase(unittest.IsolatedAsyncioTestCase):
1307
1458
  respx.get(f'{self._REPO}/git/matching-refs/tags').mock(
1308
1459
  return_value=httpx.Response(200, json=[])
1309
1460
  )
1310
- ci_route = respx.get(url__regex=r'.+/commits/.+/check-runs$').mock(
1461
+ ci_route = respx.get(url__regex=_CHECK_RUNS_RE).mock(
1311
1462
  return_value=httpx.Response(200, json=_check_runs('success'))
1312
1463
  )
1313
1464
  with mock.patch.object(commits, '_BACKFILL_CI_LIMIT', 1):
@@ -1894,7 +2045,9 @@ class SyncNewCommitsTestCase(unittest.IsolatedAsyncioTestCase):
1894
2045
  self.assertEqual(1, written)
1895
2046
  params = history.calls.last.request.url.params
1896
2047
  self.assertNotIn('since', params)
1897
- self.assertEqual(str(commits._BACKFILL_CI_LIMIT), params['per_page'])
2048
+ self.assertEqual(
2049
+ str(commits._RECENT_FALLBACK_COMMITS), params['per_page']
2050
+ )
1898
2051
 
1899
2052
  @respx.mock
1900
2053
  async def test_uncomparable_stored_sha_falls_back(self) -> None: