imbi-plugin-github 2.10.0__tar.gz → 2.11.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.10.0 → imbi_plugin_github-2.11.0}/PKG-INFO +2 -2
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/pyproject.toml +3 -3
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/src/imbi_plugin_github/commits.py +329 -43
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/src/imbi_plugin_github/deployment.py +7 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/tests/test_commits.py +411 -10
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/tests/test_deployment.py +6 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/uv.lock +6 -6
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/.github/workflows/publish.yml +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/.github/workflows/test.yml +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/.gitignore +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/.pre-commit-config.yaml +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/CLAUDE.md +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/LICENSE +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/README.md +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/justfile +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/src/imbi_plugin_github/__init__.py +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/src/imbi_plugin_github/_app_auth.py +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/src/imbi_plugin_github/_hosts.py +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/src/imbi_plugin_github/_repos.py +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/src/imbi_plugin_github/identity.py +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/src/imbi_plugin_github/lifecycle.py +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/tests/test_repos.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: imbi-plugin-github
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.11.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: BSD-3-Clause
|
|
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.14
|
|
14
14
|
Requires-Python: >=3.14
|
|
15
15
|
Requires-Dist: httpx>=0.27
|
|
16
|
-
Requires-Dist: imbi-common[databases]==2.
|
|
16
|
+
Requires-Dist: imbi-common[databases]==2.11.0
|
|
17
17
|
Requires-Dist: pydantic>=2
|
|
18
18
|
Requires-Dist: pyjwt[crypto]>=2.8
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "imbi-plugin-github"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.11.0"
|
|
4
4
|
description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -16,7 +16,7 @@ classifiers = [
|
|
|
16
16
|
]
|
|
17
17
|
dependencies = [
|
|
18
18
|
"httpx>=0.27",
|
|
19
|
-
"imbi-common[databases]==2.
|
|
19
|
+
"imbi-common[databases]==2.11.0",
|
|
20
20
|
"pydantic>=2",
|
|
21
21
|
"pyjwt[crypto]>=2.8",
|
|
22
22
|
]
|
|
@@ -37,7 +37,7 @@ github-commit-sync = "imbi_plugin_github.commits:GitHubCommitSyncPlugin"
|
|
|
37
37
|
dev = [
|
|
38
38
|
"basedpyright",
|
|
39
39
|
"coverage[toml]",
|
|
40
|
-
"imbi-common[server,databases]==2.
|
|
40
|
+
"imbi-common[server,databases]==2.11.0",
|
|
41
41
|
"pre-commit",
|
|
42
42
|
"pytest",
|
|
43
43
|
"pytest-asyncio",
|
|
@@ -50,12 +50,14 @@ from imbi_common.json_pointer import JsonPointer
|
|
|
50
50
|
from imbi_common.models import CommitRecord, TagRecord
|
|
51
51
|
from imbi_common.plugins.base import (
|
|
52
52
|
ActionDescriptor,
|
|
53
|
+
CheckStatus,
|
|
53
54
|
CredentialField,
|
|
54
55
|
PluginContext,
|
|
55
56
|
PluginManifest,
|
|
56
57
|
ServicePlugin,
|
|
57
58
|
WebhookActionPlugin,
|
|
58
59
|
)
|
|
60
|
+
from imbi_common.plugins.errors import PluginRateLimited
|
|
59
61
|
|
|
60
62
|
from imbi_plugin_github import _app_auth
|
|
61
63
|
from imbi_plugin_github._hosts import (
|
|
@@ -66,10 +68,13 @@ from imbi_plugin_github._hosts import (
|
|
|
66
68
|
from imbi_plugin_github._repos import resolve_owner_repo
|
|
67
69
|
from imbi_plugin_github.deployment import (
|
|
68
70
|
_auth_headers, # pyright: ignore[reportPrivateUsage]
|
|
71
|
+
_check_runs_to_status, # pyright: ignore[reportPrivateUsage]
|
|
72
|
+
_checks_disabled, # pyright: ignore[reportPrivateUsage]
|
|
69
73
|
_next_page_url, # pyright: ignore[reportPrivateUsage]
|
|
70
74
|
_parse_iso, # pyright: ignore[reportPrivateUsage]
|
|
71
75
|
_query_param, # pyright: ignore[reportPrivateUsage]
|
|
72
76
|
_raise_on_401, # pyright: ignore[reportPrivateUsage]
|
|
77
|
+
_record_checks_disabled, # pyright: ignore[reportPrivateUsage]
|
|
73
78
|
_short_sha, # pyright: ignore[reportPrivateUsage]
|
|
74
79
|
)
|
|
75
80
|
|
|
@@ -91,6 +96,11 @@ _MAX_COMPARE_PAGES = 20
|
|
|
91
96
|
# backfill so a very deep repo can't pin the worker indefinitely; the
|
|
92
97
|
# walk logs a truncation warning when it hits the cap.
|
|
93
98
|
_MAX_HISTORY_PAGES = 100
|
|
99
|
+
# CI status is hydrated with one ``/check-runs`` call per commit, which is
|
|
100
|
+
# prohibitively expensive across a full-history backfill. Bound it to the
|
|
101
|
+
# most-recent commits (the only ones whose CI is still meaningful and
|
|
102
|
+
# unexpired); older commits keep the ``'unknown'`` default.
|
|
103
|
+
_BACKFILL_CI_LIMIT = 25
|
|
94
104
|
|
|
95
105
|
# Per-context ceilings on how long a *single* rate-limit pause may block
|
|
96
106
|
# before the sync gives up best-effort (logged). The webhook actions
|
|
@@ -140,6 +150,12 @@ def _throttle_wait(response: httpx.Response) -> float | None:
|
|
|
140
150
|
* a 403/429 that exhausted the quota but gives no reset -> the
|
|
141
151
|
conservative ``_SECONDARY_LIMIT_WAIT_SECONDS`` floor.
|
|
142
152
|
|
|
153
|
+
GHES/GHEC sometimes return a secondary (abuse) limit as a 403/429 with
|
|
154
|
+
*none* of the standard headers -- only the documented body message
|
|
155
|
+
("You have exceeded a secondary rate limit ..."); that phrase is
|
|
156
|
+
matched as a last resort so such a response pauses on the floor rather
|
|
157
|
+
than being mistaken for a hard failure.
|
|
158
|
+
|
|
143
159
|
Returns ``None`` for anything that is *not* a throttle signal --
|
|
144
160
|
including a non-rate-limit 403 (e.g. insufficient scope), which must
|
|
145
161
|
fall through to the caller's normal error handling rather than be
|
|
@@ -159,9 +175,27 @@ def _throttle_wait(response: httpx.Response) -> float | None:
|
|
|
159
175
|
return max(0.0, reset - time.time()) + _RESET_BUFFER_SECONDS
|
|
160
176
|
if response.status_code in (403, 429):
|
|
161
177
|
return _SECONDARY_LIMIT_WAIT_SECONDS
|
|
178
|
+
if response.status_code in (403, 429) and _is_secondary_limit_body(
|
|
179
|
+
response
|
|
180
|
+
):
|
|
181
|
+
return _SECONDARY_LIMIT_WAIT_SECONDS
|
|
162
182
|
return None
|
|
163
183
|
|
|
164
184
|
|
|
185
|
+
def _is_secondary_limit_body(response: httpx.Response) -> bool:
|
|
186
|
+
"""True if a 403/429 body carries GitHub's secondary-limit message.
|
|
187
|
+
|
|
188
|
+
Matched conservatively on the documented phrase so a genuine
|
|
189
|
+
permission 403 (which the caller must surface) is never mistaken for
|
|
190
|
+
a throttle.
|
|
191
|
+
"""
|
|
192
|
+
try:
|
|
193
|
+
body = response.text
|
|
194
|
+
except (UnicodeDecodeError, httpx.ResponseNotRead):
|
|
195
|
+
return False
|
|
196
|
+
return 'secondary rate limit' in body.lower()
|
|
197
|
+
|
|
198
|
+
|
|
165
199
|
async def _request(
|
|
166
200
|
client: httpx.AsyncClient,
|
|
167
201
|
method: str,
|
|
@@ -176,11 +210,13 @@ async def _request(
|
|
|
176
210
|
for the interval GitHub states in its headers, then retries -- up to
|
|
177
211
|
``_MAX_THROTTLE_RETRIES`` times and never longer than *max_wait* on a
|
|
178
212
|
single pause. When the stated wait exceeds *max_wait* (or the retries
|
|
179
|
-
are exhausted)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
213
|
+
are exhausted) :class:`PluginRateLimited` is raised carrying the epoch
|
|
214
|
+
at which GitHub will resume, so the host can pause the work and keep it
|
|
215
|
+
queued rather than fail it; on the webhook path the caller swallows it
|
|
216
|
+
(a later push re-syncs the gap). After a *successful* response that
|
|
217
|
+
exhausted the remaining quota, pauses pre-emptively (subject to the
|
|
218
|
+
same cap) so the next call doesn't spend a round-trip on a guaranteed
|
|
219
|
+
rejection.
|
|
184
220
|
"""
|
|
185
221
|
attempts = 0
|
|
186
222
|
while True:
|
|
@@ -204,13 +240,19 @@ async def _request(
|
|
|
204
240
|
if wait > max_wait or attempts >= _MAX_THROTTLE_RETRIES:
|
|
205
241
|
LOGGER.warning(
|
|
206
242
|
'github-commit-sync rate-limited on %s (wait %.0fs, cap '
|
|
207
|
-
'%.0fs, attempt %d);
|
|
243
|
+
'%.0fs, attempt %d); pausing job until GitHub resumes',
|
|
208
244
|
url,
|
|
209
245
|
wait,
|
|
210
246
|
max_wait,
|
|
211
247
|
attempts + 1,
|
|
212
248
|
)
|
|
213
|
-
|
|
249
|
+
raise PluginRateLimited(
|
|
250
|
+
retry_at=time.time() + wait,
|
|
251
|
+
message=(
|
|
252
|
+
f'github-commit-sync rate-limited on {url}; '
|
|
253
|
+
f'resume in {wait:.0f}s'
|
|
254
|
+
),
|
|
255
|
+
)
|
|
214
256
|
attempts += 1
|
|
215
257
|
LOGGER.warning(
|
|
216
258
|
'github-commit-sync rate-limited on %s; pausing %.0fs then '
|
|
@@ -493,6 +535,7 @@ def _commit_record(
|
|
|
493
535
|
ref: str,
|
|
494
536
|
pushed_at: datetime.datetime,
|
|
495
537
|
author_user: str = '',
|
|
538
|
+
ci_status: CheckStatus = 'unknown',
|
|
496
539
|
) -> CommitRecord:
|
|
497
540
|
"""Map a GitHub commit object onto a :class:`CommitRecord`.
|
|
498
541
|
|
|
@@ -500,7 +543,8 @@ def _commit_record(
|
|
|
500
543
|
email + linked login + committer), which the UI-facing
|
|
501
544
|
``Commit``/``_commit_from_payload`` mapping drops, so it maps the raw
|
|
502
545
|
item directly while reusing the low-level ``_short_sha`` / ``_parse_iso``
|
|
503
|
-
helpers.
|
|
546
|
+
helpers. ``ci_status`` is supplied by the caller (hydrated from
|
|
547
|
+
``/check-runs``); it defaults to ``'unknown'`` when not resolved.
|
|
504
548
|
"""
|
|
505
549
|
sha = str(item.get('sha') or '')
|
|
506
550
|
commit_meta: dict[str, typing.Any] = item.get('commit') or {}
|
|
@@ -519,6 +563,7 @@ def _commit_record(
|
|
|
519
563
|
author_login=str(gh_author.get('login') or ''),
|
|
520
564
|
author_user=author_user,
|
|
521
565
|
committer_name=str(committer_meta.get('name') or ''),
|
|
566
|
+
ci_status=ci_status,
|
|
522
567
|
authored_at=authored_at or pushed_at,
|
|
523
568
|
committed_at=_parse_iso(committer_meta.get('date')),
|
|
524
569
|
url=str(item.get('html_url') or ''),
|
|
@@ -598,6 +643,92 @@ async def _fetch_recent_commits(
|
|
|
598
643
|
return typing.cast('list[dict[str, typing.Any]]', resp.json())
|
|
599
644
|
|
|
600
645
|
|
|
646
|
+
async def _ci_status(
|
|
647
|
+
client: httpx.AsyncClient,
|
|
648
|
+
sha: str,
|
|
649
|
+
*,
|
|
650
|
+
credentials: dict[str, str],
|
|
651
|
+
base: str,
|
|
652
|
+
owner: str,
|
|
653
|
+
repo: str,
|
|
654
|
+
max_wait: float,
|
|
655
|
+
) -> CheckStatus:
|
|
656
|
+
"""Roll up ``/commits/{sha}/check-runs`` into a ci_status string.
|
|
657
|
+
|
|
658
|
+
Reuses the deployment plugin's ``/check-runs`` mapping and its 403
|
|
659
|
+
"checks disabled" cache so a token without the scope spends one 403
|
|
660
|
+
rather than one per commit. Degrades to ``'unknown'`` on a 403, any
|
|
661
|
+
non-200, a parse error, a network error, or a rate-limit pause -- CI
|
|
662
|
+
status is best-effort metadata and must never fail the sync, mirroring
|
|
663
|
+
the swallow-and-continue contract the rest of this module follows.
|
|
664
|
+
"""
|
|
665
|
+
if _checks_disabled(credentials, base, owner, repo):
|
|
666
|
+
return 'unknown'
|
|
667
|
+
try:
|
|
668
|
+
resp = await _request(
|
|
669
|
+
client, 'GET', f'/commits/{sha}/check-runs', max_wait=max_wait
|
|
670
|
+
)
|
|
671
|
+
if resp.status_code == 403:
|
|
672
|
+
_record_checks_disabled(credentials, base, owner, repo)
|
|
673
|
+
return 'unknown'
|
|
674
|
+
if resp.status_code != 200:
|
|
675
|
+
return 'unknown'
|
|
676
|
+
payload = typing.cast('dict[str, typing.Any]', resp.json())
|
|
677
|
+
except Exception: # noqa: BLE001 - CI status is best-effort metadata
|
|
678
|
+
return 'unknown'
|
|
679
|
+
return _check_runs_to_status(payload)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
async def _hydrate_ci(
|
|
683
|
+
client: httpx.AsyncClient,
|
|
684
|
+
shas: list[str],
|
|
685
|
+
*,
|
|
686
|
+
credentials: dict[str, str],
|
|
687
|
+
base: str,
|
|
688
|
+
owner: str,
|
|
689
|
+
repo: str,
|
|
690
|
+
max_wait: float,
|
|
691
|
+
) -> dict[str, CheckStatus]:
|
|
692
|
+
"""Resolve ``ci_status`` for ``shas`` -> ``{sha: status}``.
|
|
693
|
+
|
|
694
|
+
Probes the head commit first so a 403 (missing scope / Actions
|
|
695
|
+
disabled) populates the cache and short-circuits the rest, mirroring
|
|
696
|
+
the deployment plugin's commit picker. Shas absent from the returned
|
|
697
|
+
map fall back to ``'unknown'`` at the call site.
|
|
698
|
+
"""
|
|
699
|
+
out: dict[str, CheckStatus] = {}
|
|
700
|
+
if not shas or _checks_disabled(credentials, base, owner, repo):
|
|
701
|
+
return out
|
|
702
|
+
head, *tail = shas
|
|
703
|
+
out[head] = await _ci_status(
|
|
704
|
+
client,
|
|
705
|
+
head,
|
|
706
|
+
credentials=credentials,
|
|
707
|
+
base=base,
|
|
708
|
+
owner=owner,
|
|
709
|
+
repo=repo,
|
|
710
|
+
max_wait=max_wait,
|
|
711
|
+
)
|
|
712
|
+
if not tail or _checks_disabled(credentials, base, owner, repo):
|
|
713
|
+
return out
|
|
714
|
+
results = await asyncio.gather(
|
|
715
|
+
*(
|
|
716
|
+
_ci_status(
|
|
717
|
+
client,
|
|
718
|
+
sha,
|
|
719
|
+
credentials=credentials,
|
|
720
|
+
base=base,
|
|
721
|
+
owner=owner,
|
|
722
|
+
repo=repo,
|
|
723
|
+
max_wait=max_wait,
|
|
724
|
+
)
|
|
725
|
+
for sha in tail
|
|
726
|
+
)
|
|
727
|
+
)
|
|
728
|
+
out.update(dict(zip(tail, results, strict=True)))
|
|
729
|
+
return out
|
|
730
|
+
|
|
731
|
+
|
|
601
732
|
def _resolve_host_for_context(ctx: PluginContext) -> str | None:
|
|
602
733
|
"""Resolve the GitHub web host for an on-demand sync (no payload).
|
|
603
734
|
|
|
@@ -772,24 +903,43 @@ async def sync_commits(
|
|
|
772
903
|
before = _resolve(action_config.before_selector, event)
|
|
773
904
|
pushed_at = datetime.datetime.now(datetime.UTC)
|
|
774
905
|
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
)
|
|
780
|
-
else:
|
|
781
|
-
last = await _last_known_sha(ctx.project_id)
|
|
782
|
-
if last:
|
|
906
|
+
ci_by_sha: dict[str, CheckStatus] = {}
|
|
907
|
+
try:
|
|
908
|
+
async with _client(base, owner, repo, token) as client:
|
|
909
|
+
if isinstance(before, str) and before and before != _ZERO_SHA:
|
|
783
910
|
raw = await _fetch_compare_commits(
|
|
784
|
-
client,
|
|
911
|
+
client, before, after, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
|
|
785
912
|
)
|
|
786
913
|
else:
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
914
|
+
last = await _last_known_sha(ctx.project_id)
|
|
915
|
+
if last:
|
|
916
|
+
raw = await _fetch_compare_commits(
|
|
917
|
+
client, last, after, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
|
|
918
|
+
)
|
|
919
|
+
else:
|
|
920
|
+
raw = await _fetch_recent_commits(
|
|
921
|
+
client,
|
|
922
|
+
after,
|
|
923
|
+
action_config.initial_limit,
|
|
924
|
+
max_wait=_WEBHOOK_MAX_WAIT_SECONDS,
|
|
925
|
+
)
|
|
926
|
+
ci_by_sha = await _hydrate_ci(
|
|
927
|
+
client,
|
|
928
|
+
[str(i['sha']) for i in raw if i.get('sha')],
|
|
929
|
+
credentials=credentials,
|
|
930
|
+
base=base,
|
|
931
|
+
owner=owner,
|
|
932
|
+
repo=repo,
|
|
933
|
+
max_wait=_WEBHOOK_MAX_WAIT_SECONDS,
|
|
934
|
+
)
|
|
935
|
+
except PluginRateLimited as exc:
|
|
936
|
+
LOGGER.warning(
|
|
937
|
+
'github-commit-sync: rate-limited syncing commits for project '
|
|
938
|
+
'%s; skipping this push (a later push re-syncs the gap): %s',
|
|
939
|
+
ctx.project_id,
|
|
940
|
+
exc,
|
|
941
|
+
)
|
|
942
|
+
return
|
|
793
943
|
user_map = await _resolve_author_users(
|
|
794
944
|
raw, ctx.resolve_user_by_identity, base
|
|
795
945
|
)
|
|
@@ -800,6 +950,7 @@ async def sync_commits(
|
|
|
800
950
|
ref=ref,
|
|
801
951
|
pushed_at=pushed_at,
|
|
802
952
|
author_user=_author_user(item, user_map),
|
|
953
|
+
ci_status=ci_by_sha.get(str(item['sha']), 'unknown'),
|
|
803
954
|
)
|
|
804
955
|
for item in raw
|
|
805
956
|
if item.get('sha')
|
|
@@ -826,6 +977,73 @@ async def _annotated_tag(
|
|
|
826
977
|
return typing.cast('dict[str, typing.Any]', resp.json())
|
|
827
978
|
|
|
828
979
|
|
|
980
|
+
async def _commit_date(
|
|
981
|
+
client: httpx.AsyncClient, sha: str, *, max_wait: float
|
|
982
|
+
) -> datetime.datetime | None:
|
|
983
|
+
"""Committer date of commit *sha*, ``None`` when unavailable.
|
|
984
|
+
|
|
985
|
+
Lightweight tags carry no tagger date, so their target commit's
|
|
986
|
+
date stands in for ``tagged_at`` — otherwise the ClickHouse row
|
|
987
|
+
falls back to ``recorded_at`` (sync time) and every release in the
|
|
988
|
+
UI reads "just now" after a full reconcile.
|
|
989
|
+
"""
|
|
990
|
+
resp = await _request(
|
|
991
|
+
client, 'GET', f'/git/commits/{sha}', max_wait=max_wait
|
|
992
|
+
)
|
|
993
|
+
if resp.status_code != 200:
|
|
994
|
+
return None
|
|
995
|
+
data = typing.cast('dict[str, typing.Any]', resp.json())
|
|
996
|
+
committer: dict[str, typing.Any] = data.get('committer') or {}
|
|
997
|
+
return _parse_iso(committer.get('date'))
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
async def _release_published_for_tag(
|
|
1001
|
+
client: httpx.AsyncClient, name: str, *, max_wait: float
|
|
1002
|
+
) -> datetime.datetime | None:
|
|
1003
|
+
"""Published date of the GitHub release for tag *name*, if any."""
|
|
1004
|
+
resp = await _request(
|
|
1005
|
+
client,
|
|
1006
|
+
'GET',
|
|
1007
|
+
f'/releases/tags/{urllib.parse.quote(name, safe="")}',
|
|
1008
|
+
max_wait=max_wait,
|
|
1009
|
+
)
|
|
1010
|
+
if resp.status_code != 200:
|
|
1011
|
+
return None
|
|
1012
|
+
data = typing.cast('dict[str, typing.Any]', resp.json())
|
|
1013
|
+
return _parse_iso(data.get('published_at'))
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
async def _release_published_map(
|
|
1017
|
+
client: httpx.AsyncClient, *, max_wait: float
|
|
1018
|
+
) -> dict[str, datetime.datetime]:
|
|
1019
|
+
"""Map tag name -> GitHub release published date for the repo.
|
|
1020
|
+
|
|
1021
|
+
One paginated ``/releases`` sweep instead of a per-tag lookup;
|
|
1022
|
+
drafts (``published_at`` null) are skipped.
|
|
1023
|
+
"""
|
|
1024
|
+
out: dict[str, datetime.datetime] = {}
|
|
1025
|
+
params: dict[str, str] = {'per_page': '100'}
|
|
1026
|
+
while True:
|
|
1027
|
+
resp = await _request(
|
|
1028
|
+
client, 'GET', '/releases', params=params, max_wait=max_wait
|
|
1029
|
+
)
|
|
1030
|
+
if resp.status_code != 200:
|
|
1031
|
+
return out
|
|
1032
|
+
rows = typing.cast('list[dict[str, typing.Any]]', resp.json())
|
|
1033
|
+
for row in rows:
|
|
1034
|
+
tag = row.get('tag_name')
|
|
1035
|
+
published = _parse_iso(row.get('published_at'))
|
|
1036
|
+
if tag and published:
|
|
1037
|
+
out[str(tag)] = published
|
|
1038
|
+
next_url = _next_page_url(resp.headers.get('link'))
|
|
1039
|
+
if next_url is None:
|
|
1040
|
+
return out
|
|
1041
|
+
next_page = _query_param(next_url, 'page')
|
|
1042
|
+
if next_page is None:
|
|
1043
|
+
return out
|
|
1044
|
+
params['page'] = next_page
|
|
1045
|
+
|
|
1046
|
+
|
|
829
1047
|
def _web_base(api_base: str) -> str:
|
|
830
1048
|
"""Map a REST API base to the web host (inverse of the routing table).
|
|
831
1049
|
|
|
@@ -857,9 +1075,24 @@ def _tag_record(
|
|
|
857
1075
|
sha: str,
|
|
858
1076
|
annotated: dict[str, typing.Any] | None = None,
|
|
859
1077
|
url: str = '',
|
|
1078
|
+
published_at: datetime.datetime | None = None,
|
|
1079
|
+
fallback_tagged_at: datetime.datetime | None = None,
|
|
860
1080
|
) -> TagRecord:
|
|
1081
|
+
"""Build the ClickHouse row for one tag.
|
|
1082
|
+
|
|
1083
|
+
``tagged_at`` preference: the GitHub release's published date
|
|
1084
|
+
(*published_at*), then the annotated tag's tagger date, then the
|
|
1085
|
+
target commit's committer date (*fallback_tagged_at*, lightweight
|
|
1086
|
+
tags only).
|
|
1087
|
+
"""
|
|
861
1088
|
if annotated is None:
|
|
862
|
-
return TagRecord(
|
|
1089
|
+
return TagRecord(
|
|
1090
|
+
project_id=project_id,
|
|
1091
|
+
name=name,
|
|
1092
|
+
sha=sha,
|
|
1093
|
+
url=url,
|
|
1094
|
+
tagged_at=published_at or fallback_tagged_at,
|
|
1095
|
+
)
|
|
863
1096
|
tagger: dict[str, typing.Any] = annotated.get('tagger') or {}
|
|
864
1097
|
return TagRecord(
|
|
865
1098
|
project_id=project_id,
|
|
@@ -869,7 +1102,7 @@ def _tag_record(
|
|
|
869
1102
|
message=str(annotated.get('message') or ''),
|
|
870
1103
|
tagger_name=str(tagger.get('name') or ''),
|
|
871
1104
|
tagger_email=str(tagger.get('email') or ''),
|
|
872
|
-
tagged_at=_parse_iso(tagger.get('date')),
|
|
1105
|
+
tagged_at=published_at or _parse_iso(tagger.get('date')),
|
|
873
1106
|
)
|
|
874
1107
|
|
|
875
1108
|
|
|
@@ -886,6 +1119,8 @@ async def _reconcile_tags(
|
|
|
886
1119
|
out: list[TagRecord] = []
|
|
887
1120
|
prefix = 'refs/tags/'
|
|
888
1121
|
params: dict[str, str] = {'per_page': '100'}
|
|
1122
|
+
# Lazily fetched on the first tag so tag-less repos cost nothing.
|
|
1123
|
+
released: dict[str, datetime.datetime] | None = None
|
|
889
1124
|
while True:
|
|
890
1125
|
resp = await _request(
|
|
891
1126
|
client,
|
|
@@ -903,6 +1138,11 @@ async def _reconcile_tags(
|
|
|
903
1138
|
if not ref.startswith(prefix) or not sha:
|
|
904
1139
|
continue
|
|
905
1140
|
name = ref[len(prefix) :]
|
|
1141
|
+
if released is None:
|
|
1142
|
+
released = await _release_published_map(
|
|
1143
|
+
client, max_wait=max_wait
|
|
1144
|
+
)
|
|
1145
|
+
published = released.get(name)
|
|
906
1146
|
annotated = (
|
|
907
1147
|
await _annotated_tag(client, sha, max_wait=max_wait)
|
|
908
1148
|
if obj.get('type') == 'tag'
|
|
@@ -915,6 +1155,12 @@ async def _reconcile_tags(
|
|
|
915
1155
|
sha=sha,
|
|
916
1156
|
annotated=annotated,
|
|
917
1157
|
url=_tag_web_url(client, name),
|
|
1158
|
+
published_at=published,
|
|
1159
|
+
fallback_tagged_at=(
|
|
1160
|
+
await _commit_date(client, sha, max_wait=max_wait)
|
|
1161
|
+
if annotated is None and published is None
|
|
1162
|
+
else None
|
|
1163
|
+
),
|
|
918
1164
|
)
|
|
919
1165
|
)
|
|
920
1166
|
next_url = _next_page_url(resp.headers.get('link'))
|
|
@@ -954,25 +1200,45 @@ async def sync_tags(
|
|
|
954
1200
|
return
|
|
955
1201
|
owner, repo, base = resolved
|
|
956
1202
|
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
records: list[pydantic.BaseModel] = [
|
|
962
|
-
_tag_record(
|
|
963
|
-
project_id=ctx.project_id,
|
|
964
|
-
name=name,
|
|
965
|
-
sha=after,
|
|
966
|
-
annotated=annotated,
|
|
967
|
-
url=_tag_web_url(client, name),
|
|
1203
|
+
try:
|
|
1204
|
+
async with _client(base, owner, repo, token) as client:
|
|
1205
|
+
annotated = await _annotated_tag(
|
|
1206
|
+
client, after, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
|
|
968
1207
|
)
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
extra = await _reconcile_tags(
|
|
972
|
-
client, ctx.project_id, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
|
|
1208
|
+
published = await _release_published_for_tag(
|
|
1209
|
+
client, name, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
|
|
973
1210
|
)
|
|
974
|
-
|
|
975
|
-
|
|
1211
|
+
records: list[pydantic.BaseModel] = [
|
|
1212
|
+
_tag_record(
|
|
1213
|
+
project_id=ctx.project_id,
|
|
1214
|
+
name=name,
|
|
1215
|
+
sha=after,
|
|
1216
|
+
annotated=annotated,
|
|
1217
|
+
url=_tag_web_url(client, name),
|
|
1218
|
+
published_at=published,
|
|
1219
|
+
fallback_tagged_at=(
|
|
1220
|
+
await _commit_date(
|
|
1221
|
+
client, after, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
|
|
1222
|
+
)
|
|
1223
|
+
if annotated is None and published is None
|
|
1224
|
+
else None
|
|
1225
|
+
),
|
|
1226
|
+
)
|
|
1227
|
+
]
|
|
1228
|
+
if action_config.reconcile_all:
|
|
1229
|
+
extra = await _reconcile_tags(
|
|
1230
|
+
client, ctx.project_id, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
|
|
1231
|
+
)
|
|
1232
|
+
seen = {name}
|
|
1233
|
+
records.extend(r for r in extra if r.name not in seen)
|
|
1234
|
+
except PluginRateLimited as exc:
|
|
1235
|
+
LOGGER.warning(
|
|
1236
|
+
'github-commit-sync: rate-limited syncing tags for project '
|
|
1237
|
+
'%s; skipping this push (a later push re-syncs the gap): %s',
|
|
1238
|
+
ctx.project_id,
|
|
1239
|
+
exc,
|
|
1240
|
+
)
|
|
1241
|
+
return
|
|
976
1242
|
try:
|
|
977
1243
|
await clickhouse.insert('tags', records)
|
|
978
1244
|
except Exception:
|
|
@@ -1102,7 +1368,10 @@ class GitHubCommitSyncPlugin(WebhookActionPlugin):
|
|
|
1102
1368
|
Returns ``(commits_recorded, tags_recorded)``. Raises
|
|
1103
1369
|
:class:`ValueError` only when the host or repository can't be
|
|
1104
1370
|
resolved; ClickHouse failures are swallowed (the count reflects
|
|
1105
|
-
what was written).
|
|
1371
|
+
what was written). Propagates :class:`PluginRateLimited` when a
|
|
1372
|
+
GitHub rate-limit reset is further out than
|
|
1373
|
+
``_BACKFILL_MAX_WAIT_SECONDS`` so the host can pause the worker and
|
|
1374
|
+
keep the job queued until GitHub resumes rather than fail it.
|
|
1106
1375
|
"""
|
|
1107
1376
|
host = _resolve_host_for_context(ctx)
|
|
1108
1377
|
if host is None:
|
|
@@ -1124,6 +1393,22 @@ class GitHubCommitSyncPlugin(WebhookActionPlugin):
|
|
|
1124
1393
|
tags = await _reconcile_tags(
|
|
1125
1394
|
client, ctx.project_id, max_wait=_BACKFILL_MAX_WAIT_SECONDS
|
|
1126
1395
|
)
|
|
1396
|
+
# CI status only for the most-recent commits: one /check-runs
|
|
1397
|
+
# call per commit is too costly across full history, and old
|
|
1398
|
+
# commits' CI is no longer meaningful.
|
|
1399
|
+
ci_by_sha = await _hydrate_ci(
|
|
1400
|
+
client,
|
|
1401
|
+
[
|
|
1402
|
+
str(i['sha'])
|
|
1403
|
+
for i in raw_commits[:_BACKFILL_CI_LIMIT]
|
|
1404
|
+
if i.get('sha')
|
|
1405
|
+
],
|
|
1406
|
+
credentials=credentials,
|
|
1407
|
+
base=base,
|
|
1408
|
+
owner=owner,
|
|
1409
|
+
repo=repo,
|
|
1410
|
+
max_wait=_BACKFILL_MAX_WAIT_SECONDS,
|
|
1411
|
+
)
|
|
1127
1412
|
user_map = await _resolve_author_users(
|
|
1128
1413
|
raw_commits, ctx.resolve_user_by_identity, base
|
|
1129
1414
|
)
|
|
@@ -1134,6 +1419,7 @@ class GitHubCommitSyncPlugin(WebhookActionPlugin):
|
|
|
1134
1419
|
ref=branch,
|
|
1135
1420
|
pushed_at=pushed_at,
|
|
1136
1421
|
author_user=_author_user(item, user_map),
|
|
1422
|
+
ci_status=ci_by_sha.get(str(item['sha']), 'unknown'),
|
|
1137
1423
|
)
|
|
1138
1424
|
for item in raw_commits
|
|
1139
1425
|
if item.get('sha')
|
{imbi_plugin_github-2.10.0 → imbi_plugin_github-2.11.0}/src/imbi_plugin_github/deployment.py
RENAMED
|
@@ -1035,12 +1035,18 @@ class _DeploymentBase(DeploymentPlugin):
|
|
|
1035
1035
|
description = deployment.get('description')
|
|
1036
1036
|
deployment_url = deployment.get('url') or deployment.get('html_url')
|
|
1037
1037
|
creator_login: str | None = None
|
|
1038
|
+
creator_subject: str | None = None
|
|
1038
1039
|
creator_raw = deployment.get('creator')
|
|
1039
1040
|
if isinstance(creator_raw, dict):
|
|
1040
1041
|
creator_dict = typing.cast(dict[str, typing.Any], creator_raw)
|
|
1041
1042
|
login = creator_dict.get('login')
|
|
1042
1043
|
if isinstance(login, str) and login:
|
|
1043
1044
|
creator_login = login
|
|
1045
|
+
# GitHub's numeric user id is the stable identity subject the
|
|
1046
|
+
# host resolves to an Imbi user (logins can be renamed).
|
|
1047
|
+
creator_id = creator_dict.get('id')
|
|
1048
|
+
if isinstance(creator_id, int):
|
|
1049
|
+
creator_subject = str(creator_id)
|
|
1044
1050
|
return RemoteDeployment(
|
|
1045
1051
|
environment=environment,
|
|
1046
1052
|
sha=str(sha),
|
|
@@ -1052,6 +1058,7 @@ class _DeploymentBase(DeploymentPlugin):
|
|
|
1052
1058
|
deployment_url=str(deployment_url) if deployment_url else None,
|
|
1053
1059
|
description=str(description) if description else None,
|
|
1054
1060
|
creator=creator_login,
|
|
1061
|
+
creator_subject=creator_subject,
|
|
1055
1062
|
)
|
|
1056
1063
|
|
|
1057
1064
|
async def _latest_status(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Tests for the GitHub commit / tag history sync webhook plugin."""
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
|
+
import datetime
|
|
4
5
|
import time
|
|
5
6
|
import typing
|
|
6
7
|
import unittest
|
|
@@ -12,9 +13,12 @@ from cryptography.hazmat.primitives import serialization
|
|
|
12
13
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
13
14
|
from imbi_common.models import CommitRecord, TagRecord
|
|
14
15
|
from imbi_common.plugins.base import PluginContext, ServicePlugin
|
|
15
|
-
from imbi_common.plugins.errors import
|
|
16
|
+
from imbi_common.plugins.errors import (
|
|
17
|
+
PluginAuthenticationFailed,
|
|
18
|
+
PluginRateLimited,
|
|
19
|
+
)
|
|
16
20
|
|
|
17
|
-
from imbi_plugin_github import _app_auth, commits
|
|
21
|
+
from imbi_plugin_github import _app_auth, commits, deployment
|
|
18
22
|
|
|
19
23
|
_ZERO = '0' * 40
|
|
20
24
|
_CREDS = {'access_token': 'gho_test'}
|
|
@@ -111,6 +115,17 @@ def _commit(
|
|
|
111
115
|
}
|
|
112
116
|
|
|
113
117
|
|
|
118
|
+
def _check_runs(
|
|
119
|
+
conclusion: str | None = 'success', *, status: str = 'completed'
|
|
120
|
+
) -> dict[str, object]:
|
|
121
|
+
"""A ``/check-runs`` payload with a single run."""
|
|
122
|
+
return {'check_runs': [{'status': status, 'conclusion': conclusion}]}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _check_runs_url(sha: str) -> str:
|
|
126
|
+
return f'https://api.github.com/repos/octo/demo/commits/{sha}/check-runs'
|
|
127
|
+
|
|
128
|
+
|
|
114
129
|
class ConfigTestCase(unittest.TestCase):
|
|
115
130
|
def test_sync_commits_defaults(self) -> None:
|
|
116
131
|
cfg = commits.SyncCommitsConfig()
|
|
@@ -423,10 +438,103 @@ class SyncCommitsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
423
438
|
)
|
|
424
439
|
|
|
425
440
|
|
|
441
|
+
class SyncCommitsCiStatusTestCase(unittest.IsolatedAsyncioTestCase):
|
|
442
|
+
"""CI status hydration during ``sync_commits``."""
|
|
443
|
+
|
|
444
|
+
def setUp(self) -> None:
|
|
445
|
+
# The /check-runs 403 cache is process-wide module state on the
|
|
446
|
+
# deployment plugin; clear it so a 403 recorded by one test can't
|
|
447
|
+
# short-circuit hydration in another.
|
|
448
|
+
deployment._CHECKS_DISABLED_TOKENS.clear()
|
|
449
|
+
|
|
450
|
+
def _mock_compare(self, *shas: str) -> None:
|
|
451
|
+
base, head = 'a' * 40, 'b' * 40
|
|
452
|
+
respx.get(
|
|
453
|
+
f'https://api.github.com/repos/octo/demo/compare/{base}...{head}'
|
|
454
|
+
).mock(
|
|
455
|
+
return_value=httpx.Response(
|
|
456
|
+
200, json={'commits': [_commit(s) for s in shas]}
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
async def _run(self) -> list[CommitRecord]:
|
|
461
|
+
base, head = 'a' * 40, 'b' * 40
|
|
462
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
|
|
463
|
+
await commits.sync_commits(
|
|
464
|
+
ctx=_ctx(),
|
|
465
|
+
credentials=_CREDS,
|
|
466
|
+
external_identifier='',
|
|
467
|
+
action_config=commits.SyncCommitsConfig(),
|
|
468
|
+
event=_event(_push(before=base, after=head)),
|
|
469
|
+
)
|
|
470
|
+
_, records = _await_args(insert)
|
|
471
|
+
return records
|
|
472
|
+
|
|
473
|
+
@respx.mock
|
|
474
|
+
async def test_hydrates_pass_and_fail(self) -> None:
|
|
475
|
+
self._mock_compare('c' * 40, 'd' * 40)
|
|
476
|
+
respx.get(_check_runs_url('c' * 40)).mock(
|
|
477
|
+
return_value=httpx.Response(200, json=_check_runs('success'))
|
|
478
|
+
)
|
|
479
|
+
respx.get(_check_runs_url('d' * 40)).mock(
|
|
480
|
+
return_value=httpx.Response(200, json=_check_runs('failure'))
|
|
481
|
+
)
|
|
482
|
+
records = await self._run()
|
|
483
|
+
self.assertEqual('pass', records[0].ci_status)
|
|
484
|
+
self.assertEqual('fail', records[1].ci_status)
|
|
485
|
+
|
|
486
|
+
@respx.mock
|
|
487
|
+
async def test_403_degrades_and_caches(self) -> None:
|
|
488
|
+
self._mock_compare('c' * 40, 'd' * 40)
|
|
489
|
+
route = respx.get(url__regex=r'.+/commits/.+/check-runs$').mock(
|
|
490
|
+
return_value=httpx.Response(403)
|
|
491
|
+
)
|
|
492
|
+
records = await self._run()
|
|
493
|
+
# Head probe 403s and records the repo as checks-disabled, so the
|
|
494
|
+
# tail commit is never probed -- one call, both 'unknown'.
|
|
495
|
+
self.assertEqual(1, route.call_count)
|
|
496
|
+
self.assertEqual(
|
|
497
|
+
['unknown', 'unknown'], [r.ci_status for r in records]
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
@respx.mock
|
|
501
|
+
async def test_in_progress_is_unknown(self) -> None:
|
|
502
|
+
self._mock_compare('c' * 40)
|
|
503
|
+
respx.get(_check_runs_url('c' * 40)).mock(
|
|
504
|
+
return_value=httpx.Response(
|
|
505
|
+
200, json=_check_runs(None, status='in_progress')
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
records = await self._run()
|
|
509
|
+
self.assertEqual('unknown', records[0].ci_status)
|
|
510
|
+
|
|
511
|
+
@respx.mock
|
|
512
|
+
async def test_network_error_degrades_to_unknown(self) -> None:
|
|
513
|
+
self._mock_compare('c' * 40)
|
|
514
|
+
respx.get(_check_runs_url('c' * 40)).mock(
|
|
515
|
+
side_effect=httpx.ConnectError('boom')
|
|
516
|
+
)
|
|
517
|
+
records = await self._run()
|
|
518
|
+
self.assertEqual('unknown', records[0].ci_status)
|
|
519
|
+
|
|
520
|
+
@respx.mock
|
|
521
|
+
async def test_non_403_http_error_degrades_to_unknown(self) -> None:
|
|
522
|
+
# A non-200, non-403 response (e.g. 500) is not cached as
|
|
523
|
+
# checks-disabled, so it degrades to 'unknown' per commit.
|
|
524
|
+
self._mock_compare('c' * 40)
|
|
525
|
+
respx.get(_check_runs_url('c' * 40)).mock(
|
|
526
|
+
return_value=httpx.Response(500)
|
|
527
|
+
)
|
|
528
|
+
records = await self._run()
|
|
529
|
+
self.assertEqual('unknown', records[0].ci_status)
|
|
530
|
+
|
|
531
|
+
|
|
426
532
|
class SyncTagsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
427
|
-
def _tag_push(
|
|
533
|
+
def _tag_push(
|
|
534
|
+
self, *, after: str = 't' * 40, ref: str = 'refs/tags/v1.2.3'
|
|
535
|
+
) -> dict[str, object]:
|
|
428
536
|
return {
|
|
429
|
-
'ref':
|
|
537
|
+
'ref': ref,
|
|
430
538
|
'after': after,
|
|
431
539
|
'repository': {
|
|
432
540
|
'full_name': 'octo/demo',
|
|
@@ -440,6 +548,16 @@ class SyncTagsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
440
548
|
respx.get(
|
|
441
549
|
f'https://api.github.com/repos/octo/demo/git/tags/{sha}'
|
|
442
550
|
).mock(return_value=httpx.Response(404))
|
|
551
|
+
respx.get(
|
|
552
|
+
'https://api.github.com/repos/octo/demo/releases/tags/v1.2.3'
|
|
553
|
+
).mock(return_value=httpx.Response(404))
|
|
554
|
+
respx.get(
|
|
555
|
+
f'https://api.github.com/repos/octo/demo/git/commits/{sha}'
|
|
556
|
+
).mock(
|
|
557
|
+
return_value=httpx.Response(
|
|
558
|
+
200, json={'committer': {'date': '2026-01-15T12:00:00Z'}}
|
|
559
|
+
)
|
|
560
|
+
)
|
|
443
561
|
with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
|
|
444
562
|
await commits.sync_tags(
|
|
445
563
|
ctx=_ctx(),
|
|
@@ -455,10 +573,106 @@ class SyncTagsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
455
573
|
self.assertEqual('v1.2.3', records[0].name)
|
|
456
574
|
self.assertEqual(sha, records[0].sha)
|
|
457
575
|
self.assertEqual('', records[0].message)
|
|
576
|
+
# Lightweight tags carry no tagger date; the target commit's
|
|
577
|
+
# committer date stands in so the UI doesn't show sync time.
|
|
578
|
+
self.assertEqual(
|
|
579
|
+
datetime.datetime.fromisoformat('2026-01-15T12:00:00Z'),
|
|
580
|
+
records[0].tagged_at,
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
@respx.mock
|
|
584
|
+
async def test_release_published_date_preferred(self) -> None:
|
|
585
|
+
# When a GitHub release exists for the tag its published date
|
|
586
|
+
# wins, and the commit-date fallback is never fetched (respx
|
|
587
|
+
# would fail the test on the unmocked /git/commits call).
|
|
588
|
+
sha = 't' * 40
|
|
589
|
+
respx.get(
|
|
590
|
+
f'https://api.github.com/repos/octo/demo/git/tags/{sha}'
|
|
591
|
+
).mock(return_value=httpx.Response(404))
|
|
592
|
+
respx.get(
|
|
593
|
+
'https://api.github.com/repos/octo/demo/releases/tags/v1.2.3'
|
|
594
|
+
).mock(
|
|
595
|
+
return_value=httpx.Response(
|
|
596
|
+
200, json={'published_at': '2026-02-03T04:05:06Z'}
|
|
597
|
+
)
|
|
598
|
+
)
|
|
599
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
|
|
600
|
+
await commits.sync_tags(
|
|
601
|
+
ctx=_ctx(),
|
|
602
|
+
credentials=_CREDS,
|
|
603
|
+
external_identifier='',
|
|
604
|
+
action_config=commits.SyncTagsConfig(),
|
|
605
|
+
event=_event(self._tag_push(after=sha)),
|
|
606
|
+
)
|
|
607
|
+
_, records = _await_args(insert)
|
|
608
|
+
self.assertEqual(
|
|
609
|
+
datetime.datetime.fromisoformat('2026-02-03T04:05:06Z'),
|
|
610
|
+
records[0].tagged_at,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
@respx.mock
|
|
614
|
+
async def test_release_lookup_encodes_slash_in_tag(self) -> None:
|
|
615
|
+
# A tag like ``release/v1.2.3`` must be encoded as a single
|
|
616
|
+
# path segment so the GitHub ``/releases/tags/:tag`` route
|
|
617
|
+
# matches and ``published_at`` is found.
|
|
618
|
+
sha = 't' * 40
|
|
619
|
+
respx.get(
|
|
620
|
+
f'https://api.github.com/repos/octo/demo/git/tags/{sha}'
|
|
621
|
+
).mock(return_value=httpx.Response(404))
|
|
622
|
+
route = respx.get(
|
|
623
|
+
'https://api.github.com/repos/octo/demo/'
|
|
624
|
+
'releases/tags/release%2Fv1.2.3'
|
|
625
|
+
).mock(
|
|
626
|
+
return_value=httpx.Response(
|
|
627
|
+
200, json={'published_at': '2026-02-03T04:05:06Z'}
|
|
628
|
+
)
|
|
629
|
+
)
|
|
630
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
|
|
631
|
+
await commits.sync_tags(
|
|
632
|
+
ctx=_ctx(),
|
|
633
|
+
credentials=_CREDS,
|
|
634
|
+
external_identifier='',
|
|
635
|
+
action_config=commits.SyncTagsConfig(),
|
|
636
|
+
event=_event(
|
|
637
|
+
self._tag_push(after=sha, ref='refs/tags/release/v1.2.3')
|
|
638
|
+
),
|
|
639
|
+
)
|
|
640
|
+
self.assertTrue(route.called)
|
|
641
|
+
_, records = _await_args(insert)
|
|
642
|
+
self.assertEqual(
|
|
643
|
+
datetime.datetime.fromisoformat('2026-02-03T04:05:06Z'),
|
|
644
|
+
records[0].tagged_at,
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
@respx.mock
|
|
648
|
+
async def test_lightweight_tag_date_fetch_failure_degrades(self) -> None:
|
|
649
|
+
sha = 't' * 40
|
|
650
|
+
respx.get(
|
|
651
|
+
f'https://api.github.com/repos/octo/demo/git/tags/{sha}'
|
|
652
|
+
).mock(return_value=httpx.Response(404))
|
|
653
|
+
respx.get(
|
|
654
|
+
'https://api.github.com/repos/octo/demo/releases/tags/v1.2.3'
|
|
655
|
+
).mock(return_value=httpx.Response(404))
|
|
656
|
+
respx.get(
|
|
657
|
+
f'https://api.github.com/repos/octo/demo/git/commits/{sha}'
|
|
658
|
+
).mock(return_value=httpx.Response(500))
|
|
659
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
|
|
660
|
+
await commits.sync_tags(
|
|
661
|
+
ctx=_ctx(),
|
|
662
|
+
credentials=_CREDS,
|
|
663
|
+
external_identifier='',
|
|
664
|
+
action_config=commits.SyncTagsConfig(),
|
|
665
|
+
event=_event(self._tag_push(after=sha)),
|
|
666
|
+
)
|
|
667
|
+
_, records = _await_args(insert)
|
|
668
|
+
self.assertIsNone(records[0].tagged_at)
|
|
458
669
|
|
|
459
670
|
@respx.mock
|
|
460
671
|
async def test_annotated_tag_metadata(self) -> None:
|
|
461
672
|
sha = 't' * 40
|
|
673
|
+
respx.get(
|
|
674
|
+
'https://api.github.com/repos/octo/demo/releases/tags/v1.2.3'
|
|
675
|
+
).mock(return_value=httpx.Response(404))
|
|
462
676
|
respx.get(
|
|
463
677
|
f'https://api.github.com/repos/octo/demo/git/tags/{sha}'
|
|
464
678
|
).mock(
|
|
@@ -493,6 +707,25 @@ class SyncTagsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
493
707
|
respx.get(
|
|
494
708
|
f'https://api.github.com/repos/octo/demo/git/tags/{sha}'
|
|
495
709
|
).mock(return_value=httpx.Response(404))
|
|
710
|
+
respx.get(
|
|
711
|
+
'https://api.github.com/repos/octo/demo/releases/tags/v1.2.3'
|
|
712
|
+
).mock(return_value=httpx.Response(404))
|
|
713
|
+
respx.get('https://api.github.com/repos/octo/demo/releases').mock(
|
|
714
|
+
return_value=httpx.Response(
|
|
715
|
+
200,
|
|
716
|
+
json=[
|
|
717
|
+
{
|
|
718
|
+
'tag_name': 'v1.0.0',
|
|
719
|
+
'published_at': '2026-02-03T04:05:06Z',
|
|
720
|
+
}
|
|
721
|
+
],
|
|
722
|
+
)
|
|
723
|
+
)
|
|
724
|
+
respx.get(url__regex=r'.*/git/commits/\w+$').mock(
|
|
725
|
+
return_value=httpx.Response(
|
|
726
|
+
200, json={'committer': {'date': '2026-01-15T12:00:00Z'}}
|
|
727
|
+
)
|
|
728
|
+
)
|
|
496
729
|
respx.get(
|
|
497
730
|
'https://api.github.com/repos/octo/demo/git/matching-refs/tags'
|
|
498
731
|
).mock(
|
|
@@ -529,6 +762,20 @@ class SyncTagsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
529
762
|
},
|
|
530
763
|
urls,
|
|
531
764
|
)
|
|
765
|
+
# v1.0.0 has a GitHub release -> its published date; v1.2.3
|
|
766
|
+
# does not -> the target commit's committer date.
|
|
767
|
+
dates = {r.name: r.tagged_at for r in records}
|
|
768
|
+
self.assertEqual(
|
|
769
|
+
{
|
|
770
|
+
'v1.2.3': datetime.datetime.fromisoformat(
|
|
771
|
+
'2026-01-15T12:00:00Z'
|
|
772
|
+
),
|
|
773
|
+
'v1.0.0': datetime.datetime.fromisoformat(
|
|
774
|
+
'2026-02-03T04:05:06Z'
|
|
775
|
+
),
|
|
776
|
+
},
|
|
777
|
+
dates,
|
|
778
|
+
)
|
|
532
779
|
|
|
533
780
|
@respx.mock
|
|
534
781
|
async def test_reconcile_all_paginates(self) -> None:
|
|
@@ -536,6 +783,17 @@ class SyncTagsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
536
783
|
respx.get(
|
|
537
784
|
f'https://api.github.com/repos/octo/demo/git/tags/{sha}'
|
|
538
785
|
).mock(return_value=httpx.Response(404))
|
|
786
|
+
respx.get(
|
|
787
|
+
'https://api.github.com/repos/octo/demo/releases/tags/v1.2.3'
|
|
788
|
+
).mock(return_value=httpx.Response(404))
|
|
789
|
+
respx.get('https://api.github.com/repos/octo/demo/releases').mock(
|
|
790
|
+
return_value=httpx.Response(200, json=[])
|
|
791
|
+
)
|
|
792
|
+
respx.get(url__regex=r'.*/git/commits/\w+$').mock(
|
|
793
|
+
return_value=httpx.Response(
|
|
794
|
+
200, json={'committer': {'date': '2026-01-15T12:00:00Z'}}
|
|
795
|
+
)
|
|
796
|
+
)
|
|
539
797
|
url = 'https://api.github.com/repos/octo/demo/git/matching-refs/tags'
|
|
540
798
|
respx.get(url).mock(
|
|
541
799
|
side_effect=[
|
|
@@ -907,6 +1165,14 @@ class SyncAllHistoryTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
907
1165
|
200, json=[_commit('c' * 40), _commit('d' * 40)]
|
|
908
1166
|
)
|
|
909
1167
|
)
|
|
1168
|
+
respx.get(url__regex=r'.*/git/commits/\w+$').mock(
|
|
1169
|
+
return_value=httpx.Response(
|
|
1170
|
+
200, json={'committer': {'date': '2026-01-15T12:00:00Z'}}
|
|
1171
|
+
)
|
|
1172
|
+
)
|
|
1173
|
+
respx.get(f'{self._REPO}/releases').mock(
|
|
1174
|
+
return_value=httpx.Response(200, json=[])
|
|
1175
|
+
)
|
|
910
1176
|
respx.get(f'{self._REPO}/git/matching-refs/tags').mock(
|
|
911
1177
|
return_value=httpx.Response(
|
|
912
1178
|
200,
|
|
@@ -951,6 +1217,57 @@ class SyncAllHistoryTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
951
1217
|
urls,
|
|
952
1218
|
)
|
|
953
1219
|
|
|
1220
|
+
@respx.mock
|
|
1221
|
+
async def test_bounds_ci_status_to_recent_commits(self) -> None:
|
|
1222
|
+
deployment._CHECKS_DISABLED_TOKENS.clear()
|
|
1223
|
+
self._mock_default_branch()
|
|
1224
|
+
respx.get(f'{self._REPO}/commits').mock(
|
|
1225
|
+
return_value=httpx.Response(
|
|
1226
|
+
200, json=[_commit('c' * 40), _commit('d' * 40)]
|
|
1227
|
+
)
|
|
1228
|
+
)
|
|
1229
|
+
respx.get(f'{self._REPO}/git/matching-refs/tags').mock(
|
|
1230
|
+
return_value=httpx.Response(200, json=[])
|
|
1231
|
+
)
|
|
1232
|
+
ci_route = respx.get(url__regex=r'.+/commits/.+/check-runs$').mock(
|
|
1233
|
+
return_value=httpx.Response(200, json=_check_runs('success'))
|
|
1234
|
+
)
|
|
1235
|
+
with mock.patch.object(commits, '_BACKFILL_CI_LIMIT', 1):
|
|
1236
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
|
|
1237
|
+
await commits.GitHubCommitSyncPlugin().sync_all_history(
|
|
1238
|
+
ctx=self._ctx(), credentials=_CREDS
|
|
1239
|
+
)
|
|
1240
|
+
# Only the most-recent commit is hydrated; the rest stay 'unknown'.
|
|
1241
|
+
self.assertEqual(1, ci_route.call_count)
|
|
1242
|
+
commit_call = next(
|
|
1243
|
+
c for c in insert.await_args_list if c.args[0] == 'commits'
|
|
1244
|
+
)
|
|
1245
|
+
records = commit_call.args[1]
|
|
1246
|
+
self.assertEqual('pass', records[0].ci_status)
|
|
1247
|
+
self.assertEqual('unknown', records[1].ci_status)
|
|
1248
|
+
|
|
1249
|
+
@respx.mock
|
|
1250
|
+
async def test_rate_limit_beyond_cap_propagates(self) -> None:
|
|
1251
|
+
# The backfill path must NOT swallow PluginRateLimited: it
|
|
1252
|
+
# propagates to the host's queue, which pauses until the reset
|
|
1253
|
+
# and keeps the job queued rather than failing it.
|
|
1254
|
+
respx.get(self._REPO).mock(
|
|
1255
|
+
return_value=httpx.Response(
|
|
1256
|
+
403,
|
|
1257
|
+
headers={
|
|
1258
|
+
'x-ratelimit-remaining': '0',
|
|
1259
|
+
'x-ratelimit-reset': str(int(time.time()) + 10_000),
|
|
1260
|
+
},
|
|
1261
|
+
)
|
|
1262
|
+
)
|
|
1263
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
|
|
1264
|
+
with self.assertRaises(PluginRateLimited) as caught:
|
|
1265
|
+
await commits.GitHubCommitSyncPlugin().sync_all_history(
|
|
1266
|
+
ctx=self._ctx(), credentials=_CREDS
|
|
1267
|
+
)
|
|
1268
|
+
self.assertGreater(caught.exception.retry_at, time.time() + 9_000)
|
|
1269
|
+
insert.assert_not_awaited()
|
|
1270
|
+
|
|
954
1271
|
@respx.mock
|
|
955
1272
|
async def test_paginates_commits(self) -> None:
|
|
956
1273
|
self._mock_default_branch()
|
|
@@ -1063,6 +1380,9 @@ class SyncAllHistoryTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
1063
1380
|
respx.get(f'{self._REPO}/commits').mock(
|
|
1064
1381
|
return_value=httpx.Response(200, json=[_commit('c' * 40)])
|
|
1065
1382
|
)
|
|
1383
|
+
respx.get(f'{self._REPO}/releases').mock(
|
|
1384
|
+
return_value=httpx.Response(200, json=[])
|
|
1385
|
+
)
|
|
1066
1386
|
tag_sha = 'a' * 40
|
|
1067
1387
|
respx.get(f'{self._REPO}/git/matching-refs/tags').mock(
|
|
1068
1388
|
return_value=httpx.Response(
|
|
@@ -1159,6 +1479,21 @@ class ThrottleWaitTestCase(unittest.TestCase):
|
|
|
1159
1479
|
resp = httpx.Response(403, json={'message': 'Not accessible'})
|
|
1160
1480
|
self.assertIsNone(commits._throttle_wait(resp))
|
|
1161
1481
|
|
|
1482
|
+
def test_secondary_limit_body_without_headers_floors(self) -> None:
|
|
1483
|
+
resp = httpx.Response(
|
|
1484
|
+
403,
|
|
1485
|
+
json={
|
|
1486
|
+
'message': (
|
|
1487
|
+
'You have exceeded a secondary rate limit. Please wait '
|
|
1488
|
+
'a few minutes before you try again.'
|
|
1489
|
+
)
|
|
1490
|
+
},
|
|
1491
|
+
)
|
|
1492
|
+
self.assertEqual(
|
|
1493
|
+
commits._SECONDARY_LIMIT_WAIT_SECONDS,
|
|
1494
|
+
commits._throttle_wait(resp),
|
|
1495
|
+
)
|
|
1496
|
+
|
|
1162
1497
|
def test_healthy_response_no_wait(self) -> None:
|
|
1163
1498
|
resp = httpx.Response(200, headers={'x-ratelimit-remaining': '4999'})
|
|
1164
1499
|
self.assertIsNone(commits._throttle_wait(resp))
|
|
@@ -1225,7 +1560,7 @@ class RequestTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
1225
1560
|
# The good response is returned, not retried.
|
|
1226
1561
|
self.assertEqual(1, client.request.await_count)
|
|
1227
1562
|
|
|
1228
|
-
async def
|
|
1563
|
+
async def test_wait_exceeding_cap_raises_rate_limited(self) -> None:
|
|
1229
1564
|
reset = int(time.time()) + 10_000
|
|
1230
1565
|
client = self._client(
|
|
1231
1566
|
httpx.Response(
|
|
@@ -1239,20 +1574,22 @@ class RequestTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
1239
1574
|
with mock.patch.object(
|
|
1240
1575
|
commits.asyncio, 'sleep', new=mock.AsyncMock()
|
|
1241
1576
|
) as slept:
|
|
1242
|
-
|
|
1243
|
-
|
|
1577
|
+
with self.assertRaises(PluginRateLimited) as caught:
|
|
1578
|
+
await commits._request(client, 'GET', '/x', max_wait=60.0)
|
|
1579
|
+
# Bails to the host without sleeping, carrying the resume time.
|
|
1580
|
+
self.assertGreater(caught.exception.retry_at, time.time() + 9_000)
|
|
1244
1581
|
slept.assert_not_awaited()
|
|
1245
1582
|
self.assertEqual(1, client.request.await_count)
|
|
1246
1583
|
|
|
1247
|
-
async def
|
|
1584
|
+
async def test_retries_exhausted_raises_rate_limited(self) -> None:
|
|
1248
1585
|
client = self._client(
|
|
1249
1586
|
httpx.Response(429, headers={'retry-after': '1'})
|
|
1250
1587
|
)
|
|
1251
1588
|
with mock.patch.object(
|
|
1252
1589
|
commits.asyncio, 'sleep', new=mock.AsyncMock()
|
|
1253
1590
|
) as slept:
|
|
1254
|
-
|
|
1255
|
-
|
|
1591
|
+
with self.assertRaises(PluginRateLimited):
|
|
1592
|
+
await commits._request(client, 'GET', '/x', max_wait=60.0)
|
|
1256
1593
|
self.assertEqual(commits._MAX_THROTTLE_RETRIES, slept.await_count)
|
|
1257
1594
|
self.assertEqual(
|
|
1258
1595
|
commits._MAX_THROTTLE_RETRIES + 1, client.request.await_count
|
|
@@ -1288,6 +1625,70 @@ class SyncCommitsThrottleTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
1288
1625
|
_, records = _await_args(insert)
|
|
1289
1626
|
self.assertEqual(1, len(records))
|
|
1290
1627
|
|
|
1628
|
+
@respx.mock
|
|
1629
|
+
async def test_rate_limited_beyond_cap_is_swallowed(self) -> None:
|
|
1630
|
+
# A reset further out than the webhook cap makes _request raise
|
|
1631
|
+
# PluginRateLimited; the webhook action swallows it (a later push
|
|
1632
|
+
# re-syncs) rather than letting it 5xx the gateway.
|
|
1633
|
+
base, head = 'a' * 40, 'b' * 40
|
|
1634
|
+
url = f'https://api.github.com/repos/octo/demo/compare/{base}...{head}'
|
|
1635
|
+
respx.get(url).mock(
|
|
1636
|
+
return_value=httpx.Response(
|
|
1637
|
+
403,
|
|
1638
|
+
headers={
|
|
1639
|
+
'x-ratelimit-remaining': '0',
|
|
1640
|
+
'x-ratelimit-reset': str(int(time.time()) + 10_000),
|
|
1641
|
+
},
|
|
1642
|
+
)
|
|
1643
|
+
)
|
|
1644
|
+
with mock.patch.object(commits.asyncio, 'sleep', new=mock.AsyncMock()):
|
|
1645
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
|
|
1646
|
+
await commits.sync_commits(
|
|
1647
|
+
ctx=_ctx(),
|
|
1648
|
+
credentials=_CREDS,
|
|
1649
|
+
external_identifier='',
|
|
1650
|
+
action_config=commits.SyncCommitsConfig(),
|
|
1651
|
+
event=_event(_push(before=base, after=head)),
|
|
1652
|
+
)
|
|
1653
|
+
insert.assert_not_awaited()
|
|
1654
|
+
|
|
1655
|
+
@respx.mock
|
|
1656
|
+
async def test_tags_rate_limited_beyond_cap_is_swallowed(self) -> None:
|
|
1657
|
+
# Mirror of the sync_commits swallow path for the tags webhook:
|
|
1658
|
+
# a reset further out than the cap makes _request raise
|
|
1659
|
+
# PluginRateLimited, which sync_tags swallows (a later push
|
|
1660
|
+
# re-syncs) rather than letting it 5xx the gateway.
|
|
1661
|
+
sha = 't' * 40
|
|
1662
|
+
respx.get(
|
|
1663
|
+
f'https://api.github.com/repos/octo/demo/git/tags/{sha}'
|
|
1664
|
+
).mock(
|
|
1665
|
+
return_value=httpx.Response(
|
|
1666
|
+
403,
|
|
1667
|
+
headers={
|
|
1668
|
+
'x-ratelimit-remaining': '0',
|
|
1669
|
+
'x-ratelimit-reset': str(int(time.time()) + 10_000),
|
|
1670
|
+
},
|
|
1671
|
+
)
|
|
1672
|
+
)
|
|
1673
|
+
push = {
|
|
1674
|
+
'ref': 'refs/tags/v1.2.3',
|
|
1675
|
+
'after': sha,
|
|
1676
|
+
'repository': {
|
|
1677
|
+
'full_name': 'octo/demo',
|
|
1678
|
+
'url': 'https://api.github.com/repos/octo/demo',
|
|
1679
|
+
},
|
|
1680
|
+
}
|
|
1681
|
+
with mock.patch.object(commits.asyncio, 'sleep', new=mock.AsyncMock()):
|
|
1682
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
|
|
1683
|
+
await commits.sync_tags(
|
|
1684
|
+
ctx=_ctx(),
|
|
1685
|
+
credentials=_CREDS,
|
|
1686
|
+
external_identifier='',
|
|
1687
|
+
action_config=commits.SyncTagsConfig(),
|
|
1688
|
+
event=_event(push),
|
|
1689
|
+
)
|
|
1690
|
+
insert.assert_not_awaited()
|
|
1691
|
+
|
|
1291
1692
|
|
|
1292
1693
|
class ResolveUserCacheTestCase(unittest.IsolatedAsyncioTestCase):
|
|
1293
1694
|
"""LRU caching of commit-author identity resolution."""
|
|
@@ -857,6 +857,7 @@ class ListRecentDeploymentsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
857
857
|
'created_at': '2026-05-13T14:00:00Z',
|
|
858
858
|
'description': 'Deploy main',
|
|
859
859
|
'url': 'https://api.github.com/repos/octo/demo/deployments/123',
|
|
860
|
+
'creator': {'login': 'octocat', 'id': 583231},
|
|
860
861
|
}
|
|
861
862
|
],
|
|
862
863
|
)
|
|
@@ -891,6 +892,11 @@ class ListRecentDeploymentsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
891
892
|
event.deployment_url,
|
|
892
893
|
'https://api.github.com/repos/octo/demo/deployments/123',
|
|
893
894
|
)
|
|
895
|
+
# The creator login is kept for display and the numeric id is
|
|
896
|
+
# surfaced as the identity subject so the host can attribute the
|
|
897
|
+
# deploy to an Imbi user.
|
|
898
|
+
self.assertEqual(event.creator, 'octocat')
|
|
899
|
+
self.assertEqual(event.creator_subject, '583231')
|
|
894
900
|
# ``created_at`` must come from the deployment row, not the
|
|
895
901
|
# latest status row (which is one minute later above).
|
|
896
902
|
self.assertEqual(
|
|
@@ -667,7 +667,7 @@ wheels = [
|
|
|
667
667
|
|
|
668
668
|
[[package]]
|
|
669
669
|
name = "imbi-common"
|
|
670
|
-
version = "2.
|
|
670
|
+
version = "2.11.0"
|
|
671
671
|
source = { registry = "https://pypi.org/simple" }
|
|
672
672
|
dependencies = [
|
|
673
673
|
{ name = "cryptography" },
|
|
@@ -683,9 +683,9 @@ dependencies = [
|
|
|
683
683
|
{ name = "python-slugify" },
|
|
684
684
|
{ name = "typer" },
|
|
685
685
|
]
|
|
686
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
686
|
+
sdist = { url = "https://files.pythonhosted.org/packages/82/02/30fe936f86936763fd0460c0bf7a8602029ee32de76052063f1322eb54cb/imbi_common-2.11.0.tar.gz", hash = "sha256:fd9126d2ae80fed72902bdcd280a17f992eff6bb80caad7f8979cc07f17ce946", size = 350702, upload-time = "2026-06-11T17:39:16.348Z" }
|
|
687
687
|
wheels = [
|
|
688
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
688
|
+
{ url = "https://files.pythonhosted.org/packages/b4/5d/6adecad7cf2fa1fa0f35e7ca37352f05d504824ba785f6775750f3e442fc/imbi_common-2.11.0-py3-none-any.whl", hash = "sha256:8ca15781dc0285ce412b9158f0162739a9ae82229b193ccf473b01e62688c923", size = 113762, upload-time = "2026-06-11T17:39:14.999Z" },
|
|
689
689
|
]
|
|
690
690
|
|
|
691
691
|
[package.optional-dependencies]
|
|
@@ -703,7 +703,7 @@ server = [
|
|
|
703
703
|
|
|
704
704
|
[[package]]
|
|
705
705
|
name = "imbi-plugin-github"
|
|
706
|
-
version = "2.
|
|
706
|
+
version = "2.11.0"
|
|
707
707
|
source = { editable = "." }
|
|
708
708
|
dependencies = [
|
|
709
709
|
{ name = "httpx" },
|
|
@@ -732,7 +732,7 @@ dist = [
|
|
|
732
732
|
[package.metadata]
|
|
733
733
|
requires-dist = [
|
|
734
734
|
{ name = "httpx", specifier = ">=0.27" },
|
|
735
|
-
{ name = "imbi-common", extras = ["databases"], specifier = "==2.
|
|
735
|
+
{ name = "imbi-common", extras = ["databases"], specifier = "==2.11.0" },
|
|
736
736
|
{ name = "pydantic", specifier = ">=2" },
|
|
737
737
|
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.8" },
|
|
738
738
|
]
|
|
@@ -741,7 +741,7 @@ requires-dist = [
|
|
|
741
741
|
dev = [
|
|
742
742
|
{ name = "basedpyright" },
|
|
743
743
|
{ name = "coverage", extras = ["toml"] },
|
|
744
|
-
{ name = "imbi-common", extras = ["server", "databases"], specifier = "==2.
|
|
744
|
+
{ name = "imbi-common", extras = ["server", "databases"], specifier = "==2.11.0" },
|
|
745
745
|
{ name = "pre-commit" },
|
|
746
746
|
{ name = "pytest" },
|
|
747
747
|
{ name = "pytest-asyncio" },
|
|
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
|
|
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
|
|
File without changes
|