imbi-plugin-github 2.29.3__tar.gz → 2.29.4__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.29.4}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/pyproject.toml +2 -2
  3. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/deployment.py +46 -3
  4. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/tests/test_commits.py +33 -0
  5. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/tests/test_deployment.py +147 -1
  6. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/.gitignore +0 -0
  7. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/README.md +0 -0
  8. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/moon.yml +0 -0
  9. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/README.md +0 -0
  10. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/__init__.py +0 -0
  11. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/_app_auth.py +0 -0
  12. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/_hosts.py +0 -0
  13. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/_repos.py +0 -0
  14. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/commits.py +0 -0
  15. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/doctor.py +0 -0
  16. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/identity.py +0 -0
  17. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/lifecycle.py +0 -0
  18. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/plugin.py +0 -0
  19. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/pull_requests.py +0 -0
  20. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/py.typed +0 -0
  21. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/tests/__init__.py +0 -0
  22. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/tests/test_doctor.py +0 -0
  23. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/tests/test_hosts.py +0 -0
  24. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/tests/test_identity.py +0 -0
  25. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/tests/test_lifecycle.py +0 -0
  26. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/tests/test_pull_requests.py +0 -0
  27. {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/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.29.4
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.29.4
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.29.4"
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.29.4",
22
22
  "pydantic>=2",
23
23
  "pyjwt[crypto]>=2.8",
24
24
  ]
@@ -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'
@@ -448,6 +448,39 @@ class SyncCommitsCiStatusTestCase(unittest.IsolatedAsyncioTestCase):
448
448
  self.assertEqual('pass', records[0].ci_status)
449
449
  self.assertEqual('fail', records[1].ci_status)
450
450
 
451
+ @respx.mock
452
+ async def test_rerun_to_green_syncs_as_pass(self) -> None:
453
+ # The sync path shares the deployment plugin's rollup, so it
454
+ # inherits the re-run de-duplication -- without it the API and
455
+ # the ClickHouse table agree with each other while both report a
456
+ # green commit as 'fail' (issue #282).
457
+ self._mock_compare('c' * 40)
458
+ respx.get(_check_runs_url('c' * 40)).mock(
459
+ return_value=httpx.Response(
460
+ 200,
461
+ json={
462
+ 'check_runs': [
463
+ {
464
+ 'id': 1,
465
+ 'name': 'acceptance',
466
+ 'status': 'completed',
467
+ 'conclusion': 'failure',
468
+ 'app': {'id': 7},
469
+ },
470
+ {
471
+ 'id': 2,
472
+ 'name': 'acceptance',
473
+ 'status': 'completed',
474
+ 'conclusion': 'success',
475
+ 'app': {'id': 7},
476
+ },
477
+ ]
478
+ },
479
+ )
480
+ )
481
+ records = await self._run()
482
+ self.assertEqual('pass', records[0].ci_status)
483
+
451
484
  @respx.mock
452
485
  async def test_403_degrades_and_caches(self) -> None:
453
486
  self._mock_compare('c' * 40, 'd' * 40)
@@ -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: