imbi-plugin-github 2.29.3__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.3 → imbi_plugin_github-2.30.0}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/pyproject.toml +2 -2
  3. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/commits.py +107 -16
  4. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/deployment.py +46 -3
  5. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/tests/test_commits.py +189 -3
  6. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/tests/test_deployment.py +147 -1
  7. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/.gitignore +0 -0
  8. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/README.md +0 -0
  9. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/moon.yml +0 -0
  10. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/README.md +0 -0
  11. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/__init__.py +0 -0
  12. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/_app_auth.py +0 -0
  13. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/_hosts.py +0 -0
  14. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/_repos.py +0 -0
  15. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/doctor.py +0 -0
  16. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/identity.py +0 -0
  17. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/lifecycle.py +0 -0
  18. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/plugin.py +0 -0
  19. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/pull_requests.py +0 -0
  20. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/py.typed +0 -0
  21. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/tests/__init__.py +0 -0
  22. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/tests/test_doctor.py +0 -0
  23. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/tests/test_hosts.py +0 -0
  24. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/tests/test_identity.py +0 -0
  25. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/tests/test_lifecycle.py +0 -0
  26. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/tests/test_pull_requests.py +0 -0
  27. {imbi_plugin_github-2.29.3 → 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.3
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.3
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.3"
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.3",
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:
@@ -506,6 +506,44 @@ def _commit_from_payload(payload: dict[str, typing.Any]) -> Commit:
506
506
  )
507
507
 
508
508
 
509
+ def _latest_runs(
510
+ raw_runs: list[dict[str, typing.Any]],
511
+ ) -> list[dict[str, typing.Any]]:
512
+ """Collapse ``check_runs`` to the current run for each check.
513
+
514
+ GitHub keeps every historical run for a commit in the
515
+ ``/commits/{sha}/check-runs`` payload, so a workflow that failed and
516
+ was re-run to green reports both. Rolling up that flat set pins the
517
+ commit at ``fail`` forever -- no re-run can ever clear it.
518
+
519
+ ``filter=latest`` does not help: it de-duplicates re-run *attempts*
520
+ within a single check suite, and a re-run dispatched as a fresh
521
+ workflow run gets a suite (and a check run) of its own. The
522
+ de-duplication has to happen here.
523
+
524
+ Runs are keyed on ``(app.id, name)`` rather than ``name`` alone so
525
+ two apps publishing a same-named check stay distinct, and ordered by
526
+ check-run ``id``, which is monotonic at creation. Timestamps are not
527
+ usable as a sort key -- skipped runs report ``completed_at`` earlier
528
+ than ``started_at``. A run carrying no integer ``id`` cannot be
529
+ ordered against anything, so it is passed through untouched rather
530
+ than being discarded on a guess.
531
+ """
532
+ latest: dict[tuple[str, str], tuple[int, dict[str, typing.Any]]] = {}
533
+ unordered: list[dict[str, typing.Any]] = []
534
+ for run in raw_runs:
535
+ run_id = run.get('id')
536
+ if isinstance(run_id, bool) or not isinstance(run_id, int):
537
+ unordered.append(run)
538
+ continue
539
+ app: dict[str, typing.Any] = run.get('app') or {}
540
+ key = (str(app.get('id') or ''), str(run.get('name') or ''))
541
+ current = latest.get(key)
542
+ if current is None or run_id > current[0]:
543
+ latest[key] = (run_id, run)
544
+ return [run for _, run in latest.values()] + unordered
545
+
546
+
509
547
  def _check_runs_to_status(
510
548
  payload: dict[str, typing.Any],
511
549
  ) -> typing.Literal['pass', 'fail', 'warn', 'unknown']:
@@ -513,12 +551,17 @@ def _check_runs_to_status(
513
551
  raw_runs: list[dict[str, typing.Any]] = payload.get('check_runs') or []
514
552
  if not raw_runs:
515
553
  return 'unknown'
554
+ runs = _latest_runs(raw_runs)
516
555
  # Don't roll up while any run is still in flight — a mix of one
517
556
  # ``success`` and one ``in_progress`` would otherwise surface as
518
- # ``pass`` because the in-progress conclusion is ``None``.
519
- if any(str(run.get('status') or '') != 'completed' for run in raw_runs):
557
+ # ``pass`` because the in-progress conclusion is ``None``. Scan only
558
+ # the surviving runs: a *superseded* run left non-terminal (a suite
559
+ # GitHub cancelled mid-flight, which happens in the wild) would
560
+ # otherwise pin the commit at ``unknown`` just as permanently as the
561
+ # stale ``failure`` pinned it at ``fail``.
562
+ if any(str(run.get('status') or '') != 'completed' for run in runs):
520
563
  return 'unknown'
521
- conclusions = {str(run.get('conclusion') or '') for run in raw_runs}
564
+ conclusions = {str(run.get('conclusion') or '') for run in runs}
522
565
  failed = {'failure', 'timed_out', 'action_required'}
523
566
  if conclusions & failed:
524
567
  return 'fail'
@@ -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
@@ -448,10 +599,43 @@ class SyncCommitsCiStatusTestCase(unittest.IsolatedAsyncioTestCase):
448
599
  self.assertEqual('pass', records[0].ci_status)
449
600
  self.assertEqual('fail', records[1].ci_status)
450
601
 
602
+ @respx.mock
603
+ async def test_rerun_to_green_syncs_as_pass(self) -> None:
604
+ # The sync path shares the deployment plugin's rollup, so it
605
+ # inherits the re-run de-duplication -- without it the API and
606
+ # the ClickHouse table agree with each other while both report a
607
+ # green commit as 'fail' (issue #282).
608
+ self._mock_compare('c' * 40)
609
+ respx.get(_check_runs_url('c' * 40)).mock(
610
+ return_value=httpx.Response(
611
+ 200,
612
+ json={
613
+ 'check_runs': [
614
+ {
615
+ 'id': 1,
616
+ 'name': 'acceptance',
617
+ 'status': 'completed',
618
+ 'conclusion': 'failure',
619
+ 'app': {'id': 7},
620
+ },
621
+ {
622
+ 'id': 2,
623
+ 'name': 'acceptance',
624
+ 'status': 'completed',
625
+ 'conclusion': 'success',
626
+ 'app': {'id': 7},
627
+ },
628
+ ]
629
+ },
630
+ )
631
+ )
632
+ records = await self._run()
633
+ self.assertEqual('pass', records[0].ci_status)
634
+
451
635
  @respx.mock
452
636
  async def test_403_degrades_and_caches(self) -> None:
453
637
  self._mock_compare('c' * 40, 'd' * 40)
454
- route = respx.get(url__regex=r'.+/commits/.+/check-runs$').mock(
638
+ route = respx.get(url__regex=_CHECK_RUNS_RE).mock(
455
639
  return_value=httpx.Response(403)
456
640
  )
457
641
  records = await self._run()
@@ -1274,7 +1458,7 @@ class SyncAllHistoryTestCase(unittest.IsolatedAsyncioTestCase):
1274
1458
  respx.get(f'{self._REPO}/git/matching-refs/tags').mock(
1275
1459
  return_value=httpx.Response(200, json=[])
1276
1460
  )
1277
- ci_route = respx.get(url__regex=r'.+/commits/.+/check-runs$').mock(
1461
+ ci_route = respx.get(url__regex=_CHECK_RUNS_RE).mock(
1278
1462
  return_value=httpx.Response(200, json=_check_runs('success'))
1279
1463
  )
1280
1464
  with mock.patch.object(commits, '_BACKFILL_CI_LIMIT', 1):
@@ -1861,7 +2045,9 @@ class SyncNewCommitsTestCase(unittest.IsolatedAsyncioTestCase):
1861
2045
  self.assertEqual(1, written)
1862
2046
  params = history.calls.last.request.url.params
1863
2047
  self.assertNotIn('since', params)
1864
- self.assertEqual(str(commits._BACKFILL_CI_LIMIT), params['per_page'])
2048
+ self.assertEqual(
2049
+ str(commits._RECENT_FALLBACK_COMMITS), params['per_page']
2050
+ )
1865
2051
 
1866
2052
  @respx.mock
1867
2053
  async def test_uncomparable_stored_sha_falls_back(self) -> None:
@@ -19,6 +19,7 @@ from imbi.plugins.github.deployment import (
19
19
  GitHubDeployment,
20
20
  _active_scan_limit,
21
21
  _artifact_status,
22
+ _check_runs_to_status,
22
23
  _mainline_branches,
23
24
  _repo_root_from_redirect,
24
25
  )
@@ -565,10 +566,17 @@ class CommitsTestCase(unittest.IsolatedAsyncioTestCase):
565
566
  json={
566
567
  'check_runs': [
567
568
  {
569
+ 'id': 1,
570
+ 'name': 'unit',
568
571
  'status': 'completed',
569
572
  'conclusion': 'success',
570
573
  },
571
- {'status': 'in_progress', 'conclusion': None},
574
+ {
575
+ 'id': 2,
576
+ 'name': 'acceptance',
577
+ 'status': 'in_progress',
578
+ 'conclusion': None,
579
+ },
572
580
  ]
573
581
  },
574
582
  )
@@ -2569,6 +2577,144 @@ class CheckStatusTestCase(unittest.IsolatedAsyncioTestCase):
2569
2577
  self.assertEqual(status, 'unknown')
2570
2578
 
2571
2579
 
2580
+ def _run(
2581
+ run_id: int,
2582
+ conclusion: str | None,
2583
+ *,
2584
+ name: str = 'run / Run acceptance tests (testing)',
2585
+ status: str = 'completed',
2586
+ app_id: int = 15368,
2587
+ ) -> dict[str, object]:
2588
+ """One entry of a ``/check-runs`` payload."""
2589
+ return {
2590
+ 'id': run_id,
2591
+ 'name': name,
2592
+ 'status': status,
2593
+ 'conclusion': conclusion,
2594
+ 'app': {'id': app_id},
2595
+ }
2596
+
2597
+
2598
+ class CheckRunsRollupTestCase(unittest.TestCase):
2599
+ """``_check_runs_to_status`` must judge a commit on its *current* runs.
2600
+
2601
+ GitHub returns every historical run for a commit, so a check that
2602
+ failed and was re-run to green appears twice. Rolling up the flat
2603
+ set pinned such a commit at ``fail`` permanently -- see issue #282.
2604
+ """
2605
+
2606
+ def test_superseded_failure_does_not_pin_fail(self) -> None:
2607
+ # The reported case: two failures and a later success, all
2608
+ # carrying the same check name.
2609
+ status = _check_runs_to_status(
2610
+ {
2611
+ 'check_runs': [
2612
+ _run(1, 'failure'),
2613
+ _run(2, 'failure'),
2614
+ _run(3, 'success'),
2615
+ _run(4, 'success', name='unit tests'),
2616
+ _run(5, 'skipped', name='deploy'),
2617
+ ]
2618
+ }
2619
+ )
2620
+ self.assertEqual(status, 'pass')
2621
+
2622
+ def test_latest_failure_still_fails(self) -> None:
2623
+ # Don't over-correct: a check that went green and then red is
2624
+ # red. Payload order is deliberately not id order.
2625
+ status = _check_runs_to_status(
2626
+ {'check_runs': [_run(9, 'failure'), _run(3, 'success')]}
2627
+ )
2628
+ self.assertEqual(status, 'fail')
2629
+
2630
+ def test_same_name_from_two_apps_is_not_collapsed(self) -> None:
2631
+ # Two apps publishing a same-named check are distinct checks;
2632
+ # collapsing them would hide the failing one.
2633
+ status = _check_runs_to_status(
2634
+ {
2635
+ 'check_runs': [
2636
+ _run(1, 'failure', app_id=111),
2637
+ _run(2, 'success', app_id=222),
2638
+ ]
2639
+ }
2640
+ )
2641
+ self.assertEqual(status, 'fail')
2642
+
2643
+ def test_superseded_cancelled_does_not_warn(self) -> None:
2644
+ # Cancel a hung job, re-run it green: the commit is green.
2645
+ status = _check_runs_to_status(
2646
+ {'check_runs': [_run(1, 'cancelled'), _run(2, 'success')]}
2647
+ )
2648
+ self.assertEqual(status, 'pass')
2649
+
2650
+ def test_latest_cancelled_still_warns(self) -> None:
2651
+ status = _check_runs_to_status(
2652
+ {'check_runs': [_run(1, 'success'), _run(2, 'cancelled')]}
2653
+ )
2654
+ self.assertEqual(status, 'warn')
2655
+
2656
+ def test_superseded_in_flight_run_is_not_unknown(self) -> None:
2657
+ # A suite GitHub cancelled mid-flight can be left non-terminal
2658
+ # forever. Only the current run's status gates the rollup --
2659
+ # otherwise this trades a stuck ``fail`` for a stuck ``unknown``.
2660
+ status = _check_runs_to_status(
2661
+ {
2662
+ 'check_runs': [
2663
+ _run(1, None, status='queued'),
2664
+ _run(2, 'success'),
2665
+ ]
2666
+ }
2667
+ )
2668
+ self.assertEqual(status, 'pass')
2669
+
2670
+ def test_current_run_in_flight_is_unknown(self) -> None:
2671
+ status = _check_runs_to_status(
2672
+ {
2673
+ 'check_runs': [
2674
+ _run(1, 'success'),
2675
+ _run(2, None, status='in_progress'),
2676
+ ]
2677
+ }
2678
+ )
2679
+ self.assertEqual(status, 'unknown')
2680
+
2681
+ def test_ordering_ignores_timestamps(self) -> None:
2682
+ # ``skipped`` runs report ``completed_at`` before ``started_at``,
2683
+ # so only the monotonic ``id`` is a safe sort key.
2684
+ older = _run(1, 'failure')
2685
+ older.update(
2686
+ {
2687
+ 'started_at': '2026-08-20T19:56:25Z',
2688
+ 'completed_at': '2026-08-20T19:58:00Z',
2689
+ }
2690
+ )
2691
+ newer = _run(2, 'success')
2692
+ newer.update(
2693
+ {
2694
+ 'started_at': '2026-08-20T16:40:02Z',
2695
+ 'completed_at': '2026-08-20T16:38:51Z',
2696
+ }
2697
+ )
2698
+ self.assertEqual(
2699
+ _check_runs_to_status({'check_runs': [older, newer]}), 'pass'
2700
+ )
2701
+
2702
+ def test_runs_without_ids_are_not_collapsed(self) -> None:
2703
+ # Nothing orders these, so neither may be dropped on a guess.
2704
+ status = _check_runs_to_status(
2705
+ {
2706
+ 'check_runs': [
2707
+ {'status': 'completed', 'conclusion': 'success'},
2708
+ {'status': 'completed', 'conclusion': 'failure'},
2709
+ ]
2710
+ }
2711
+ )
2712
+ self.assertEqual(status, 'fail')
2713
+
2714
+ def test_empty_payload_is_unknown(self) -> None:
2715
+ self.assertEqual(_check_runs_to_status({'check_runs': []}), 'unknown')
2716
+
2717
+
2572
2718
  class TagAndReleaseTestCase(unittest.IsolatedAsyncioTestCase):
2573
2719
  @respx.mock
2574
2720
  async def test_create_tag_and_ref(self) -> None: