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.
@@ -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, payload: object) -> object:
124
- """Resolve a JSON Pointer against the payload, ``None`` if absent."""
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(payload, None), # pyright: ignore[reportUnknownMemberType]
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
- payload: object,
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, payload))
378
+ base = _api_base_from_repo_url(_resolve(repo_url_pointer, event))
203
379
  if base:
204
380
  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 '
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, payload: object
390
+ selector: jsonpointer.JsonPointer, event: object
215
391
  ) -> tuple[str, str] | None:
216
- full_name = _resolve(selector, payload)
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
- payload: object,
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, payload)
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
- payload,
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 client.get(path, params=params)
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 client.get(
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(client: httpx.AsyncClient) -> str:
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.get(url)
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 client.get('/commits', params=params)
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('/repository/url')
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('/repository/url')
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
- payload: object,
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, payload)
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, payload)
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, payload)
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, payload)
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
- async with _client(base, owner, repo, token) as client:
529
- if isinstance(before, str) and before and before != _ZERO_SHA:
530
- raw = await _fetch_compare_commits(client, before, after)
531
- else:
532
- last = await _last_known_sha(ctx.project_id)
533
- if last:
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, project_id=ctx.project_id, ref=ref, pushed_at=pushed_at
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.get(f'/git/tags/{sha}')
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(project_id=project_id, name=name, sha=sha, url=url)
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 client.get('/git/matching-refs/tags', params=params)
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
- payload: object,
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, payload)
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, payload)
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, payload)
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
- async with _client(base, owner, repo, token) as client:
691
- annotated = await _annotated_tag(client, after)
692
- records: list[pydantic.BaseModel] = [
693
- _tag_record(
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
- if action_config.reconcile_all:
702
- extra = await _reconcile_tags(client, ctx.project_id)
703
- seen = {name}
704
- records.extend(r for r in extra if r.name not in seen)
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(client)
848
- raw_commits = await _fetch_all_commits(client, branch)
849
- tags = await _reconcile_tags(client, ctx.project_id)
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')
@@ -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.9.3
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.9.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,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.30.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -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,,