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.
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/PKG-INFO +2 -2
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/pyproject.toml +2 -2
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/deployment.py +46 -3
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/tests/test_commits.py +33 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/tests/test_deployment.py +147 -1
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/.gitignore +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/README.md +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/moon.yml +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/README.md +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/__init__.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/_app_auth.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/_hosts.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/_repos.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/commits.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/doctor.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/identity.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/lifecycle.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/plugin.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/pull_requests.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/py.typed +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/tests/test_doctor.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/tests/test_pull_requests.py +0 -0
- {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
|
+
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.
|
|
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
|
+
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.
|
|
21
|
+
"imbi-common[databases]==2.29.4",
|
|
22
22
|
"pydantic>=2",
|
|
23
23
|
"pyjwt[crypto]>=2.8",
|
|
24
24
|
]
|
{imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/deployment.py
RENAMED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
{
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/_app_auth.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/lifecycle.py
RENAMED
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.29.3 → imbi_plugin_github-2.29.4}/src/imbi/plugins/github/pull_requests.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|