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.
@@ -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, payload: object) -> object:
124
- """Resolve a JSON Pointer against the payload, ``None`` if absent."""
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(payload, None), # pyright: ignore[reportUnknownMemberType]
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
- payload: object,
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, payload))
336
+ base = _api_base_from_repo_url(_resolve(repo_url_pointer, event))
203
337
  if base:
204
338
  LOGGER.info(
205
- 'github-commit-sync falling back to payload repository.url for '
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, payload: object
348
+ selector: jsonpointer.JsonPointer, event: object
215
349
  ) -> tuple[str, str] | None:
216
- full_name = _resolve(selector, payload)
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
- payload: object,
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, payload)
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
- payload,
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 client.get(path, params=params)
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 client.get(
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(client: httpx.AsyncClient) -> str:
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.get(url)
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 client.get('/commits', params=params)
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('/repository/url')
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('/repository/url')
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
- payload: object,
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, payload)
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, payload)
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, payload)
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, payload)
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(client, before, after)
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(client, last, after)
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, after, action_config.initial_limit
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, project_id=ctx.project_id, ref=ref, pushed_at=pushed_at
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.get(f'/git/tags/{sha}')
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 (lightweight); ``ReplacingMergeTree``
593
- dedupes against rows recorded from individual pushes."""
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 client.get('/tags', params=params)
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
- name = str(row.get('name') or '')
602
- commit: dict[str, typing.Any] = row.get('commit') or {}
603
- sha = str(commit.get('sha') or '')
604
- if name and sha:
605
- out.append(
606
- _tag_record(project_id=project_id, name=name, sha=sha)
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
- payload: object,
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, payload)
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, payload)
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, payload)
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(client, after)
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(client, ctx.project_id)
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(client)
802
- raw_commits = await _fetch_all_commits(client, branch)
803
- tags = await _reconcile_tags(client, ctx.project_id)
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.9.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.9.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=okoJd6GM_YlkqyJb3mHlxz58JU7Ar8ve8bQcDkTrkRI,29939
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.9.2.dist-info/METADATA,sha256=ACIhGSJ7VSQL2O-v0SmxRAqfov95uaEvWYZsEdjTICc,5809
10
- imbi_plugin_github-2.9.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
- imbi_plugin_github-2.9.2.dist-info/entry_points.txt,sha256=RavN_FYCeS97iauHgCG3tHFdVULbEMXmQfvgCgLeGkI,805
12
- imbi_plugin_github-2.9.2.dist-info/licenses/LICENSE,sha256=el9B20zOqEcv_mRrHgbdkVyxSC6I8np7am0D6mO2A9Y,1491
13
- imbi_plugin_github-2.9.2.dist-info/RECORD,,
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,,