imbi-plugin-github 2.9.3__py3-none-any.whl → 2.11.0__py3-none-any.whl
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/commits.py +644 -77
- imbi_plugin_github/deployment.py +7 -0
- {imbi_plugin_github-2.9.3.dist-info → imbi_plugin_github-2.11.0.dist-info}/METADATA +2 -2
- imbi_plugin_github-2.11.0.dist-info/RECORD +13 -0
- {imbi_plugin_github-2.9.3.dist-info → imbi_plugin_github-2.11.0.dist-info}/WHEEL +1 -1
- imbi_plugin_github-2.9.3.dist-info/RECORD +0 -13
- {imbi_plugin_github-2.9.3.dist-info → imbi_plugin_github-2.11.0.dist-info}/entry_points.txt +0 -0
- {imbi_plugin_github-2.9.3.dist-info → imbi_plugin_github-2.11.0.dist-info}/licenses/LICENSE +0 -0
imbi_plugin_github/commits.py
CHANGED
|
@@ -33,8 +33,12 @@ recording behaves.
|
|
|
33
33
|
|
|
34
34
|
from __future__ import annotations
|
|
35
35
|
|
|
36
|
+
import asyncio
|
|
37
|
+
import collections
|
|
38
|
+
import collections.abc
|
|
36
39
|
import datetime
|
|
37
40
|
import logging
|
|
41
|
+
import time
|
|
38
42
|
import typing
|
|
39
43
|
import urllib.parse
|
|
40
44
|
|
|
@@ -46,12 +50,14 @@ from imbi_common.json_pointer import JsonPointer
|
|
|
46
50
|
from imbi_common.models import CommitRecord, TagRecord
|
|
47
51
|
from imbi_common.plugins.base import (
|
|
48
52
|
ActionDescriptor,
|
|
53
|
+
CheckStatus,
|
|
49
54
|
CredentialField,
|
|
50
55
|
PluginContext,
|
|
51
56
|
PluginManifest,
|
|
52
57
|
ServicePlugin,
|
|
53
58
|
WebhookActionPlugin,
|
|
54
59
|
)
|
|
60
|
+
from imbi_common.plugins.errors import PluginRateLimited
|
|
55
61
|
|
|
56
62
|
from imbi_plugin_github import _app_auth
|
|
57
63
|
from imbi_plugin_github._hosts import (
|
|
@@ -62,10 +68,13 @@ from imbi_plugin_github._hosts import (
|
|
|
62
68
|
from imbi_plugin_github._repos import resolve_owner_repo
|
|
63
69
|
from imbi_plugin_github.deployment import (
|
|
64
70
|
_auth_headers, # pyright: ignore[reportPrivateUsage]
|
|
71
|
+
_check_runs_to_status, # pyright: ignore[reportPrivateUsage]
|
|
72
|
+
_checks_disabled, # pyright: ignore[reportPrivateUsage]
|
|
65
73
|
_next_page_url, # pyright: ignore[reportPrivateUsage]
|
|
66
74
|
_parse_iso, # pyright: ignore[reportPrivateUsage]
|
|
67
75
|
_query_param, # pyright: ignore[reportPrivateUsage]
|
|
68
76
|
_raise_on_401, # pyright: ignore[reportPrivateUsage]
|
|
77
|
+
_record_checks_disabled, # pyright: ignore[reportPrivateUsage]
|
|
69
78
|
_short_sha, # pyright: ignore[reportPrivateUsage]
|
|
70
79
|
)
|
|
71
80
|
|
|
@@ -87,6 +96,173 @@ _MAX_COMPARE_PAGES = 20
|
|
|
87
96
|
# backfill so a very deep repo can't pin the worker indefinitely; the
|
|
88
97
|
# walk logs a truncation warning when it hits the cap.
|
|
89
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
|
|
104
|
+
|
|
105
|
+
# Per-context ceilings on how long a *single* rate-limit pause may block
|
|
106
|
+
# before the sync gives up best-effort (logged). The webhook actions
|
|
107
|
+
# (``sync_commits`` / ``sync_tags``) run inside the gateway's request
|
|
108
|
+
# path, so they pause only briefly and bail when GitHub's reset is
|
|
109
|
+
# further out -- a later push re-syncs the gap (``ReplacingMergeTree``
|
|
110
|
+
# dedupes). The on-demand backfill (``sync_all_history``) runs in a
|
|
111
|
+
# background worker and can wait out a full primary-limit reset.
|
|
112
|
+
_WEBHOOK_MAX_WAIT_SECONDS = 60.0
|
|
113
|
+
_BACKFILL_MAX_WAIT_SECONDS = 900.0
|
|
114
|
+
# GitHub's documented floor to wait when a secondary (abuse) rate-limit
|
|
115
|
+
# response carries neither ``retry-after`` nor an exhausted
|
|
116
|
+
# ``x-ratelimit-*`` to time the resume from.
|
|
117
|
+
_SECONDARY_LIMIT_WAIT_SECONDS = 60.0
|
|
118
|
+
# Small cushion added to a primary-limit reset wait so minor clock skew
|
|
119
|
+
# between us and GitHub can't wake us a hair early into a re-throttle.
|
|
120
|
+
_RESET_BUFFER_SECONDS = 1.0
|
|
121
|
+
# Pathological-loop guard: how many times one request may be paused and
|
|
122
|
+
# retried before giving up. A primary reset / ``retry-after`` normally
|
|
123
|
+
# clears the limit on the first retry, so this only bounds a misbehaving
|
|
124
|
+
# endpoint.
|
|
125
|
+
_MAX_THROTTLE_RETRIES = 3
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _header_int(response: httpx.Response, name: str) -> int | None:
|
|
129
|
+
"""Parse an integer response header, ``None`` when absent/malformed."""
|
|
130
|
+
raw = response.headers.get(name)
|
|
131
|
+
if raw is None:
|
|
132
|
+
return None
|
|
133
|
+
try:
|
|
134
|
+
return int(raw)
|
|
135
|
+
except ValueError:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _throttle_wait(response: httpx.Response) -> float | None:
|
|
140
|
+
"""Seconds to pause for GitHub's rate-limit headers, else ``None``.
|
|
141
|
+
|
|
142
|
+
Detects both throttle styles GitHub documents and reads the resume
|
|
143
|
+
time straight from the response:
|
|
144
|
+
|
|
145
|
+
* **secondary limit** -- a ``retry-after`` header (any status): honor
|
|
146
|
+
it verbatim;
|
|
147
|
+
* **primary limit** -- ``x-ratelimit-remaining: 0`` with an
|
|
148
|
+
``x-ratelimit-reset`` epoch: wait until the reset (plus a small
|
|
149
|
+
clock-skew cushion);
|
|
150
|
+
* a 403/429 that exhausted the quota but gives no reset -> the
|
|
151
|
+
conservative ``_SECONDARY_LIMIT_WAIT_SECONDS`` floor.
|
|
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
|
+
|
|
159
|
+
Returns ``None`` for anything that is *not* a throttle signal --
|
|
160
|
+
including a non-rate-limit 403 (e.g. insufficient scope), which must
|
|
161
|
+
fall through to the caller's normal error handling rather than be
|
|
162
|
+
mistaken for a pause. A successful response that merely depleted the
|
|
163
|
+
quota (``remaining == 0`` on a 2xx) also yields a wait, so the caller
|
|
164
|
+
can pause pre-emptively before the next request.
|
|
165
|
+
"""
|
|
166
|
+
retry_after = response.headers.get('retry-after')
|
|
167
|
+
if retry_after is not None:
|
|
168
|
+
try:
|
|
169
|
+
return max(0.0, float(retry_after))
|
|
170
|
+
except ValueError:
|
|
171
|
+
return _SECONDARY_LIMIT_WAIT_SECONDS
|
|
172
|
+
if _header_int(response, 'x-ratelimit-remaining') == 0:
|
|
173
|
+
reset = _header_int(response, 'x-ratelimit-reset')
|
|
174
|
+
if reset is not None:
|
|
175
|
+
return max(0.0, reset - time.time()) + _RESET_BUFFER_SECONDS
|
|
176
|
+
if response.status_code in (403, 429):
|
|
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
|
|
182
|
+
return None
|
|
183
|
+
|
|
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
|
+
|
|
199
|
+
async def _request(
|
|
200
|
+
client: httpx.AsyncClient,
|
|
201
|
+
method: str,
|
|
202
|
+
url: str,
|
|
203
|
+
*,
|
|
204
|
+
max_wait: float,
|
|
205
|
+
**kwargs: typing.Any,
|
|
206
|
+
) -> httpx.Response:
|
|
207
|
+
"""Issue a request, pausing and resuming on GitHub rate limits.
|
|
208
|
+
|
|
209
|
+
On a throttled response (403/429 carrying a rate-limit signal) sleeps
|
|
210
|
+
for the interval GitHub states in its headers, then retries -- up to
|
|
211
|
+
``_MAX_THROTTLE_RETRIES`` times and never longer than *max_wait* on a
|
|
212
|
+
single pause. When the stated wait exceeds *max_wait* (or the retries
|
|
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.
|
|
220
|
+
"""
|
|
221
|
+
attempts = 0
|
|
222
|
+
while True:
|
|
223
|
+
response = await client.request(method, url, **kwargs)
|
|
224
|
+
wait = _throttle_wait(response)
|
|
225
|
+
if wait is None:
|
|
226
|
+
return response
|
|
227
|
+
if response.status_code not in (403, 429):
|
|
228
|
+
# Pre-emptive: the request succeeded but drained the quota;
|
|
229
|
+
# pause (bounded) so the next call isn't a guaranteed 403,
|
|
230
|
+
# then hand back the good response.
|
|
231
|
+
if wait <= max_wait:
|
|
232
|
+
LOGGER.info(
|
|
233
|
+
'github-commit-sync quota exhausted on %s; pausing '
|
|
234
|
+
'%.0fs before continuing',
|
|
235
|
+
url,
|
|
236
|
+
wait,
|
|
237
|
+
)
|
|
238
|
+
await asyncio.sleep(wait)
|
|
239
|
+
return response
|
|
240
|
+
if wait > max_wait or attempts >= _MAX_THROTTLE_RETRIES:
|
|
241
|
+
LOGGER.warning(
|
|
242
|
+
'github-commit-sync rate-limited on %s (wait %.0fs, cap '
|
|
243
|
+
'%.0fs, attempt %d); pausing job until GitHub resumes',
|
|
244
|
+
url,
|
|
245
|
+
wait,
|
|
246
|
+
max_wait,
|
|
247
|
+
attempts + 1,
|
|
248
|
+
)
|
|
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
|
+
)
|
|
256
|
+
attempts += 1
|
|
257
|
+
LOGGER.warning(
|
|
258
|
+
'github-commit-sync rate-limited on %s; pausing %.0fs then '
|
|
259
|
+
'retrying (attempt %d/%d)',
|
|
260
|
+
url,
|
|
261
|
+
wait,
|
|
262
|
+
attempts,
|
|
263
|
+
_MAX_THROTTLE_RETRIES,
|
|
264
|
+
)
|
|
265
|
+
await asyncio.sleep(wait)
|
|
90
266
|
|
|
91
267
|
|
|
92
268
|
async def _resolve_bearer(
|
|
@@ -120,11 +296,11 @@ async def _resolve_bearer(
|
|
|
120
296
|
)
|
|
121
297
|
|
|
122
298
|
|
|
123
|
-
def _resolve(pointer: jsonpointer.JsonPointer,
|
|
124
|
-
"""Resolve a JSON Pointer against the
|
|
299
|
+
def _resolve(pointer: jsonpointer.JsonPointer, event: object) -> object:
|
|
300
|
+
"""Resolve a JSON Pointer against the event, ``None`` if absent."""
|
|
125
301
|
return typing.cast(
|
|
126
302
|
'object',
|
|
127
|
-
pointer.resolve(
|
|
303
|
+
pointer.resolve(event, None), # pyright: ignore[reportUnknownMemberType]
|
|
128
304
|
)
|
|
129
305
|
|
|
130
306
|
|
|
@@ -182,7 +358,7 @@ def _resolve_api_base(
|
|
|
182
358
|
ctx: PluginContext,
|
|
183
359
|
explicit: str | None,
|
|
184
360
|
repo_url_pointer: jsonpointer.JsonPointer,
|
|
185
|
-
|
|
361
|
+
event: object,
|
|
186
362
|
) -> str | None:
|
|
187
363
|
"""Pick the GitHub API base for this call (see module docstring)."""
|
|
188
364
|
if explicit:
|
|
@@ -199,11 +375,11 @@ def _resolve_api_base(
|
|
|
199
375
|
endpoint = ctx.assignment_options.get('service_endpoint')
|
|
200
376
|
if isinstance(endpoint, str) and endpoint:
|
|
201
377
|
return endpoint.rstrip('/')
|
|
202
|
-
base = _api_base_from_repo_url(_resolve(repo_url_pointer,
|
|
378
|
+
base = _api_base_from_repo_url(_resolve(repo_url_pointer, event))
|
|
203
379
|
if base:
|
|
204
380
|
LOGGER.info(
|
|
205
|
-
|
|
206
|
-
'the API base; no api_base_url, connected GitHub plugin, or '
|
|
381
|
+
"github-commit-sync falling back to the event's repository.url "
|
|
382
|
+
'for the API base; no api_base_url, connected GitHub plugin, or '
|
|
207
383
|
'service_endpoint was available'
|
|
208
384
|
)
|
|
209
385
|
return base
|
|
@@ -211,9 +387,9 @@ def _resolve_api_base(
|
|
|
211
387
|
|
|
212
388
|
|
|
213
389
|
def _owner_repo(
|
|
214
|
-
selector: jsonpointer.JsonPointer,
|
|
390
|
+
selector: jsonpointer.JsonPointer, event: object
|
|
215
391
|
) -> tuple[str, str] | None:
|
|
216
|
-
full_name = _resolve(selector,
|
|
392
|
+
full_name = _resolve(selector, event)
|
|
217
393
|
if not isinstance(full_name, str) or '/' not in full_name:
|
|
218
394
|
return None
|
|
219
395
|
owner, _, repo = full_name.partition('/')
|
|
@@ -225,14 +401,14 @@ def _owner_repo(
|
|
|
225
401
|
def _resolve_repo_and_base(
|
|
226
402
|
ctx: PluginContext,
|
|
227
403
|
action_config: SyncCommitsConfig | SyncTagsConfig,
|
|
228
|
-
|
|
404
|
+
event: object,
|
|
229
405
|
) -> tuple[str, str, str] | None:
|
|
230
406
|
"""Resolve ``(owner, repo, api_base)`` for an action.
|
|
231
407
|
|
|
232
408
|
Returns ``None`` (after logging) when the owner/repo or API base
|
|
233
409
|
can't be determined, so both action callables share one short-circuit.
|
|
234
410
|
"""
|
|
235
|
-
owner_repo = _owner_repo(action_config.repository_selector,
|
|
411
|
+
owner_repo = _owner_repo(action_config.repository_selector, event)
|
|
236
412
|
if owner_repo is None:
|
|
237
413
|
LOGGER.warning('github-commit-sync: no owner/repo in push payload')
|
|
238
414
|
return None
|
|
@@ -240,7 +416,7 @@ def _resolve_repo_and_base(
|
|
|
240
416
|
ctx,
|
|
241
417
|
action_config.api_base_url,
|
|
242
418
|
action_config.repo_api_url_selector,
|
|
243
|
-
|
|
419
|
+
event,
|
|
244
420
|
)
|
|
245
421
|
if base is None:
|
|
246
422
|
LOGGER.warning(
|
|
@@ -262,12 +438,104 @@ def _client(base: str, owner: str, repo: str, token: str) -> httpx.AsyncClient:
|
|
|
262
438
|
)
|
|
263
439
|
|
|
264
440
|
|
|
441
|
+
ResolveUser = collections.abc.Callable[
|
|
442
|
+
[str], collections.abc.Awaitable[str | None]
|
|
443
|
+
]
|
|
444
|
+
|
|
445
|
+
# Process-wide, bounded LRU cache of resolved commit-author identities,
|
|
446
|
+
# keyed by ``(api_base, subject)``. The subject is GitHub's numeric user
|
|
447
|
+
# id, which is only unique *per host* (github.com id 42 != a GHES id 42),
|
|
448
|
+
# so the resolved API base scopes the key. Only *successful* resolutions
|
|
449
|
+
# are cached: within a single sync :func:`_resolve_author_users` already
|
|
450
|
+
# de-dupes subjects, and leaving misses uncached means a contributor who
|
|
451
|
+
# links their Imbi identity later is picked up on the next sync instead
|
|
452
|
+
# of being stuck unresolved for the process's lifetime. An
|
|
453
|
+
# ``OrderedDict`` gives LRU eviction once the cache exceeds
|
|
454
|
+
# ``_USER_CACHE_MAX`` entries.
|
|
455
|
+
_USER_CACHE: collections.OrderedDict[tuple[str, str], str] = (
|
|
456
|
+
collections.OrderedDict()
|
|
457
|
+
)
|
|
458
|
+
_USER_CACHE_MAX = 8192
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
async def _resolve_user(
|
|
462
|
+
resolver: ResolveUser, base: str, subject: str
|
|
463
|
+
) -> str | None:
|
|
464
|
+
"""Resolve a GitHub user id to an Imbi email, LRU-cached per host.
|
|
465
|
+
|
|
466
|
+
``subject`` is the GitHub numeric user id (the identity-plugin
|
|
467
|
+
subject). Only successful resolutions are memoized under
|
|
468
|
+
``(base, subject)``; a miss is returned but not cached, so a
|
|
469
|
+
contributor who links their Imbi identity later resolves on a
|
|
470
|
+
subsequent sync rather than being memoized as unresolved for the
|
|
471
|
+
process's lifetime.
|
|
472
|
+
"""
|
|
473
|
+
key = (base, subject)
|
|
474
|
+
if key in _USER_CACHE:
|
|
475
|
+
_USER_CACHE.move_to_end(key)
|
|
476
|
+
return _USER_CACHE[key]
|
|
477
|
+
email = await resolver(subject)
|
|
478
|
+
if email is None:
|
|
479
|
+
return None
|
|
480
|
+
_USER_CACHE[key] = email
|
|
481
|
+
_USER_CACHE.move_to_end(key)
|
|
482
|
+
if len(_USER_CACHE) > _USER_CACHE_MAX:
|
|
483
|
+
_USER_CACHE.popitem(last=False)
|
|
484
|
+
return email
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
async def _resolve_author_users(
|
|
488
|
+
raw: list[dict[str, typing.Any]],
|
|
489
|
+
resolver: ResolveUser | None,
|
|
490
|
+
base: str,
|
|
491
|
+
) -> dict[str, str]:
|
|
492
|
+
"""Map each commit's GitHub author id to a resolved Imbi email.
|
|
493
|
+
|
|
494
|
+
Returns an empty map when the host wired no resolver. Distinct
|
|
495
|
+
author ids are resolved once each (LRU-cached across syncs); misses
|
|
496
|
+
are dropped, so the map carries only positive matches and
|
|
497
|
+
:func:`_author_user` falls back to ``''`` for everyone else.
|
|
498
|
+
"""
|
|
499
|
+
if resolver is None:
|
|
500
|
+
return {}
|
|
501
|
+
subjects: set[str] = set()
|
|
502
|
+
for item in raw:
|
|
503
|
+
gh_author: dict[str, typing.Any] = item.get('author') or {}
|
|
504
|
+
gid = gh_author.get('id')
|
|
505
|
+
if gid is not None:
|
|
506
|
+
subjects.add(str(gid))
|
|
507
|
+
out: dict[str, str] = {}
|
|
508
|
+
for subject in subjects:
|
|
509
|
+
try:
|
|
510
|
+
email = await _resolve_user(resolver, base, subject)
|
|
511
|
+
except Exception as exc: # noqa: BLE001 - attribution is best-effort
|
|
512
|
+
LOGGER.warning(
|
|
513
|
+
'github-commit-sync: failed to resolve author %s; '
|
|
514
|
+
'leaving unattributed: %s',
|
|
515
|
+
subject,
|
|
516
|
+
exc,
|
|
517
|
+
)
|
|
518
|
+
continue
|
|
519
|
+
if email:
|
|
520
|
+
out[subject] = email
|
|
521
|
+
return out
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _author_user(item: dict[str, typing.Any], user_map: dict[str, str]) -> str:
|
|
525
|
+
"""Resolved Imbi email for a commit's author, ``''`` when unmatched."""
|
|
526
|
+
gh_author: dict[str, typing.Any] = item.get('author') or {}
|
|
527
|
+
gid = gh_author.get('id')
|
|
528
|
+
return user_map.get(str(gid), '') if gid is not None else ''
|
|
529
|
+
|
|
530
|
+
|
|
265
531
|
def _commit_record(
|
|
266
532
|
item: dict[str, typing.Any],
|
|
267
533
|
*,
|
|
268
534
|
project_id: str,
|
|
269
535
|
ref: str,
|
|
270
536
|
pushed_at: datetime.datetime,
|
|
537
|
+
author_user: str = '',
|
|
538
|
+
ci_status: CheckStatus = 'unknown',
|
|
271
539
|
) -> CommitRecord:
|
|
272
540
|
"""Map a GitHub commit object onto a :class:`CommitRecord`.
|
|
273
541
|
|
|
@@ -275,7 +543,8 @@ def _commit_record(
|
|
|
275
543
|
email + linked login + committer), which the UI-facing
|
|
276
544
|
``Commit``/``_commit_from_payload`` mapping drops, so it maps the raw
|
|
277
545
|
item directly while reusing the low-level ``_short_sha`` / ``_parse_iso``
|
|
278
|
-
helpers.
|
|
546
|
+
helpers. ``ci_status`` is supplied by the caller (hydrated from
|
|
547
|
+
``/check-runs``); it defaults to ``'unknown'`` when not resolved.
|
|
279
548
|
"""
|
|
280
549
|
sha = str(item.get('sha') or '')
|
|
281
550
|
commit_meta: dict[str, typing.Any] = item.get('commit') or {}
|
|
@@ -292,7 +561,9 @@ def _commit_record(
|
|
|
292
561
|
author_name=str(author_meta.get('name') or ''),
|
|
293
562
|
author_email=str(author_meta.get('email') or ''),
|
|
294
563
|
author_login=str(gh_author.get('login') or ''),
|
|
564
|
+
author_user=author_user,
|
|
295
565
|
committer_name=str(committer_meta.get('name') or ''),
|
|
566
|
+
ci_status=ci_status,
|
|
296
567
|
authored_at=authored_at or pushed_at,
|
|
297
568
|
committed_at=_parse_iso(committer_meta.get('date')),
|
|
298
569
|
url=str(item.get('html_url') or ''),
|
|
@@ -301,7 +572,7 @@ def _commit_record(
|
|
|
301
572
|
|
|
302
573
|
|
|
303
574
|
async def _fetch_compare_commits(
|
|
304
|
-
client: httpx.AsyncClient, base: str, head: str
|
|
575
|
+
client: httpx.AsyncClient, base: str, head: str, *, max_wait: float
|
|
305
576
|
) -> list[dict[str, typing.Any]]:
|
|
306
577
|
"""``GET /compare/{base}...{head}`` following the Link pagination."""
|
|
307
578
|
quoted = urllib.parse.quote(f'{base}...{head}', safe='.')
|
|
@@ -309,7 +580,9 @@ async def _fetch_compare_commits(
|
|
|
309
580
|
params: dict[str, str] = {'per_page': '250'}
|
|
310
581
|
commits: list[dict[str, typing.Any]] = []
|
|
311
582
|
for page in range(1, _MAX_COMPARE_PAGES + 1):
|
|
312
|
-
resp = await
|
|
583
|
+
resp = await _request(
|
|
584
|
+
client, 'GET', path, params=params, max_wait=max_wait
|
|
585
|
+
)
|
|
313
586
|
resp.raise_for_status()
|
|
314
587
|
payload = typing.cast('dict[str, typing.Any]', resp.json())
|
|
315
588
|
commits.extend(payload.get('commits') or [])
|
|
@@ -356,17 +629,106 @@ async def _last_known_sha(project_id: str) -> str | None:
|
|
|
356
629
|
|
|
357
630
|
|
|
358
631
|
async def _fetch_recent_commits(
|
|
359
|
-
client: httpx.AsyncClient, head: str, limit: int
|
|
632
|
+
client: httpx.AsyncClient, head: str, limit: int, *, max_wait: float
|
|
360
633
|
) -> list[dict[str, typing.Any]]:
|
|
361
634
|
"""Bounded ``GET /commits?sha={head}`` fallback for new branches."""
|
|
362
|
-
resp = await
|
|
635
|
+
resp = await _request(
|
|
636
|
+
client,
|
|
637
|
+
'GET',
|
|
363
638
|
'/commits',
|
|
364
639
|
params={'sha': head, 'per_page': str(max(1, min(limit, 100)))},
|
|
640
|
+
max_wait=max_wait,
|
|
365
641
|
)
|
|
366
642
|
resp.raise_for_status()
|
|
367
643
|
return typing.cast('list[dict[str, typing.Any]]', resp.json())
|
|
368
644
|
|
|
369
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
|
+
|
|
370
732
|
def _resolve_host_for_context(ctx: PluginContext) -> str | None:
|
|
371
733
|
"""Resolve the GitHub web host for an on-demand sync (no payload).
|
|
372
734
|
|
|
@@ -386,20 +748,22 @@ def _resolve_host_for_context(ctx: PluginContext) -> str | None:
|
|
|
386
748
|
return None
|
|
387
749
|
|
|
388
750
|
|
|
389
|
-
async def _fetch_default_branch(
|
|
751
|
+
async def _fetch_default_branch(
|
|
752
|
+
client: httpx.AsyncClient, *, max_wait: float
|
|
753
|
+
) -> str:
|
|
390
754
|
"""Return the repo's default branch name (``main`` when unknown)."""
|
|
391
755
|
# httpx normalises ``base_url`` with a trailing slash; GHEC's gateway
|
|
392
756
|
# 404s on ``/repos/<o>/<r>/`` so request the absolute URL with the
|
|
393
757
|
# trailing slash stripped, matching the deployment plugin.
|
|
394
758
|
url = str(client.base_url).rstrip('/')
|
|
395
|
-
resp = await client
|
|
759
|
+
resp = await _request(client, 'GET', url, max_wait=max_wait)
|
|
396
760
|
resp.raise_for_status()
|
|
397
761
|
meta = typing.cast('dict[str, typing.Any]', resp.json())
|
|
398
762
|
return str(meta.get('default_branch') or 'main')
|
|
399
763
|
|
|
400
764
|
|
|
401
765
|
async def _fetch_all_commits(
|
|
402
|
-
client: httpx.AsyncClient, branch: str
|
|
766
|
+
client: httpx.AsyncClient, branch: str, *, max_wait: float
|
|
403
767
|
) -> list[dict[str, typing.Any]]:
|
|
404
768
|
"""Walk every commit reachable from ``branch`` via Link pagination.
|
|
405
769
|
|
|
@@ -409,7 +773,9 @@ async def _fetch_all_commits(
|
|
|
409
773
|
params: dict[str, str] = {'sha': branch, 'per_page': '100'}
|
|
410
774
|
out: list[dict[str, typing.Any]] = []
|
|
411
775
|
for page in range(1, _MAX_HISTORY_PAGES + 1):
|
|
412
|
-
resp = await
|
|
776
|
+
resp = await _request(
|
|
777
|
+
client, 'GET', '/commits', params=params, max_wait=max_wait
|
|
778
|
+
)
|
|
413
779
|
resp.raise_for_status()
|
|
414
780
|
out.extend(typing.cast('list[dict[str, typing.Any]]', resp.json()))
|
|
415
781
|
next_url = _next_page_url(resp.headers.get('link'))
|
|
@@ -455,46 +821,58 @@ async def _insert_best_effort(
|
|
|
455
821
|
|
|
456
822
|
|
|
457
823
|
class SyncCommitsConfig(pydantic.BaseModel):
|
|
458
|
-
"""``WebhookRule.handler_config`` for ``sync_commits``.
|
|
824
|
+
"""``WebhookRule.handler_config`` for ``sync_commits``.
|
|
825
|
+
|
|
826
|
+
Selectors resolve against the event context, so the push body lives
|
|
827
|
+
under ``/payload`` (e.g. ``/payload/after``).
|
|
828
|
+
"""
|
|
459
829
|
|
|
460
830
|
before_selector: JsonPointer = pydantic.Field(
|
|
461
|
-
default_factory=lambda: jsonpointer.JsonPointer('/before')
|
|
831
|
+
default_factory=lambda: jsonpointer.JsonPointer('/payload/before')
|
|
462
832
|
)
|
|
463
833
|
after_selector: JsonPointer = pydantic.Field(
|
|
464
|
-
default_factory=lambda: jsonpointer.JsonPointer('/after')
|
|
834
|
+
default_factory=lambda: jsonpointer.JsonPointer('/payload/after')
|
|
465
835
|
)
|
|
466
836
|
ref_selector: JsonPointer = pydantic.Field(
|
|
467
|
-
default_factory=lambda: jsonpointer.JsonPointer('/ref')
|
|
837
|
+
default_factory=lambda: jsonpointer.JsonPointer('/payload/ref')
|
|
468
838
|
)
|
|
469
839
|
repository_selector: JsonPointer = pydantic.Field(
|
|
470
840
|
default_factory=lambda: jsonpointer.JsonPointer(
|
|
471
|
-
'/repository/full_name'
|
|
841
|
+
'/payload/repository/full_name'
|
|
472
842
|
)
|
|
473
843
|
)
|
|
474
844
|
api_base_url: str | None = None
|
|
475
845
|
repo_api_url_selector: JsonPointer = pydantic.Field(
|
|
476
|
-
default_factory=lambda: jsonpointer.JsonPointer(
|
|
846
|
+
default_factory=lambda: jsonpointer.JsonPointer(
|
|
847
|
+
'/payload/repository/url'
|
|
848
|
+
)
|
|
477
849
|
)
|
|
478
850
|
initial_limit: int = 100
|
|
479
851
|
|
|
480
852
|
|
|
481
853
|
class SyncTagsConfig(pydantic.BaseModel):
|
|
482
|
-
"""``WebhookRule.handler_config`` for ``sync_tags``.
|
|
854
|
+
"""``WebhookRule.handler_config`` for ``sync_tags``.
|
|
855
|
+
|
|
856
|
+
Selectors resolve against the event context, so the push body lives
|
|
857
|
+
under ``/payload`` (e.g. ``/payload/ref``).
|
|
858
|
+
"""
|
|
483
859
|
|
|
484
860
|
ref_selector: JsonPointer = pydantic.Field(
|
|
485
|
-
default_factory=lambda: jsonpointer.JsonPointer('/ref')
|
|
861
|
+
default_factory=lambda: jsonpointer.JsonPointer('/payload/ref')
|
|
486
862
|
)
|
|
487
863
|
after_selector: JsonPointer = pydantic.Field(
|
|
488
|
-
default_factory=lambda: jsonpointer.JsonPointer('/after')
|
|
864
|
+
default_factory=lambda: jsonpointer.JsonPointer('/payload/after')
|
|
489
865
|
)
|
|
490
866
|
repository_selector: JsonPointer = pydantic.Field(
|
|
491
867
|
default_factory=lambda: jsonpointer.JsonPointer(
|
|
492
|
-
'/repository/full_name'
|
|
868
|
+
'/payload/repository/full_name'
|
|
493
869
|
)
|
|
494
870
|
)
|
|
495
871
|
api_base_url: str | None = None
|
|
496
872
|
repo_api_url_selector: JsonPointer = pydantic.Field(
|
|
497
|
-
default_factory=lambda: jsonpointer.JsonPointer(
|
|
873
|
+
default_factory=lambda: jsonpointer.JsonPointer(
|
|
874
|
+
'/payload/repository/url'
|
|
875
|
+
)
|
|
498
876
|
)
|
|
499
877
|
reconcile_all: bool = False
|
|
500
878
|
|
|
@@ -505,7 +883,7 @@ async def sync_commits(
|
|
|
505
883
|
credentials: dict[str, str],
|
|
506
884
|
external_identifier: str,
|
|
507
885
|
action_config: SyncCommitsConfig,
|
|
508
|
-
|
|
886
|
+
event: object,
|
|
509
887
|
) -> None:
|
|
510
888
|
"""Sync the commits in a ``push`` delivery into the ``commits`` table.
|
|
511
889
|
|
|
@@ -513,32 +891,66 @@ async def sync_commits(
|
|
|
513
891
|
action does not filter refs itself.
|
|
514
892
|
"""
|
|
515
893
|
del external_identifier
|
|
516
|
-
after = _resolve(action_config.after_selector,
|
|
894
|
+
after = _resolve(action_config.after_selector, event)
|
|
517
895
|
if not isinstance(after, str) or not after or after == _ZERO_SHA:
|
|
518
896
|
return # branch delete / no head -- nothing to sync
|
|
519
|
-
resolved = _resolve_repo_and_base(ctx, action_config,
|
|
897
|
+
resolved = _resolve_repo_and_base(ctx, action_config, event)
|
|
520
898
|
if resolved is None:
|
|
521
899
|
return
|
|
522
900
|
owner, repo, base = resolved
|
|
523
|
-
ref_raw = _resolve(action_config.ref_selector,
|
|
901
|
+
ref_raw = _resolve(action_config.ref_selector, event)
|
|
524
902
|
ref = _branch_short_name(ref_raw if isinstance(ref_raw, str) else '')
|
|
525
|
-
before = _resolve(action_config.before_selector,
|
|
903
|
+
before = _resolve(action_config.before_selector, event)
|
|
526
904
|
pushed_at = datetime.datetime.now(datetime.UTC)
|
|
527
905
|
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
raw = await _fetch_compare_commits(client, last, after)
|
|
535
|
-
else:
|
|
536
|
-
raw = await _fetch_recent_commits(
|
|
537
|
-
client, after, action_config.initial_limit
|
|
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:
|
|
910
|
+
raw = await _fetch_compare_commits(
|
|
911
|
+
client, before, after, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
|
|
538
912
|
)
|
|
913
|
+
else:
|
|
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
|
|
943
|
+
user_map = await _resolve_author_users(
|
|
944
|
+
raw, ctx.resolve_user_by_identity, base
|
|
945
|
+
)
|
|
539
946
|
records: list[pydantic.BaseModel] = [
|
|
540
947
|
_commit_record(
|
|
541
|
-
item,
|
|
948
|
+
item,
|
|
949
|
+
project_id=ctx.project_id,
|
|
950
|
+
ref=ref,
|
|
951
|
+
pushed_at=pushed_at,
|
|
952
|
+
author_user=_author_user(item, user_map),
|
|
953
|
+
ci_status=ci_by_sha.get(str(item['sha']), 'unknown'),
|
|
542
954
|
)
|
|
543
955
|
for item in raw
|
|
544
956
|
if item.get('sha')
|
|
@@ -556,15 +968,82 @@ async def sync_commits(
|
|
|
556
968
|
|
|
557
969
|
|
|
558
970
|
async def _annotated_tag(
|
|
559
|
-
client: httpx.AsyncClient, sha: str
|
|
971
|
+
client: httpx.AsyncClient, sha: str, *, max_wait: float
|
|
560
972
|
) -> dict[str, typing.Any] | None:
|
|
561
973
|
"""Fetch annotated-tag metadata, ``None`` for a lightweight tag."""
|
|
562
|
-
resp = await client
|
|
974
|
+
resp = await _request(client, 'GET', f'/git/tags/{sha}', max_wait=max_wait)
|
|
563
975
|
if resp.status_code != 200:
|
|
564
976
|
return None
|
|
565
977
|
return typing.cast('dict[str, typing.Any]', resp.json())
|
|
566
978
|
|
|
567
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
|
+
|
|
568
1047
|
def _web_base(api_base: str) -> str:
|
|
569
1048
|
"""Map a REST API base to the web host (inverse of the routing table).
|
|
570
1049
|
|
|
@@ -596,9 +1075,24 @@ def _tag_record(
|
|
|
596
1075
|
sha: str,
|
|
597
1076
|
annotated: dict[str, typing.Any] | None = None,
|
|
598
1077
|
url: str = '',
|
|
1078
|
+
published_at: datetime.datetime | None = None,
|
|
1079
|
+
fallback_tagged_at: datetime.datetime | None = None,
|
|
599
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
|
+
"""
|
|
600
1088
|
if annotated is None:
|
|
601
|
-
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
|
+
)
|
|
602
1096
|
tagger: dict[str, typing.Any] = annotated.get('tagger') or {}
|
|
603
1097
|
return TagRecord(
|
|
604
1098
|
project_id=project_id,
|
|
@@ -608,12 +1102,12 @@ def _tag_record(
|
|
|
608
1102
|
message=str(annotated.get('message') or ''),
|
|
609
1103
|
tagger_name=str(tagger.get('name') or ''),
|
|
610
1104
|
tagger_email=str(tagger.get('email') or ''),
|
|
611
|
-
tagged_at=_parse_iso(tagger.get('date')),
|
|
1105
|
+
tagged_at=published_at or _parse_iso(tagger.get('date')),
|
|
612
1106
|
)
|
|
613
1107
|
|
|
614
1108
|
|
|
615
1109
|
async def _reconcile_tags(
|
|
616
|
-
client: httpx.AsyncClient, project_id: str
|
|
1110
|
+
client: httpx.AsyncClient, project_id: str, *, max_wait: float
|
|
617
1111
|
) -> list[TagRecord]:
|
|
618
1112
|
"""Upsert the repo's full tag list via the git-refs API.
|
|
619
1113
|
|
|
@@ -625,8 +1119,16 @@ async def _reconcile_tags(
|
|
|
625
1119
|
out: list[TagRecord] = []
|
|
626
1120
|
prefix = 'refs/tags/'
|
|
627
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
|
|
628
1124
|
while True:
|
|
629
|
-
resp = await
|
|
1125
|
+
resp = await _request(
|
|
1126
|
+
client,
|
|
1127
|
+
'GET',
|
|
1128
|
+
'/git/matching-refs/tags',
|
|
1129
|
+
params=params,
|
|
1130
|
+
max_wait=max_wait,
|
|
1131
|
+
)
|
|
630
1132
|
resp.raise_for_status()
|
|
631
1133
|
rows = typing.cast('list[dict[str, typing.Any]]', resp.json())
|
|
632
1134
|
for row in rows:
|
|
@@ -636,8 +1138,13 @@ async def _reconcile_tags(
|
|
|
636
1138
|
if not ref.startswith(prefix) or not sha:
|
|
637
1139
|
continue
|
|
638
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)
|
|
639
1146
|
annotated = (
|
|
640
|
-
await _annotated_tag(client, sha)
|
|
1147
|
+
await _annotated_tag(client, sha, max_wait=max_wait)
|
|
641
1148
|
if obj.get('type') == 'tag'
|
|
642
1149
|
else None
|
|
643
1150
|
)
|
|
@@ -648,6 +1155,12 @@ async def _reconcile_tags(
|
|
|
648
1155
|
sha=sha,
|
|
649
1156
|
annotated=annotated,
|
|
650
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
|
+
),
|
|
651
1164
|
)
|
|
652
1165
|
)
|
|
653
1166
|
next_url = _next_page_url(resp.headers.get('link'))
|
|
@@ -665,16 +1178,16 @@ async def sync_tags(
|
|
|
665
1178
|
credentials: dict[str, str],
|
|
666
1179
|
external_identifier: str,
|
|
667
1180
|
action_config: SyncTagsConfig,
|
|
668
|
-
|
|
1181
|
+
event: object,
|
|
669
1182
|
) -> None:
|
|
670
1183
|
"""Sync the tag in a ``push`` delivery into the ``tags`` table."""
|
|
671
1184
|
del external_identifier
|
|
672
|
-
ref_raw = _resolve(action_config.ref_selector,
|
|
1185
|
+
ref_raw = _resolve(action_config.ref_selector, event)
|
|
673
1186
|
prefix = 'refs/tags/'
|
|
674
1187
|
if not isinstance(ref_raw, str) or not ref_raw.startswith(prefix):
|
|
675
1188
|
return
|
|
676
1189
|
name = ref_raw[len(prefix) :]
|
|
677
|
-
after = _resolve(action_config.after_selector,
|
|
1190
|
+
after = _resolve(action_config.after_selector, event)
|
|
678
1191
|
if (
|
|
679
1192
|
not name
|
|
680
1193
|
or not isinstance(after, str)
|
|
@@ -682,26 +1195,50 @@ async def sync_tags(
|
|
|
682
1195
|
or after == _ZERO_SHA
|
|
683
1196
|
):
|
|
684
1197
|
return # tag delete / no target
|
|
685
|
-
resolved = _resolve_repo_and_base(ctx, action_config,
|
|
1198
|
+
resolved = _resolve_repo_and_base(ctx, action_config, event)
|
|
686
1199
|
if resolved is None:
|
|
687
1200
|
return
|
|
688
1201
|
owner, repo, base = resolved
|
|
689
1202
|
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
project_id=ctx.project_id,
|
|
695
|
-
name=name,
|
|
696
|
-
sha=after,
|
|
697
|
-
annotated=annotated,
|
|
698
|
-
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
|
|
699
1207
|
)
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
1208
|
+
published = await _release_published_for_tag(
|
|
1209
|
+
client, name, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
|
|
1210
|
+
)
|
|
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
|
|
705
1242
|
try:
|
|
706
1243
|
await clickhouse.insert('tags', records)
|
|
707
1244
|
except Exception:
|
|
@@ -831,7 +1368,10 @@ class GitHubCommitSyncPlugin(WebhookActionPlugin):
|
|
|
831
1368
|
Returns ``(commits_recorded, tags_recorded)``. Raises
|
|
832
1369
|
:class:`ValueError` only when the host or repository can't be
|
|
833
1370
|
resolved; ClickHouse failures are swallowed (the count reflects
|
|
834
|
-
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.
|
|
835
1375
|
"""
|
|
836
1376
|
host = _resolve_host_for_context(ctx)
|
|
837
1377
|
if host is None:
|
|
@@ -844,15 +1384,42 @@ class GitHubCommitSyncPlugin(WebhookActionPlugin):
|
|
|
844
1384
|
pushed_at = datetime.datetime.now(datetime.UTC)
|
|
845
1385
|
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
846
1386
|
async with _client(base, owner, repo, token) as client:
|
|
847
|
-
branch = await _fetch_default_branch(
|
|
848
|
-
|
|
849
|
-
|
|
1387
|
+
branch = await _fetch_default_branch(
|
|
1388
|
+
client, max_wait=_BACKFILL_MAX_WAIT_SECONDS
|
|
1389
|
+
)
|
|
1390
|
+
raw_commits = await _fetch_all_commits(
|
|
1391
|
+
client, branch, max_wait=_BACKFILL_MAX_WAIT_SECONDS
|
|
1392
|
+
)
|
|
1393
|
+
tags = await _reconcile_tags(
|
|
1394
|
+
client, ctx.project_id, max_wait=_BACKFILL_MAX_WAIT_SECONDS
|
|
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
|
+
)
|
|
1412
|
+
user_map = await _resolve_author_users(
|
|
1413
|
+
raw_commits, ctx.resolve_user_by_identity, base
|
|
1414
|
+
)
|
|
850
1415
|
commit_records: list[pydantic.BaseModel] = [
|
|
851
1416
|
_commit_record(
|
|
852
1417
|
item,
|
|
853
1418
|
project_id=ctx.project_id,
|
|
854
1419
|
ref=branch,
|
|
855
1420
|
pushed_at=pushed_at,
|
|
1421
|
+
author_user=_author_user(item, user_map),
|
|
1422
|
+
ci_status=ci_by_sha.get(str(item['sha']), 'unknown'),
|
|
856
1423
|
)
|
|
857
1424
|
for item in raw_commits
|
|
858
1425
|
if item.get('sha')
|
imbi_plugin_github/deployment.py
CHANGED
|
@@ -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,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
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
imbi_plugin_github/__init__.py,sha256=8ZVhm8XpGqgYRUtpHl0uuZTT3gmMyHNhKd9k6qoatB4,298
|
|
2
|
+
imbi_plugin_github/_app_auth.py,sha256=LnLrvqmu2UC27uHHJfIzFPNDh33RtgeOX1RXceX9Pj0,7000
|
|
3
|
+
imbi_plugin_github/_hosts.py,sha256=jQ1Z6yEpjAa4b7LocLfEelnBcRYYQLNLBNCdWZtAXUc,2258
|
|
4
|
+
imbi_plugin_github/_repos.py,sha256=agp4ZgRuqbrGBRMFeHomnUf1HvvaNvapBCWHQebdviQ,4559
|
|
5
|
+
imbi_plugin_github/commits.py,sha256=Pp4-LHnNpDrhsDbJSmQQO7qLgWiaBpOBPQJKTkTcn5w,53014
|
|
6
|
+
imbi_plugin_github/deployment.py,sha256=d82G7zGcN-A_1J19tUZuzVzK3AOfvIbowtGJ6zLEeck,48287
|
|
7
|
+
imbi_plugin_github/identity.py,sha256=RSs1cLQpZmfyQ_0KQkohDIZEBYVBnre6RJIt166EtdQ,15525
|
|
8
|
+
imbi_plugin_github/lifecycle.py,sha256=48mLKjFPG7BOanpA7jWnmbWOdsDLh1F39g2AoxDLL7M,33211
|
|
9
|
+
imbi_plugin_github-2.11.0.dist-info/METADATA,sha256=wJlhW2IAWTwZ20BTw91MPtjRJp-MnO1s7fYlqMzFGds,5811
|
|
10
|
+
imbi_plugin_github-2.11.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
imbi_plugin_github-2.11.0.dist-info/entry_points.txt,sha256=RavN_FYCeS97iauHgCG3tHFdVULbEMXmQfvgCgLeGkI,805
|
|
12
|
+
imbi_plugin_github-2.11.0.dist-info/licenses/LICENSE,sha256=el9B20zOqEcv_mRrHgbdkVyxSC6I8np7am0D6mO2A9Y,1491
|
|
13
|
+
imbi_plugin_github-2.11.0.dist-info/RECORD,,
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
imbi_plugin_github/__init__.py,sha256=8ZVhm8XpGqgYRUtpHl0uuZTT3gmMyHNhKd9k6qoatB4,298
|
|
2
|
-
imbi_plugin_github/_app_auth.py,sha256=LnLrvqmu2UC27uHHJfIzFPNDh33RtgeOX1RXceX9Pj0,7000
|
|
3
|
-
imbi_plugin_github/_hosts.py,sha256=jQ1Z6yEpjAa4b7LocLfEelnBcRYYQLNLBNCdWZtAXUc,2258
|
|
4
|
-
imbi_plugin_github/_repos.py,sha256=agp4ZgRuqbrGBRMFeHomnUf1HvvaNvapBCWHQebdviQ,4559
|
|
5
|
-
imbi_plugin_github/commits.py,sha256=LXC2dkPIMmPxyB3FKujD_6nTE3F0CVux-v3nI72Q-QE,31549
|
|
6
|
-
imbi_plugin_github/deployment.py,sha256=V6YlZX24m33BPJWkC-vCZZLSsH71etkl0xkhkDsKASg,47914
|
|
7
|
-
imbi_plugin_github/identity.py,sha256=RSs1cLQpZmfyQ_0KQkohDIZEBYVBnre6RJIt166EtdQ,15525
|
|
8
|
-
imbi_plugin_github/lifecycle.py,sha256=48mLKjFPG7BOanpA7jWnmbWOdsDLh1F39g2AoxDLL7M,33211
|
|
9
|
-
imbi_plugin_github-2.9.3.dist-info/METADATA,sha256=eLq2egFOhfiG4vxXTSkm6zcX73RpF0je4Pt8i5IG2X0,5809
|
|
10
|
-
imbi_plugin_github-2.9.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
-
imbi_plugin_github-2.9.3.dist-info/entry_points.txt,sha256=RavN_FYCeS97iauHgCG3tHFdVULbEMXmQfvgCgLeGkI,805
|
|
12
|
-
imbi_plugin_github-2.9.3.dist-info/licenses/LICENSE,sha256=el9B20zOqEcv_mRrHgbdkVyxSC6I8np7am0D6mO2A9Y,1491
|
|
13
|
-
imbi_plugin_github-2.9.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|