imbi-plugin-github 2.9.2__py3-none-any.whl → 2.10.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 +389 -62
- {imbi_plugin_github-2.9.2.dist-info → imbi_plugin_github-2.10.0.dist-info}/METADATA +2 -2
- {imbi_plugin_github-2.9.2.dist-info → imbi_plugin_github-2.10.0.dist-info}/RECORD +6 -6
- {imbi_plugin_github-2.9.2.dist-info → imbi_plugin_github-2.10.0.dist-info}/WHEEL +0 -0
- {imbi_plugin_github-2.9.2.dist-info → imbi_plugin_github-2.10.0.dist-info}/entry_points.txt +0 -0
- {imbi_plugin_github-2.9.2.dist-info → imbi_plugin_github-2.10.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
|
|
|
@@ -88,6 +92,136 @@ _MAX_COMPARE_PAGES = 20
|
|
|
88
92
|
# walk logs a truncation warning when it hits the cap.
|
|
89
93
|
_MAX_HISTORY_PAGES = 100
|
|
90
94
|
|
|
95
|
+
# Per-context ceilings on how long a *single* rate-limit pause may block
|
|
96
|
+
# before the sync gives up best-effort (logged). The webhook actions
|
|
97
|
+
# (``sync_commits`` / ``sync_tags``) run inside the gateway's request
|
|
98
|
+
# path, so they pause only briefly and bail when GitHub's reset is
|
|
99
|
+
# further out -- a later push re-syncs the gap (``ReplacingMergeTree``
|
|
100
|
+
# dedupes). The on-demand backfill (``sync_all_history``) runs in a
|
|
101
|
+
# background worker and can wait out a full primary-limit reset.
|
|
102
|
+
_WEBHOOK_MAX_WAIT_SECONDS = 60.0
|
|
103
|
+
_BACKFILL_MAX_WAIT_SECONDS = 900.0
|
|
104
|
+
# GitHub's documented floor to wait when a secondary (abuse) rate-limit
|
|
105
|
+
# response carries neither ``retry-after`` nor an exhausted
|
|
106
|
+
# ``x-ratelimit-*`` to time the resume from.
|
|
107
|
+
_SECONDARY_LIMIT_WAIT_SECONDS = 60.0
|
|
108
|
+
# Small cushion added to a primary-limit reset wait so minor clock skew
|
|
109
|
+
# between us and GitHub can't wake us a hair early into a re-throttle.
|
|
110
|
+
_RESET_BUFFER_SECONDS = 1.0
|
|
111
|
+
# Pathological-loop guard: how many times one request may be paused and
|
|
112
|
+
# retried before giving up. A primary reset / ``retry-after`` normally
|
|
113
|
+
# clears the limit on the first retry, so this only bounds a misbehaving
|
|
114
|
+
# endpoint.
|
|
115
|
+
_MAX_THROTTLE_RETRIES = 3
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _header_int(response: httpx.Response, name: str) -> int | None:
|
|
119
|
+
"""Parse an integer response header, ``None`` when absent/malformed."""
|
|
120
|
+
raw = response.headers.get(name)
|
|
121
|
+
if raw is None:
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
return int(raw)
|
|
125
|
+
except ValueError:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _throttle_wait(response: httpx.Response) -> float | None:
|
|
130
|
+
"""Seconds to pause for GitHub's rate-limit headers, else ``None``.
|
|
131
|
+
|
|
132
|
+
Detects both throttle styles GitHub documents and reads the resume
|
|
133
|
+
time straight from the response:
|
|
134
|
+
|
|
135
|
+
* **secondary limit** -- a ``retry-after`` header (any status): honor
|
|
136
|
+
it verbatim;
|
|
137
|
+
* **primary limit** -- ``x-ratelimit-remaining: 0`` with an
|
|
138
|
+
``x-ratelimit-reset`` epoch: wait until the reset (plus a small
|
|
139
|
+
clock-skew cushion);
|
|
140
|
+
* a 403/429 that exhausted the quota but gives no reset -> the
|
|
141
|
+
conservative ``_SECONDARY_LIMIT_WAIT_SECONDS`` floor.
|
|
142
|
+
|
|
143
|
+
Returns ``None`` for anything that is *not* a throttle signal --
|
|
144
|
+
including a non-rate-limit 403 (e.g. insufficient scope), which must
|
|
145
|
+
fall through to the caller's normal error handling rather than be
|
|
146
|
+
mistaken for a pause. A successful response that merely depleted the
|
|
147
|
+
quota (``remaining == 0`` on a 2xx) also yields a wait, so the caller
|
|
148
|
+
can pause pre-emptively before the next request.
|
|
149
|
+
"""
|
|
150
|
+
retry_after = response.headers.get('retry-after')
|
|
151
|
+
if retry_after is not None:
|
|
152
|
+
try:
|
|
153
|
+
return max(0.0, float(retry_after))
|
|
154
|
+
except ValueError:
|
|
155
|
+
return _SECONDARY_LIMIT_WAIT_SECONDS
|
|
156
|
+
if _header_int(response, 'x-ratelimit-remaining') == 0:
|
|
157
|
+
reset = _header_int(response, 'x-ratelimit-reset')
|
|
158
|
+
if reset is not None:
|
|
159
|
+
return max(0.0, reset - time.time()) + _RESET_BUFFER_SECONDS
|
|
160
|
+
if response.status_code in (403, 429):
|
|
161
|
+
return _SECONDARY_LIMIT_WAIT_SECONDS
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def _request(
|
|
166
|
+
client: httpx.AsyncClient,
|
|
167
|
+
method: str,
|
|
168
|
+
url: str,
|
|
169
|
+
*,
|
|
170
|
+
max_wait: float,
|
|
171
|
+
**kwargs: typing.Any,
|
|
172
|
+
) -> httpx.Response:
|
|
173
|
+
"""Issue a request, pausing and resuming on GitHub rate limits.
|
|
174
|
+
|
|
175
|
+
On a throttled response (403/429 carrying a rate-limit signal) sleeps
|
|
176
|
+
for the interval GitHub states in its headers, then retries -- up to
|
|
177
|
+
``_MAX_THROTTLE_RETRIES`` times and never longer than *max_wait* on a
|
|
178
|
+
single pause. When the stated wait exceeds *max_wait* (or the retries
|
|
179
|
+
are exhausted) the throttled response is handed back unchanged so the
|
|
180
|
+
caller's ``raise_for_status`` surfaces it and the sync bails
|
|
181
|
+
best-effort. After a *successful* response that exhausted the
|
|
182
|
+
remaining quota, pauses pre-emptively (subject to the same cap) so the
|
|
183
|
+
next call doesn't spend a round-trip on a guaranteed rejection.
|
|
184
|
+
"""
|
|
185
|
+
attempts = 0
|
|
186
|
+
while True:
|
|
187
|
+
response = await client.request(method, url, **kwargs)
|
|
188
|
+
wait = _throttle_wait(response)
|
|
189
|
+
if wait is None:
|
|
190
|
+
return response
|
|
191
|
+
if response.status_code not in (403, 429):
|
|
192
|
+
# Pre-emptive: the request succeeded but drained the quota;
|
|
193
|
+
# pause (bounded) so the next call isn't a guaranteed 403,
|
|
194
|
+
# then hand back the good response.
|
|
195
|
+
if wait <= max_wait:
|
|
196
|
+
LOGGER.info(
|
|
197
|
+
'github-commit-sync quota exhausted on %s; pausing '
|
|
198
|
+
'%.0fs before continuing',
|
|
199
|
+
url,
|
|
200
|
+
wait,
|
|
201
|
+
)
|
|
202
|
+
await asyncio.sleep(wait)
|
|
203
|
+
return response
|
|
204
|
+
if wait > max_wait or attempts >= _MAX_THROTTLE_RETRIES:
|
|
205
|
+
LOGGER.warning(
|
|
206
|
+
'github-commit-sync rate-limited on %s (wait %.0fs, cap '
|
|
207
|
+
'%.0fs, attempt %d); bailing best-effort',
|
|
208
|
+
url,
|
|
209
|
+
wait,
|
|
210
|
+
max_wait,
|
|
211
|
+
attempts + 1,
|
|
212
|
+
)
|
|
213
|
+
return response
|
|
214
|
+
attempts += 1
|
|
215
|
+
LOGGER.warning(
|
|
216
|
+
'github-commit-sync rate-limited on %s; pausing %.0fs then '
|
|
217
|
+
'retrying (attempt %d/%d)',
|
|
218
|
+
url,
|
|
219
|
+
wait,
|
|
220
|
+
attempts,
|
|
221
|
+
_MAX_THROTTLE_RETRIES,
|
|
222
|
+
)
|
|
223
|
+
await asyncio.sleep(wait)
|
|
224
|
+
|
|
91
225
|
|
|
92
226
|
async def _resolve_bearer(
|
|
93
227
|
credentials: dict[str, str], base: str, owner: str, repo: str
|
|
@@ -120,11 +254,11 @@ async def _resolve_bearer(
|
|
|
120
254
|
)
|
|
121
255
|
|
|
122
256
|
|
|
123
|
-
def _resolve(pointer: jsonpointer.JsonPointer,
|
|
124
|
-
"""Resolve a JSON Pointer against the
|
|
257
|
+
def _resolve(pointer: jsonpointer.JsonPointer, event: object) -> object:
|
|
258
|
+
"""Resolve a JSON Pointer against the event, ``None`` if absent."""
|
|
125
259
|
return typing.cast(
|
|
126
260
|
'object',
|
|
127
|
-
pointer.resolve(
|
|
261
|
+
pointer.resolve(event, None), # pyright: ignore[reportUnknownMemberType]
|
|
128
262
|
)
|
|
129
263
|
|
|
130
264
|
|
|
@@ -182,7 +316,7 @@ def _resolve_api_base(
|
|
|
182
316
|
ctx: PluginContext,
|
|
183
317
|
explicit: str | None,
|
|
184
318
|
repo_url_pointer: jsonpointer.JsonPointer,
|
|
185
|
-
|
|
319
|
+
event: object,
|
|
186
320
|
) -> str | None:
|
|
187
321
|
"""Pick the GitHub API base for this call (see module docstring)."""
|
|
188
322
|
if explicit:
|
|
@@ -199,11 +333,11 @@ def _resolve_api_base(
|
|
|
199
333
|
endpoint = ctx.assignment_options.get('service_endpoint')
|
|
200
334
|
if isinstance(endpoint, str) and endpoint:
|
|
201
335
|
return endpoint.rstrip('/')
|
|
202
|
-
base = _api_base_from_repo_url(_resolve(repo_url_pointer,
|
|
336
|
+
base = _api_base_from_repo_url(_resolve(repo_url_pointer, event))
|
|
203
337
|
if base:
|
|
204
338
|
LOGGER.info(
|
|
205
|
-
|
|
206
|
-
'the API base; no api_base_url, connected GitHub plugin, or '
|
|
339
|
+
"github-commit-sync falling back to the event's repository.url "
|
|
340
|
+
'for the API base; no api_base_url, connected GitHub plugin, or '
|
|
207
341
|
'service_endpoint was available'
|
|
208
342
|
)
|
|
209
343
|
return base
|
|
@@ -211,9 +345,9 @@ def _resolve_api_base(
|
|
|
211
345
|
|
|
212
346
|
|
|
213
347
|
def _owner_repo(
|
|
214
|
-
selector: jsonpointer.JsonPointer,
|
|
348
|
+
selector: jsonpointer.JsonPointer, event: object
|
|
215
349
|
) -> tuple[str, str] | None:
|
|
216
|
-
full_name = _resolve(selector,
|
|
350
|
+
full_name = _resolve(selector, event)
|
|
217
351
|
if not isinstance(full_name, str) or '/' not in full_name:
|
|
218
352
|
return None
|
|
219
353
|
owner, _, repo = full_name.partition('/')
|
|
@@ -225,14 +359,14 @@ def _owner_repo(
|
|
|
225
359
|
def _resolve_repo_and_base(
|
|
226
360
|
ctx: PluginContext,
|
|
227
361
|
action_config: SyncCommitsConfig | SyncTagsConfig,
|
|
228
|
-
|
|
362
|
+
event: object,
|
|
229
363
|
) -> tuple[str, str, str] | None:
|
|
230
364
|
"""Resolve ``(owner, repo, api_base)`` for an action.
|
|
231
365
|
|
|
232
366
|
Returns ``None`` (after logging) when the owner/repo or API base
|
|
233
367
|
can't be determined, so both action callables share one short-circuit.
|
|
234
368
|
"""
|
|
235
|
-
owner_repo = _owner_repo(action_config.repository_selector,
|
|
369
|
+
owner_repo = _owner_repo(action_config.repository_selector, event)
|
|
236
370
|
if owner_repo is None:
|
|
237
371
|
LOGGER.warning('github-commit-sync: no owner/repo in push payload')
|
|
238
372
|
return None
|
|
@@ -240,7 +374,7 @@ def _resolve_repo_and_base(
|
|
|
240
374
|
ctx,
|
|
241
375
|
action_config.api_base_url,
|
|
242
376
|
action_config.repo_api_url_selector,
|
|
243
|
-
|
|
377
|
+
event,
|
|
244
378
|
)
|
|
245
379
|
if base is None:
|
|
246
380
|
LOGGER.warning(
|
|
@@ -262,12 +396,103 @@ def _client(base: str, owner: str, repo: str, token: str) -> httpx.AsyncClient:
|
|
|
262
396
|
)
|
|
263
397
|
|
|
264
398
|
|
|
399
|
+
ResolveUser = collections.abc.Callable[
|
|
400
|
+
[str], collections.abc.Awaitable[str | None]
|
|
401
|
+
]
|
|
402
|
+
|
|
403
|
+
# Process-wide, bounded LRU cache of resolved commit-author identities,
|
|
404
|
+
# keyed by ``(api_base, subject)``. The subject is GitHub's numeric user
|
|
405
|
+
# id, which is only unique *per host* (github.com id 42 != a GHES id 42),
|
|
406
|
+
# so the resolved API base scopes the key. Only *successful* resolutions
|
|
407
|
+
# are cached: within a single sync :func:`_resolve_author_users` already
|
|
408
|
+
# de-dupes subjects, and leaving misses uncached means a contributor who
|
|
409
|
+
# links their Imbi identity later is picked up on the next sync instead
|
|
410
|
+
# of being stuck unresolved for the process's lifetime. An
|
|
411
|
+
# ``OrderedDict`` gives LRU eviction once the cache exceeds
|
|
412
|
+
# ``_USER_CACHE_MAX`` entries.
|
|
413
|
+
_USER_CACHE: collections.OrderedDict[tuple[str, str], str] = (
|
|
414
|
+
collections.OrderedDict()
|
|
415
|
+
)
|
|
416
|
+
_USER_CACHE_MAX = 8192
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
async def _resolve_user(
|
|
420
|
+
resolver: ResolveUser, base: str, subject: str
|
|
421
|
+
) -> str | None:
|
|
422
|
+
"""Resolve a GitHub user id to an Imbi email, LRU-cached per host.
|
|
423
|
+
|
|
424
|
+
``subject`` is the GitHub numeric user id (the identity-plugin
|
|
425
|
+
subject). Only successful resolutions are memoized under
|
|
426
|
+
``(base, subject)``; a miss is returned but not cached, so a
|
|
427
|
+
contributor who links their Imbi identity later resolves on a
|
|
428
|
+
subsequent sync rather than being memoized as unresolved for the
|
|
429
|
+
process's lifetime.
|
|
430
|
+
"""
|
|
431
|
+
key = (base, subject)
|
|
432
|
+
if key in _USER_CACHE:
|
|
433
|
+
_USER_CACHE.move_to_end(key)
|
|
434
|
+
return _USER_CACHE[key]
|
|
435
|
+
email = await resolver(subject)
|
|
436
|
+
if email is None:
|
|
437
|
+
return None
|
|
438
|
+
_USER_CACHE[key] = email
|
|
439
|
+
_USER_CACHE.move_to_end(key)
|
|
440
|
+
if len(_USER_CACHE) > _USER_CACHE_MAX:
|
|
441
|
+
_USER_CACHE.popitem(last=False)
|
|
442
|
+
return email
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
async def _resolve_author_users(
|
|
446
|
+
raw: list[dict[str, typing.Any]],
|
|
447
|
+
resolver: ResolveUser | None,
|
|
448
|
+
base: str,
|
|
449
|
+
) -> dict[str, str]:
|
|
450
|
+
"""Map each commit's GitHub author id to a resolved Imbi email.
|
|
451
|
+
|
|
452
|
+
Returns an empty map when the host wired no resolver. Distinct
|
|
453
|
+
author ids are resolved once each (LRU-cached across syncs); misses
|
|
454
|
+
are dropped, so the map carries only positive matches and
|
|
455
|
+
:func:`_author_user` falls back to ``''`` for everyone else.
|
|
456
|
+
"""
|
|
457
|
+
if resolver is None:
|
|
458
|
+
return {}
|
|
459
|
+
subjects: set[str] = set()
|
|
460
|
+
for item in raw:
|
|
461
|
+
gh_author: dict[str, typing.Any] = item.get('author') or {}
|
|
462
|
+
gid = gh_author.get('id')
|
|
463
|
+
if gid is not None:
|
|
464
|
+
subjects.add(str(gid))
|
|
465
|
+
out: dict[str, str] = {}
|
|
466
|
+
for subject in subjects:
|
|
467
|
+
try:
|
|
468
|
+
email = await _resolve_user(resolver, base, subject)
|
|
469
|
+
except Exception as exc: # noqa: BLE001 - attribution is best-effort
|
|
470
|
+
LOGGER.warning(
|
|
471
|
+
'github-commit-sync: failed to resolve author %s; '
|
|
472
|
+
'leaving unattributed: %s',
|
|
473
|
+
subject,
|
|
474
|
+
exc,
|
|
475
|
+
)
|
|
476
|
+
continue
|
|
477
|
+
if email:
|
|
478
|
+
out[subject] = email
|
|
479
|
+
return out
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _author_user(item: dict[str, typing.Any], user_map: dict[str, str]) -> str:
|
|
483
|
+
"""Resolved Imbi email for a commit's author, ``''`` when unmatched."""
|
|
484
|
+
gh_author: dict[str, typing.Any] = item.get('author') or {}
|
|
485
|
+
gid = gh_author.get('id')
|
|
486
|
+
return user_map.get(str(gid), '') if gid is not None else ''
|
|
487
|
+
|
|
488
|
+
|
|
265
489
|
def _commit_record(
|
|
266
490
|
item: dict[str, typing.Any],
|
|
267
491
|
*,
|
|
268
492
|
project_id: str,
|
|
269
493
|
ref: str,
|
|
270
494
|
pushed_at: datetime.datetime,
|
|
495
|
+
author_user: str = '',
|
|
271
496
|
) -> CommitRecord:
|
|
272
497
|
"""Map a GitHub commit object onto a :class:`CommitRecord`.
|
|
273
498
|
|
|
@@ -292,6 +517,7 @@ def _commit_record(
|
|
|
292
517
|
author_name=str(author_meta.get('name') or ''),
|
|
293
518
|
author_email=str(author_meta.get('email') or ''),
|
|
294
519
|
author_login=str(gh_author.get('login') or ''),
|
|
520
|
+
author_user=author_user,
|
|
295
521
|
committer_name=str(committer_meta.get('name') or ''),
|
|
296
522
|
authored_at=authored_at or pushed_at,
|
|
297
523
|
committed_at=_parse_iso(committer_meta.get('date')),
|
|
@@ -301,7 +527,7 @@ def _commit_record(
|
|
|
301
527
|
|
|
302
528
|
|
|
303
529
|
async def _fetch_compare_commits(
|
|
304
|
-
client: httpx.AsyncClient, base: str, head: str
|
|
530
|
+
client: httpx.AsyncClient, base: str, head: str, *, max_wait: float
|
|
305
531
|
) -> list[dict[str, typing.Any]]:
|
|
306
532
|
"""``GET /compare/{base}...{head}`` following the Link pagination."""
|
|
307
533
|
quoted = urllib.parse.quote(f'{base}...{head}', safe='.')
|
|
@@ -309,7 +535,9 @@ async def _fetch_compare_commits(
|
|
|
309
535
|
params: dict[str, str] = {'per_page': '250'}
|
|
310
536
|
commits: list[dict[str, typing.Any]] = []
|
|
311
537
|
for page in range(1, _MAX_COMPARE_PAGES + 1):
|
|
312
|
-
resp = await
|
|
538
|
+
resp = await _request(
|
|
539
|
+
client, 'GET', path, params=params, max_wait=max_wait
|
|
540
|
+
)
|
|
313
541
|
resp.raise_for_status()
|
|
314
542
|
payload = typing.cast('dict[str, typing.Any]', resp.json())
|
|
315
543
|
commits.extend(payload.get('commits') or [])
|
|
@@ -356,12 +584,15 @@ async def _last_known_sha(project_id: str) -> str | None:
|
|
|
356
584
|
|
|
357
585
|
|
|
358
586
|
async def _fetch_recent_commits(
|
|
359
|
-
client: httpx.AsyncClient, head: str, limit: int
|
|
587
|
+
client: httpx.AsyncClient, head: str, limit: int, *, max_wait: float
|
|
360
588
|
) -> list[dict[str, typing.Any]]:
|
|
361
589
|
"""Bounded ``GET /commits?sha={head}`` fallback for new branches."""
|
|
362
|
-
resp = await
|
|
590
|
+
resp = await _request(
|
|
591
|
+
client,
|
|
592
|
+
'GET',
|
|
363
593
|
'/commits',
|
|
364
594
|
params={'sha': head, 'per_page': str(max(1, min(limit, 100)))},
|
|
595
|
+
max_wait=max_wait,
|
|
365
596
|
)
|
|
366
597
|
resp.raise_for_status()
|
|
367
598
|
return typing.cast('list[dict[str, typing.Any]]', resp.json())
|
|
@@ -386,20 +617,22 @@ def _resolve_host_for_context(ctx: PluginContext) -> str | None:
|
|
|
386
617
|
return None
|
|
387
618
|
|
|
388
619
|
|
|
389
|
-
async def _fetch_default_branch(
|
|
620
|
+
async def _fetch_default_branch(
|
|
621
|
+
client: httpx.AsyncClient, *, max_wait: float
|
|
622
|
+
) -> str:
|
|
390
623
|
"""Return the repo's default branch name (``main`` when unknown)."""
|
|
391
624
|
# httpx normalises ``base_url`` with a trailing slash; GHEC's gateway
|
|
392
625
|
# 404s on ``/repos/<o>/<r>/`` so request the absolute URL with the
|
|
393
626
|
# trailing slash stripped, matching the deployment plugin.
|
|
394
627
|
url = str(client.base_url).rstrip('/')
|
|
395
|
-
resp = await client
|
|
628
|
+
resp = await _request(client, 'GET', url, max_wait=max_wait)
|
|
396
629
|
resp.raise_for_status()
|
|
397
630
|
meta = typing.cast('dict[str, typing.Any]', resp.json())
|
|
398
631
|
return str(meta.get('default_branch') or 'main')
|
|
399
632
|
|
|
400
633
|
|
|
401
634
|
async def _fetch_all_commits(
|
|
402
|
-
client: httpx.AsyncClient, branch: str
|
|
635
|
+
client: httpx.AsyncClient, branch: str, *, max_wait: float
|
|
403
636
|
) -> list[dict[str, typing.Any]]:
|
|
404
637
|
"""Walk every commit reachable from ``branch`` via Link pagination.
|
|
405
638
|
|
|
@@ -409,7 +642,9 @@ async def _fetch_all_commits(
|
|
|
409
642
|
params: dict[str, str] = {'sha': branch, 'per_page': '100'}
|
|
410
643
|
out: list[dict[str, typing.Any]] = []
|
|
411
644
|
for page in range(1, _MAX_HISTORY_PAGES + 1):
|
|
412
|
-
resp = await
|
|
645
|
+
resp = await _request(
|
|
646
|
+
client, 'GET', '/commits', params=params, max_wait=max_wait
|
|
647
|
+
)
|
|
413
648
|
resp.raise_for_status()
|
|
414
649
|
out.extend(typing.cast('list[dict[str, typing.Any]]', resp.json()))
|
|
415
650
|
next_url = _next_page_url(resp.headers.get('link'))
|
|
@@ -455,46 +690,58 @@ async def _insert_best_effort(
|
|
|
455
690
|
|
|
456
691
|
|
|
457
692
|
class SyncCommitsConfig(pydantic.BaseModel):
|
|
458
|
-
"""``WebhookRule.handler_config`` for ``sync_commits``.
|
|
693
|
+
"""``WebhookRule.handler_config`` for ``sync_commits``.
|
|
694
|
+
|
|
695
|
+
Selectors resolve against the event context, so the push body lives
|
|
696
|
+
under ``/payload`` (e.g. ``/payload/after``).
|
|
697
|
+
"""
|
|
459
698
|
|
|
460
699
|
before_selector: JsonPointer = pydantic.Field(
|
|
461
|
-
default_factory=lambda: jsonpointer.JsonPointer('/before')
|
|
700
|
+
default_factory=lambda: jsonpointer.JsonPointer('/payload/before')
|
|
462
701
|
)
|
|
463
702
|
after_selector: JsonPointer = pydantic.Field(
|
|
464
|
-
default_factory=lambda: jsonpointer.JsonPointer('/after')
|
|
703
|
+
default_factory=lambda: jsonpointer.JsonPointer('/payload/after')
|
|
465
704
|
)
|
|
466
705
|
ref_selector: JsonPointer = pydantic.Field(
|
|
467
|
-
default_factory=lambda: jsonpointer.JsonPointer('/ref')
|
|
706
|
+
default_factory=lambda: jsonpointer.JsonPointer('/payload/ref')
|
|
468
707
|
)
|
|
469
708
|
repository_selector: JsonPointer = pydantic.Field(
|
|
470
709
|
default_factory=lambda: jsonpointer.JsonPointer(
|
|
471
|
-
'/repository/full_name'
|
|
710
|
+
'/payload/repository/full_name'
|
|
472
711
|
)
|
|
473
712
|
)
|
|
474
713
|
api_base_url: str | None = None
|
|
475
714
|
repo_api_url_selector: JsonPointer = pydantic.Field(
|
|
476
|
-
default_factory=lambda: jsonpointer.JsonPointer(
|
|
715
|
+
default_factory=lambda: jsonpointer.JsonPointer(
|
|
716
|
+
'/payload/repository/url'
|
|
717
|
+
)
|
|
477
718
|
)
|
|
478
719
|
initial_limit: int = 100
|
|
479
720
|
|
|
480
721
|
|
|
481
722
|
class SyncTagsConfig(pydantic.BaseModel):
|
|
482
|
-
"""``WebhookRule.handler_config`` for ``sync_tags``.
|
|
723
|
+
"""``WebhookRule.handler_config`` for ``sync_tags``.
|
|
724
|
+
|
|
725
|
+
Selectors resolve against the event context, so the push body lives
|
|
726
|
+
under ``/payload`` (e.g. ``/payload/ref``).
|
|
727
|
+
"""
|
|
483
728
|
|
|
484
729
|
ref_selector: JsonPointer = pydantic.Field(
|
|
485
|
-
default_factory=lambda: jsonpointer.JsonPointer('/ref')
|
|
730
|
+
default_factory=lambda: jsonpointer.JsonPointer('/payload/ref')
|
|
486
731
|
)
|
|
487
732
|
after_selector: JsonPointer = pydantic.Field(
|
|
488
|
-
default_factory=lambda: jsonpointer.JsonPointer('/after')
|
|
733
|
+
default_factory=lambda: jsonpointer.JsonPointer('/payload/after')
|
|
489
734
|
)
|
|
490
735
|
repository_selector: JsonPointer = pydantic.Field(
|
|
491
736
|
default_factory=lambda: jsonpointer.JsonPointer(
|
|
492
|
-
'/repository/full_name'
|
|
737
|
+
'/payload/repository/full_name'
|
|
493
738
|
)
|
|
494
739
|
)
|
|
495
740
|
api_base_url: str | None = None
|
|
496
741
|
repo_api_url_selector: JsonPointer = pydantic.Field(
|
|
497
|
-
default_factory=lambda: jsonpointer.JsonPointer(
|
|
742
|
+
default_factory=lambda: jsonpointer.JsonPointer(
|
|
743
|
+
'/payload/repository/url'
|
|
744
|
+
)
|
|
498
745
|
)
|
|
499
746
|
reconcile_all: bool = False
|
|
500
747
|
|
|
@@ -505,7 +752,7 @@ async def sync_commits(
|
|
|
505
752
|
credentials: dict[str, str],
|
|
506
753
|
external_identifier: str,
|
|
507
754
|
action_config: SyncCommitsConfig,
|
|
508
|
-
|
|
755
|
+
event: object,
|
|
509
756
|
) -> None:
|
|
510
757
|
"""Sync the commits in a ``push`` delivery into the ``commits`` table.
|
|
511
758
|
|
|
@@ -513,32 +760,46 @@ async def sync_commits(
|
|
|
513
760
|
action does not filter refs itself.
|
|
514
761
|
"""
|
|
515
762
|
del external_identifier
|
|
516
|
-
after = _resolve(action_config.after_selector,
|
|
763
|
+
after = _resolve(action_config.after_selector, event)
|
|
517
764
|
if not isinstance(after, str) or not after or after == _ZERO_SHA:
|
|
518
765
|
return # branch delete / no head -- nothing to sync
|
|
519
|
-
resolved = _resolve_repo_and_base(ctx, action_config,
|
|
766
|
+
resolved = _resolve_repo_and_base(ctx, action_config, event)
|
|
520
767
|
if resolved is None:
|
|
521
768
|
return
|
|
522
769
|
owner, repo, base = resolved
|
|
523
|
-
ref_raw = _resolve(action_config.ref_selector,
|
|
770
|
+
ref_raw = _resolve(action_config.ref_selector, event)
|
|
524
771
|
ref = _branch_short_name(ref_raw if isinstance(ref_raw, str) else '')
|
|
525
|
-
before = _resolve(action_config.before_selector,
|
|
772
|
+
before = _resolve(action_config.before_selector, event)
|
|
526
773
|
pushed_at = datetime.datetime.now(datetime.UTC)
|
|
527
774
|
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
528
775
|
async with _client(base, owner, repo, token) as client:
|
|
529
776
|
if isinstance(before, str) and before and before != _ZERO_SHA:
|
|
530
|
-
raw = await _fetch_compare_commits(
|
|
777
|
+
raw = await _fetch_compare_commits(
|
|
778
|
+
client, before, after, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
|
|
779
|
+
)
|
|
531
780
|
else:
|
|
532
781
|
last = await _last_known_sha(ctx.project_id)
|
|
533
782
|
if last:
|
|
534
|
-
raw = await _fetch_compare_commits(
|
|
783
|
+
raw = await _fetch_compare_commits(
|
|
784
|
+
client, last, after, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
|
|
785
|
+
)
|
|
535
786
|
else:
|
|
536
787
|
raw = await _fetch_recent_commits(
|
|
537
|
-
client,
|
|
788
|
+
client,
|
|
789
|
+
after,
|
|
790
|
+
action_config.initial_limit,
|
|
791
|
+
max_wait=_WEBHOOK_MAX_WAIT_SECONDS,
|
|
538
792
|
)
|
|
793
|
+
user_map = await _resolve_author_users(
|
|
794
|
+
raw, ctx.resolve_user_by_identity, base
|
|
795
|
+
)
|
|
539
796
|
records: list[pydantic.BaseModel] = [
|
|
540
797
|
_commit_record(
|
|
541
|
-
item,
|
|
798
|
+
item,
|
|
799
|
+
project_id=ctx.project_id,
|
|
800
|
+
ref=ref,
|
|
801
|
+
pushed_at=pushed_at,
|
|
802
|
+
author_user=_author_user(item, user_map),
|
|
542
803
|
)
|
|
543
804
|
for item in raw
|
|
544
805
|
if item.get('sha')
|
|
@@ -556,29 +817,55 @@ async def sync_commits(
|
|
|
556
817
|
|
|
557
818
|
|
|
558
819
|
async def _annotated_tag(
|
|
559
|
-
client: httpx.AsyncClient, sha: str
|
|
820
|
+
client: httpx.AsyncClient, sha: str, *, max_wait: float
|
|
560
821
|
) -> dict[str, typing.Any] | None:
|
|
561
822
|
"""Fetch annotated-tag metadata, ``None`` for a lightweight tag."""
|
|
562
|
-
resp = await client
|
|
823
|
+
resp = await _request(client, 'GET', f'/git/tags/{sha}', max_wait=max_wait)
|
|
563
824
|
if resp.status_code != 200:
|
|
564
825
|
return None
|
|
565
826
|
return typing.cast('dict[str, typing.Any]', resp.json())
|
|
566
827
|
|
|
567
828
|
|
|
829
|
+
def _web_base(api_base: str) -> str:
|
|
830
|
+
"""Map a REST API base to the web host (inverse of the routing table).
|
|
831
|
+
|
|
832
|
+
``https://api.github.com`` -> ``https://github.com``;
|
|
833
|
+
``https://api.<tenant>.ghe.com`` -> ``https://<tenant>.ghe.com``;
|
|
834
|
+
GHES ``https://<host>/api/v3`` -> ``https://<host>``.
|
|
835
|
+
"""
|
|
836
|
+
if api_base.endswith('/api/v3'):
|
|
837
|
+
return api_base[: -len('/api/v3')]
|
|
838
|
+
return api_base.replace('://api.', '://', 1)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _tag_web_url(client: httpx.AsyncClient, name: str) -> str:
|
|
842
|
+
"""Build the web URL for *name* from the client's repo-scoped base."""
|
|
843
|
+
full = str(client.base_url).rstrip('/')
|
|
844
|
+
marker = '/repos/'
|
|
845
|
+
idx = full.find(marker)
|
|
846
|
+
if idx == -1:
|
|
847
|
+
return ''
|
|
848
|
+
web = _web_base(full[:idx])
|
|
849
|
+
owner_repo = full[idx + len(marker) :]
|
|
850
|
+
return f'{web}/{owner_repo}/releases/tag/{urllib.parse.quote(name)}'
|
|
851
|
+
|
|
852
|
+
|
|
568
853
|
def _tag_record(
|
|
569
854
|
*,
|
|
570
855
|
project_id: str,
|
|
571
856
|
name: str,
|
|
572
857
|
sha: str,
|
|
573
858
|
annotated: dict[str, typing.Any] | None = None,
|
|
859
|
+
url: str = '',
|
|
574
860
|
) -> TagRecord:
|
|
575
861
|
if annotated is None:
|
|
576
|
-
return TagRecord(project_id=project_id, name=name, sha=sha)
|
|
862
|
+
return TagRecord(project_id=project_id, name=name, sha=sha, url=url)
|
|
577
863
|
tagger: dict[str, typing.Any] = annotated.get('tagger') or {}
|
|
578
864
|
return TagRecord(
|
|
579
865
|
project_id=project_id,
|
|
580
866
|
name=name,
|
|
581
867
|
sha=sha,
|
|
868
|
+
url=url,
|
|
582
869
|
message=str(annotated.get('message') or ''),
|
|
583
870
|
tagger_name=str(tagger.get('name') or ''),
|
|
584
871
|
tagger_email=str(tagger.get('email') or ''),
|
|
@@ -587,24 +874,49 @@ def _tag_record(
|
|
|
587
874
|
|
|
588
875
|
|
|
589
876
|
async def _reconcile_tags(
|
|
590
|
-
client: httpx.AsyncClient, project_id: str
|
|
877
|
+
client: httpx.AsyncClient, project_id: str, *, max_wait: float
|
|
591
878
|
) -> list[TagRecord]:
|
|
592
|
-
"""Upsert the repo's full tag list
|
|
593
|
-
|
|
879
|
+
"""Upsert the repo's full tag list via the git-refs API.
|
|
880
|
+
|
|
881
|
+
``/git/matching-refs/tags`` yields each tag's object sha + type;
|
|
882
|
+
annotated tags (``type == 'tag'``) are enriched with tagger/message/
|
|
883
|
+
date from the tag object, lightweight tags carry name/sha/url only.
|
|
884
|
+
``ReplacingMergeTree`` dedupes against rows recorded from pushes.
|
|
885
|
+
"""
|
|
594
886
|
out: list[TagRecord] = []
|
|
887
|
+
prefix = 'refs/tags/'
|
|
595
888
|
params: dict[str, str] = {'per_page': '100'}
|
|
596
889
|
while True:
|
|
597
|
-
resp = await
|
|
890
|
+
resp = await _request(
|
|
891
|
+
client,
|
|
892
|
+
'GET',
|
|
893
|
+
'/git/matching-refs/tags',
|
|
894
|
+
params=params,
|
|
895
|
+
max_wait=max_wait,
|
|
896
|
+
)
|
|
598
897
|
resp.raise_for_status()
|
|
599
898
|
rows = typing.cast('list[dict[str, typing.Any]]', resp.json())
|
|
600
899
|
for row in rows:
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
sha = str(
|
|
604
|
-
if
|
|
605
|
-
|
|
606
|
-
|
|
900
|
+
ref = str(row.get('ref') or '')
|
|
901
|
+
obj: dict[str, typing.Any] = row.get('object') or {}
|
|
902
|
+
sha = str(obj.get('sha') or '')
|
|
903
|
+
if not ref.startswith(prefix) or not sha:
|
|
904
|
+
continue
|
|
905
|
+
name = ref[len(prefix) :]
|
|
906
|
+
annotated = (
|
|
907
|
+
await _annotated_tag(client, sha, max_wait=max_wait)
|
|
908
|
+
if obj.get('type') == 'tag'
|
|
909
|
+
else None
|
|
910
|
+
)
|
|
911
|
+
out.append(
|
|
912
|
+
_tag_record(
|
|
913
|
+
project_id=project_id,
|
|
914
|
+
name=name,
|
|
915
|
+
sha=sha,
|
|
916
|
+
annotated=annotated,
|
|
917
|
+
url=_tag_web_url(client, name),
|
|
607
918
|
)
|
|
919
|
+
)
|
|
608
920
|
next_url = _next_page_url(resp.headers.get('link'))
|
|
609
921
|
if next_url is None:
|
|
610
922
|
return out
|
|
@@ -620,16 +932,16 @@ async def sync_tags(
|
|
|
620
932
|
credentials: dict[str, str],
|
|
621
933
|
external_identifier: str,
|
|
622
934
|
action_config: SyncTagsConfig,
|
|
623
|
-
|
|
935
|
+
event: object,
|
|
624
936
|
) -> None:
|
|
625
937
|
"""Sync the tag in a ``push`` delivery into the ``tags`` table."""
|
|
626
938
|
del external_identifier
|
|
627
|
-
ref_raw = _resolve(action_config.ref_selector,
|
|
939
|
+
ref_raw = _resolve(action_config.ref_selector, event)
|
|
628
940
|
prefix = 'refs/tags/'
|
|
629
941
|
if not isinstance(ref_raw, str) or not ref_raw.startswith(prefix):
|
|
630
942
|
return
|
|
631
943
|
name = ref_raw[len(prefix) :]
|
|
632
|
-
after = _resolve(action_config.after_selector,
|
|
944
|
+
after = _resolve(action_config.after_selector, event)
|
|
633
945
|
if (
|
|
634
946
|
not name
|
|
635
947
|
or not isinstance(after, str)
|
|
@@ -637,23 +949,28 @@ async def sync_tags(
|
|
|
637
949
|
or after == _ZERO_SHA
|
|
638
950
|
):
|
|
639
951
|
return # tag delete / no target
|
|
640
|
-
resolved = _resolve_repo_and_base(ctx, action_config,
|
|
952
|
+
resolved = _resolve_repo_and_base(ctx, action_config, event)
|
|
641
953
|
if resolved is None:
|
|
642
954
|
return
|
|
643
955
|
owner, repo, base = resolved
|
|
644
956
|
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
645
957
|
async with _client(base, owner, repo, token) as client:
|
|
646
|
-
annotated = await _annotated_tag(
|
|
958
|
+
annotated = await _annotated_tag(
|
|
959
|
+
client, after, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
|
|
960
|
+
)
|
|
647
961
|
records: list[pydantic.BaseModel] = [
|
|
648
962
|
_tag_record(
|
|
649
963
|
project_id=ctx.project_id,
|
|
650
964
|
name=name,
|
|
651
965
|
sha=after,
|
|
652
966
|
annotated=annotated,
|
|
967
|
+
url=_tag_web_url(client, name),
|
|
653
968
|
)
|
|
654
969
|
]
|
|
655
970
|
if action_config.reconcile_all:
|
|
656
|
-
extra = await _reconcile_tags(
|
|
971
|
+
extra = await _reconcile_tags(
|
|
972
|
+
client, ctx.project_id, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
|
|
973
|
+
)
|
|
657
974
|
seen = {name}
|
|
658
975
|
records.extend(r for r in extra if r.name not in seen)
|
|
659
976
|
try:
|
|
@@ -798,15 +1115,25 @@ class GitHubCommitSyncPlugin(WebhookActionPlugin):
|
|
|
798
1115
|
pushed_at = datetime.datetime.now(datetime.UTC)
|
|
799
1116
|
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
800
1117
|
async with _client(base, owner, repo, token) as client:
|
|
801
|
-
branch = await _fetch_default_branch(
|
|
802
|
-
|
|
803
|
-
|
|
1118
|
+
branch = await _fetch_default_branch(
|
|
1119
|
+
client, max_wait=_BACKFILL_MAX_WAIT_SECONDS
|
|
1120
|
+
)
|
|
1121
|
+
raw_commits = await _fetch_all_commits(
|
|
1122
|
+
client, branch, max_wait=_BACKFILL_MAX_WAIT_SECONDS
|
|
1123
|
+
)
|
|
1124
|
+
tags = await _reconcile_tags(
|
|
1125
|
+
client, ctx.project_id, max_wait=_BACKFILL_MAX_WAIT_SECONDS
|
|
1126
|
+
)
|
|
1127
|
+
user_map = await _resolve_author_users(
|
|
1128
|
+
raw_commits, ctx.resolve_user_by_identity, base
|
|
1129
|
+
)
|
|
804
1130
|
commit_records: list[pydantic.BaseModel] = [
|
|
805
1131
|
_commit_record(
|
|
806
1132
|
item,
|
|
807
1133
|
project_id=ctx.project_id,
|
|
808
1134
|
ref=branch,
|
|
809
1135
|
pushed_at=pushed_at,
|
|
1136
|
+
author_user=_author_user(item, user_map),
|
|
810
1137
|
)
|
|
811
1138
|
for item in raw_commits
|
|
812
1139
|
if item.get('sha')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: imbi-plugin-github
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.10.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.10.0
|
|
17
17
|
Requires-Dist: pydantic>=2
|
|
18
18
|
Requires-Dist: pyjwt[crypto]>=2.8
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
@@ -2,12 +2,12 @@ imbi_plugin_github/__init__.py,sha256=8ZVhm8XpGqgYRUtpHl0uuZTT3gmMyHNhKd9k6qoatB
|
|
|
2
2
|
imbi_plugin_github/_app_auth.py,sha256=LnLrvqmu2UC27uHHJfIzFPNDh33RtgeOX1RXceX9Pj0,7000
|
|
3
3
|
imbi_plugin_github/_hosts.py,sha256=jQ1Z6yEpjAa4b7LocLfEelnBcRYYQLNLBNCdWZtAXUc,2258
|
|
4
4
|
imbi_plugin_github/_repos.py,sha256=agp4ZgRuqbrGBRMFeHomnUf1HvvaNvapBCWHQebdviQ,4559
|
|
5
|
-
imbi_plugin_github/commits.py,sha256=
|
|
5
|
+
imbi_plugin_github/commits.py,sha256=hZiTCboherDMY4noKF2T6BKKWMm0uYK4dPAV5c1Giv0,42141
|
|
6
6
|
imbi_plugin_github/deployment.py,sha256=V6YlZX24m33BPJWkC-vCZZLSsH71etkl0xkhkDsKASg,47914
|
|
7
7
|
imbi_plugin_github/identity.py,sha256=RSs1cLQpZmfyQ_0KQkohDIZEBYVBnre6RJIt166EtdQ,15525
|
|
8
8
|
imbi_plugin_github/lifecycle.py,sha256=48mLKjFPG7BOanpA7jWnmbWOdsDLh1F39g2AoxDLL7M,33211
|
|
9
|
-
imbi_plugin_github-2.
|
|
10
|
-
imbi_plugin_github-2.
|
|
11
|
-
imbi_plugin_github-2.
|
|
12
|
-
imbi_plugin_github-2.
|
|
13
|
-
imbi_plugin_github-2.
|
|
9
|
+
imbi_plugin_github-2.10.0.dist-info/METADATA,sha256=iLcyJ1p6YvChsBou6DyMlf-HHq3jDKZ1c-Rn5jPUYc8,5811
|
|
10
|
+
imbi_plugin_github-2.10.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
imbi_plugin_github-2.10.0.dist-info/entry_points.txt,sha256=RavN_FYCeS97iauHgCG3tHFdVULbEMXmQfvgCgLeGkI,805
|
|
12
|
+
imbi_plugin_github-2.10.0.dist-info/licenses/LICENSE,sha256=el9B20zOqEcv_mRrHgbdkVyxSC6I8np7am0D6mO2A9Y,1491
|
|
13
|
+
imbi_plugin_github-2.10.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|