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.
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/PKG-INFO +2 -2
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/pyproject.toml +2 -2
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/commits.py +107 -16
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/deployment.py +46 -3
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/tests/test_commits.py +189 -3
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/tests/test_deployment.py +147 -1
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/.gitignore +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/README.md +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/moon.yml +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/README.md +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/__init__.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/_app_auth.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/_hosts.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/_repos.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/doctor.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/identity.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/lifecycle.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/plugin.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/pull_requests.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/src/imbi/plugins/github/py.typed +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/tests/test_doctor.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/tests/test_pull_requests.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
89
|
-
# prohibitively expensive across a full-history
|
|
90
|
-
# most-recent commits
|
|
91
|
-
#
|
|
92
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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(
|
|
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
|
|
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:
|
{imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/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'
|
|
@@ -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=
|
|
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=
|
|
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(
|
|
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
|
-
{
|
|
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.30.0}/src/imbi/plugins/github/_app_auth.py
RENAMED
|
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.30.0}/src/imbi/plugins/github/lifecycle.py
RENAMED
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.29.3 → imbi_plugin_github-2.30.0}/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
|