imbi-plugin-github 2.30.0__tar.gz → 2.31.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/pyproject.toml +2 -2
  3. imbi_plugin_github-2.31.0/src/imbi/plugins/github/_app_auth.py +468 -0
  4. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/commits.py +10 -15
  5. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/deployment.py +190 -26
  6. imbi_plugin_github-2.31.0/tests/test_app_auth_scopes.py +359 -0
  7. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/tests/test_commits.py +114 -8
  8. imbi_plugin_github-2.30.0/src/imbi/plugins/github/_app_auth.py +0 -245
  9. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/.gitignore +0 -0
  10. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/README.md +0 -0
  11. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/moon.yml +0 -0
  12. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/README.md +0 -0
  13. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/__init__.py +0 -0
  14. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/_hosts.py +0 -0
  15. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/_repos.py +0 -0
  16. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/doctor.py +0 -0
  17. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/identity.py +0 -0
  18. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/lifecycle.py +0 -0
  19. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/plugin.py +0 -0
  20. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/pull_requests.py +0 -0
  21. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/py.typed +0 -0
  22. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/tests/__init__.py +0 -0
  23. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/tests/test_deployment.py +0 -0
  24. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/tests/test_doctor.py +0 -0
  25. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/tests/test_hosts.py +0 -0
  26. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/tests/test_identity.py +0 -0
  27. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/tests/test_lifecycle.py +0 -0
  28. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/tests/test_pull_requests.py +0 -0
  29. {imbi_plugin_github-2.30.0 → imbi_plugin_github-2.31.0}/tests/test_repos.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: imbi-plugin-github
3
- Version: 2.30.0
3
+ Version: 2.31.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-Expression: BSD-3-Clause
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.14
13
13
  Requires-Python: >=3.14
14
14
  Requires-Dist: httpx>=0.27
15
- Requires-Dist: imbi-common[databases]==2.30.0
15
+ Requires-Dist: imbi-common[databases]==2.31.0
16
16
  Requires-Dist: pydantic>=2
17
17
  Requires-Dist: pyjwt[crypto]>=2.8
18
18
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "imbi-plugin-github"
3
- version = "2.30.0"
3
+ version = "2.31.0"
4
4
  description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -18,7 +18,7 @@ classifiers = [
18
18
  ]
19
19
  dependencies = [
20
20
  "httpx>=0.27",
21
- "imbi-common[databases]==2.30.0",
21
+ "imbi-common[databases]==2.31.0",
22
22
  "pydantic>=2",
23
23
  "pyjwt[crypto]>=2.8",
24
24
  ]
@@ -0,0 +1,468 @@
1
+ """GitHub App installation-token minting for webhook actions.
2
+
3
+ The commit-sync webhook plugin has no acting user, so when it is
4
+ configured with GitHub App credentials (``app_id`` + ``private_key``) it
5
+ mints a short-lived *installation* access token per call instead of
6
+ carrying a static PAT. Tokens are cached process-wide until shortly
7
+ before they expire, so a busy org makes one token-exchange round-trip
8
+ per hour per ``(app, installation, host)`` rather than one per webhook
9
+ delivery.
10
+
11
+ All three GitHub flavors work unchanged: the caller resolves the API
12
+ base via :func:`imbi.plugins.github._hosts.host_to_api_base` and passes
13
+ it in, so the JWT exchange hits ``api.github.com``,
14
+ ``api.<tenant>.ghe.com``, or ``<ghes>/api/v3`` as appropriate.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import binascii
21
+ import collections.abc
22
+ import datetime
23
+ import logging
24
+ import time
25
+ import typing
26
+
27
+ import httpx
28
+ import jwt
29
+
30
+ from imbi.common import cache
31
+ from imbi.common.plugins.errors import PluginInstallationMissing
32
+ from imbi.plugins.github.deployment import (
33
+ _auth_headers, # pyright: ignore[reportPrivateUsage]
34
+ _raise_on_401, # pyright: ignore[reportPrivateUsage]
35
+ )
36
+
37
+ LOGGER = logging.getLogger(__name__)
38
+
39
+
40
+ class AppNotInstalledError(PluginInstallationMissing):
41
+ """The GitHub App is not installed for the target repository.
42
+
43
+ Raised by :func:`_discover_installation_id` when GitHub answers the
44
+ installation lookup with a 404 -- the App has not been installed on
45
+ the repo/org, or the repo was renamed/removed. Sync callers treat
46
+ this as a clean skip rather than a hard failure, so an uninstalled
47
+ App never surfaces as a Sentry error on a backfill worker.
48
+
49
+ A *mutating* caller must not treat it that way: a silent skip there
50
+ means the deploy or rollback did not happen and nothing said so.
51
+ Those callers pass ``cache_misses=False`` (see
52
+ :func:`installation_token`) and surface it as a terminal error.
53
+ """
54
+
55
+
56
+ _HTTP_TIMEOUT_SECONDS = 10.0
57
+ # GitHub rejects an App JWT whose ``exp`` is more than 10 minutes out;
58
+ # sign for 9 to leave room for clock skew between us and GitHub.
59
+ _JWT_TTL_SECONDS = 540
60
+ # Re-mint an installation token this many seconds before it actually
61
+ # expires so an in-flight request never races the expiry boundary.
62
+ _TOKEN_REFRESH_MARGIN_SECONDS = 300.0
63
+ # Installation tokens last an hour; assume ~55 minutes when GitHub omits
64
+ # (or we can't parse) the ``expires_at`` field.
65
+ _DEFAULT_TOKEN_TTL_SECONDS = 3300.0
66
+
67
+ # Upper bounds on the process-wide caches. Both were unbounded, so a
68
+ # long-lived worker touching many repositories grew them for the life of
69
+ # the process -- one installation entry per repository, forever. The
70
+ # bounds are generous: eviction costs one extra round-trip, and an
71
+ # installation covers an entire org, so a process rarely holds more than
72
+ # a handful of distinct ids.
73
+ _TOKEN_CACHE_MAX_ENTRIES = 1024
74
+ _INSTALL_CACHE_MAX_ENTRIES = 4096
75
+ _NOT_INSTALLED_CACHE_MAX_ENTRIES = 4096
76
+ # Long enough to stop a sweep re-paying the 404 on every call for an
77
+ # uninstalled repo, short enough that installing the App during an
78
+ # incident is visible within a minute. Mutating callers do not consult
79
+ # this cache at all (see ``cache_misses``), so the blackout never
80
+ # outlives an operator's fix on the path that matters.
81
+ _NOT_INSTALLED_TTL_SECONDS = 60.0
82
+
83
+ #: The permission set a token is minted with, canonicalized for use as
84
+ #: part of a cache key. ``None`` means "whatever the installation
85
+ #: grants" -- the pre-scoping behaviour, still used by callers that have
86
+ #: not declared what they need.
87
+ Scope: typing.TypeAlias = collections.abc.Mapping[str, str] | None
88
+ FrozenScope: typing.TypeAlias = tuple[tuple[str, str], ...] | None
89
+
90
+
91
+ def freeze_scope(scope: Scope) -> FrozenScope:
92
+ """Canonical, hashable form of a requested permission set.
93
+
94
+ Sorted so that ``{'a': 'read', 'b': 'write'}`` and
95
+ ``{'b': 'write', 'a': 'read'}`` are one cache entry, and distinct
96
+ from ``None`` -- a token minted with the App's full grant is *not*
97
+ interchangeable with one minted for ``{'contents': 'read'}``, and
98
+ conflating them would silently undo the down-scoping.
99
+ """
100
+ if scope is None:
101
+ return None
102
+ return tuple(sorted(scope.items()))
103
+
104
+
105
+ # Process-wide caches. Tokens carry a per-entry deadline derived from
106
+ # GitHub's own ``expires_at`` rather than a uniform TTL; installation ids
107
+ # do not expire (an App being uninstalled surfaces as a 404 on the next
108
+ # mint, which evicts the id).
109
+ #
110
+ # The requested scope is part of the token key, and that is a
111
+ # correctness requirement rather than an optimization: a narrower token
112
+ # must never be served to a caller that asked for a wider one, or a
113
+ # ``contents: read`` token would answer a ``contents: write`` request
114
+ # and the write would 403 from GitHub at the worst possible moment.
115
+ _TOKEN_CACHE: cache.LRUCache[tuple[str, str, str, FrozenScope], str] = (
116
+ cache.LRUCache(_TOKEN_CACHE_MAX_ENTRIES)
117
+ )
118
+ _INSTALL_CACHE: cache.LRUCache[tuple[str, str, str, str], str] = (
119
+ cache.LRUCache(_INSTALL_CACHE_MAX_ENTRIES)
120
+ )
121
+ # Negative cache: repositories the App is known not to be installed on.
122
+ # The value is unused; membership is the whole answer.
123
+ _NOT_INSTALLED_CACHE: cache.LRUCache[tuple[str, str, str, str], bool] = (
124
+ cache.LRUCache(
125
+ _NOT_INSTALLED_CACHE_MAX_ENTRIES, ttl=_NOT_INSTALLED_TTL_SECONDS
126
+ )
127
+ )
128
+
129
+ # Serializes the cold-cache path. Without it, N concurrent calls for
130
+ # one uncached key each mint their own token: GitHub happily issues them,
131
+ # the last write wins, and the rest are wasted round-trips against the
132
+ # App's rate limit.
133
+ #
134
+ # Two granularities, because a repo is not an installation. The
135
+ # ``'repo'`` lock collapses concurrent callers for one repository, which
136
+ # is what dedupes *discovery*; the ``'install'`` lock collapses everyone
137
+ # who resolved to the same installation -- including callers for
138
+ # different repositories under it -- which is what dedupes *minting*,
139
+ # since the token belongs to the installation. The tag keeps the two
140
+ # key spaces disjoint: an explicitly configured installation id takes the
141
+ # ``'repo'`` lock on its way in, and would otherwise deadlock against
142
+ # itself on the ``'install'`` lock, which is not reentrant. Locks are
143
+ # always taken in that order, so the nesting cannot cycle.
144
+ _MINT_LOCK: cache.KeyedLock[tuple[str, ...]] = cache.KeyedLock()
145
+
146
+
147
+ def known_installation_id(
148
+ app_id: str, base: str, owner: str, repo: str
149
+ ) -> str | None:
150
+ """The installation id already resolved for ``owner/repo``, if any.
151
+
152
+ Read-only view of the discovery cache, for callers that want to
153
+ *record* which installation carried an action out without forcing a
154
+ lookup of their own. Populated as a side effect of
155
+ :func:`installation_token`, so a caller that just minted a token
156
+ always finds it here.
157
+ """
158
+ return _INSTALL_CACHE.get((app_id, base, owner, repo))
159
+
160
+
161
+ def reset_cache() -> None:
162
+ """Clear the process-wide token / installation caches (tests)."""
163
+ _TOKEN_CACHE.clear()
164
+ _INSTALL_CACHE.clear()
165
+ _NOT_INSTALLED_CACHE.clear()
166
+
167
+
168
+ def _load_private_key(raw: str) -> str:
169
+ """Return a PEM private key from raw PEM or a base64-encoded PEM.
170
+
171
+ Operators may paste the key GitHub generated directly, or a
172
+ single-line base64 encoding of it (handy where the config UI lacks a
173
+ multi-line field). Raises ``ValueError`` for anything else.
174
+ """
175
+ value = raw.strip()
176
+ if '-----BEGIN' in value:
177
+ return value
178
+ try:
179
+ decoded = base64.b64decode(value, validate=True).decode('utf-8')
180
+ except (binascii.Error, ValueError, UnicodeDecodeError) as exc:
181
+ raise ValueError(
182
+ 'github-commit-sync private_key is neither a PEM nor a '
183
+ 'base64-encoded PEM'
184
+ ) from exc
185
+ if '-----BEGIN' not in decoded:
186
+ raise ValueError(
187
+ 'github-commit-sync private_key decoded but is not a PEM'
188
+ )
189
+ return decoded
190
+
191
+
192
+ def _app_jwt(app_id: str, private_key: str) -> str:
193
+ now = int(time.time())
194
+ return jwt.encode(
195
+ {'iat': now - 60, 'exp': now + _JWT_TTL_SECONDS, 'iss': app_id},
196
+ _load_private_key(private_key),
197
+ algorithm='RS256',
198
+ )
199
+
200
+
201
+ def _token_deadline(expires_at: object) -> float:
202
+ """Map GitHub's ISO ``expires_at`` to a monotonic cache deadline."""
203
+ now = time.monotonic()
204
+ if not isinstance(expires_at, str):
205
+ return now + _DEFAULT_TOKEN_TTL_SECONDS
206
+ try:
207
+ exp = datetime.datetime.fromisoformat(expires_at)
208
+ except ValueError:
209
+ return now + _DEFAULT_TOKEN_TTL_SECONDS
210
+ remaining = (exp - datetime.datetime.now(datetime.UTC)).total_seconds()
211
+ return now + max(0.0, remaining - _TOKEN_REFRESH_MARGIN_SECONDS)
212
+
213
+
214
+ def _cached_token(
215
+ app_id: str,
216
+ base: str,
217
+ installation_id: str | None,
218
+ owner: str,
219
+ repo: str,
220
+ scope: FrozenScope,
221
+ ) -> str | None:
222
+ """Return a live cached token for ``scope``, or ``None`` to mint one.
223
+
224
+ The installation id is the one the caller configured, falling back
225
+ to a previously discovered one for the repo -- so a repo whose
226
+ installation is already known is served from cache without the
227
+ discovery round-trip.
228
+ """
229
+ install = installation_id or _INSTALL_CACHE.get(
230
+ (app_id, base, owner, repo)
231
+ )
232
+ if install is None:
233
+ return None
234
+ return _TOKEN_CACHE.get((app_id, install, base, scope))
235
+
236
+
237
+ async def _discover_installation_id(
238
+ client: httpx.AsyncClient, owner: str, repo: str
239
+ ) -> str:
240
+ resp = await client.get(f'/repos/{owner}/{repo}/installation')
241
+ if resp.status_code == 404:
242
+ raise AppNotInstalledError(
243
+ f'no GitHub App installation found for {owner}/{repo}',
244
+ owner_repo=f'{owner}/{repo}',
245
+ )
246
+ resp.raise_for_status()
247
+ data = typing.cast('dict[str, typing.Any]', resp.json())
248
+ install_id = data.get('id')
249
+ if install_id is None:
250
+ raise AppNotInstalledError(
251
+ f'no GitHub App installation found for {owner}/{repo}',
252
+ owner_repo=f'{owner}/{repo}',
253
+ )
254
+ return str(install_id)
255
+
256
+
257
+ async def _mint(
258
+ client: httpx.AsyncClient, installation_id: str, scope: FrozenScope
259
+ ) -> tuple[str, object]:
260
+ """Exchange the App JWT for an installation access token.
261
+
262
+ With ``scope`` set, GitHub mints a token carrying *only* those
263
+ permissions -- a subset of what the installation grants -- instead
264
+ of the installation's full set. Requesting a permission the
265
+ installation does not hold is a ``422``, which is the right failure:
266
+ a capability asking for authority the App was never given should say
267
+ so rather than act with less.
268
+ """
269
+ # ``is not None`` rather than truthiness: ``freeze_scope({})`` is an
270
+ # empty tuple, and asking GitHub for *no* permissions must send
271
+ # ``{'permissions': {}}``. Omitting the body instead makes GitHub
272
+ # grant the installation's entire set -- the narrowest request
273
+ # would fail open to the widest token.
274
+ body: dict[str, typing.Any] | None = (
275
+ {'permissions': dict(scope)} if scope is not None else None
276
+ )
277
+ resp = await client.post(
278
+ f'/app/installations/{installation_id}/access_tokens', json=body
279
+ )
280
+ resp.raise_for_status()
281
+ data = typing.cast('dict[str, typing.Any]', resp.json())
282
+ return str(data['token']), data.get('expires_at')
283
+
284
+
285
+ async def installation_token(
286
+ *,
287
+ base: str,
288
+ app_id: str,
289
+ private_key: str,
290
+ installation_id: str | None,
291
+ owner: str,
292
+ repo: str,
293
+ scope: Scope = None,
294
+ cache_misses: bool = True,
295
+ ) -> str:
296
+ """Return a valid installation token, minting/caching as needed.
297
+
298
+ ``installation_id`` may be ``None``, in which case the installation
299
+ is discovered from the target repo (and cached). The resulting
300
+ token is cached until shortly before it expires.
301
+
302
+ ``scope`` is the GitHub App permission set the calling operation
303
+ needs (e.g. ``{'contents': 'read'}``). The token is minted with
304
+ exactly that set rather than the installation's full grant, so the
305
+ authority in flight tracks the operation instead of the
306
+ Integration's configuration. ``None`` keeps the pre-scoping
307
+ behaviour. It is part of the cache key: see :func:`freeze_scope`.
308
+
309
+ ``cache_misses=False`` skips the negative cache for a repo the App
310
+ is known to be uninstalled on, paying the 404 instead. Mutating
311
+ callers pass it so that installing the App during an incident takes
312
+ effect on the next attempt rather than after the negative TTL.
313
+
314
+ Concurrent callers for the same installation mint once, whether they
315
+ named the same repository or two repositories that turn out to share
316
+ an installation: the first through stores the token and the rest read
317
+ it back rather than each making their own exchange.
318
+ """
319
+ frozen = freeze_scope(scope)
320
+ if (
321
+ token := _cached_token(
322
+ app_id, base, installation_id, owner, repo, frozen
323
+ )
324
+ ) is not None:
325
+ return token
326
+ if cache_misses and _NOT_INSTALLED_CACHE.get((app_id, base, owner, repo)):
327
+ raise AppNotInstalledError(
328
+ f'no GitHub App installation found for {owner}/{repo}',
329
+ owner_repo=f'{owner}/{repo}',
330
+ )
331
+ # Held across discovery, so concurrent callers for one repository
332
+ # look its installation up once.
333
+ async with _MINT_LOCK(('repo', app_id, base, owner, repo)):
334
+ # The coroutine that held the lock may have just minted this.
335
+ if (
336
+ token := _cached_token(
337
+ app_id, base, installation_id, owner, repo, frozen
338
+ )
339
+ ) is not None:
340
+ return token
341
+ try:
342
+ return await _mint_and_cache(
343
+ base=base,
344
+ app_id=app_id,
345
+ private_key=private_key,
346
+ installation_id=installation_id,
347
+ owner=owner,
348
+ repo=repo,
349
+ scope=frozen,
350
+ )
351
+ except AppNotInstalledError:
352
+ _NOT_INSTALLED_CACHE.set((app_id, base, owner, repo), True)
353
+ raise
354
+
355
+
356
+ async def _mint_and_cache(
357
+ *,
358
+ base: str,
359
+ app_id: str,
360
+ private_key: str,
361
+ installation_id: str | None,
362
+ owner: str,
363
+ repo: str,
364
+ scope: FrozenScope = None,
365
+ ) -> str:
366
+ """Mint a fresh installation token and cache it until it expires."""
367
+ install = installation_id or _INSTALL_CACHE.get(
368
+ (app_id, base, owner, repo)
369
+ )
370
+ install_was_cached = install is not None and installation_id is None
371
+ app_token = _app_jwt(app_id, private_key)
372
+ async with httpx.AsyncClient(
373
+ base_url=base,
374
+ headers=_auth_headers(app_token),
375
+ timeout=_HTTP_TIMEOUT_SECONDS,
376
+ event_hooks={'response': [_raise_on_401]},
377
+ ) as client:
378
+ if install is None:
379
+ install = await _discover_installation_id(client, owner, repo)
380
+ _INSTALL_CACHE.set((app_id, base, owner, repo), install)
381
+ try:
382
+ return await _mint_locked(client, app_id, base, install, scope)
383
+ except httpx.HTTPStatusError as exc:
384
+ # A 404 (or 401, surfaced as PluginAuthenticationFailed by the
385
+ # response hook) against a *cached* installation id means the
386
+ # app was uninstalled/reinstalled or transferred. Evict the
387
+ # stale id and rediscover once before giving up.
388
+ if not install_was_cached or exc.response.status_code != 404:
389
+ raise
390
+ _INSTALL_CACHE.pop((app_id, base, owner, repo))
391
+ install = await _discover_installation_id(client, owner, repo)
392
+ _INSTALL_CACHE.set((app_id, base, owner, repo), install)
393
+ return await _mint_locked(client, app_id, base, install, scope)
394
+
395
+
396
+ async def _mint_locked(
397
+ client: httpx.AsyncClient,
398
+ app_id: str,
399
+ base: str,
400
+ install: str,
401
+ scope: FrozenScope,
402
+ ) -> str:
403
+ """Mint one token per installation, however many callers want it.
404
+
405
+ The recheck inside the lock is what makes this collapse rather than
406
+ queue: a caller that waited here because someone else was minting
407
+ finds their token and never asks GitHub for its own. Rediscovery
408
+ after a stale id re-enters under the new installation's lock, which
409
+ is why the key is a parameter rather than derived from a repo.
410
+ """
411
+ async with _MINT_LOCK(('install', app_id, base, install, str(scope))):
412
+ cached = _TOKEN_CACHE.get((app_id, install, base, scope))
413
+ if cached is not None:
414
+ return cached
415
+ token, expires_at = await _mint(client, install, scope)
416
+ _TOKEN_CACHE.set(
417
+ (app_id, install, base, scope),
418
+ token,
419
+ expires_at=_token_deadline(expires_at),
420
+ )
421
+ return token
422
+
423
+
424
+ async def resolve_bearer(
425
+ credentials: dict[str, str],
426
+ base: str,
427
+ owner: str,
428
+ repo: str,
429
+ *,
430
+ scope: Scope = None,
431
+ cache_misses: bool = True,
432
+ ) -> str:
433
+ """Resolve the Bearer token used for a repo's GitHub API calls.
434
+
435
+ Prefers an explicit PAT (``access_token``/``token``). Otherwise mints
436
+ a short-lived GitHub App installation token from ``app_id`` +
437
+ ``private_key`` (with an optional ``installation_id`` that skips
438
+ per-repo installation discovery). Tokens are cached process-wide.
439
+
440
+ ``scope`` is the permission set the calling operation needs; the
441
+ minted token carries that set rather than the App installation's
442
+ full grant. It has no effect on the PAT branch -- a PAT's scope is
443
+ fixed when an operator creates it, and nothing here can narrow it.
444
+
445
+ Shared by every host-agnostic behavioral plugin (commit-sync,
446
+ pr-sync, deployment) so a service configured with only App
447
+ credentials -- and therefore no acting user -- can still act.
448
+ """
449
+ token = credentials.get('access_token') or credentials.get('token')
450
+ if token:
451
+ return token
452
+ app_id = credentials.get('app_id')
453
+ private_key = credentials.get('private_key')
454
+ if app_id and private_key:
455
+ return await installation_token(
456
+ base=base,
457
+ app_id=app_id,
458
+ private_key=private_key,
459
+ installation_id=credentials.get('installation_id') or None,
460
+ owner=owner,
461
+ repo=repo,
462
+ scope=scope,
463
+ cache_misses=cache_misses,
464
+ )
465
+ raise ValueError(
466
+ 'GitHub plugin requires either an access_token (PAT) or '
467
+ 'app_id + private_key (GitHub App) credentials'
468
+ )
@@ -29,7 +29,6 @@ recording behaves.
29
29
  from __future__ import annotations
30
30
 
31
31
  import asyncio
32
- import collections
33
32
  import collections.abc
34
33
  import datetime
35
34
  import logging
@@ -41,7 +40,7 @@ import httpx
41
40
  import jsonpointer
42
41
  import pydantic
43
42
 
44
- from imbi.common import clickhouse
43
+ from imbi.common import cache, clickhouse
45
44
  from imbi.common.json_pointer import JsonPointer
46
45
  from imbi.common.models import CommitRecord, TagRecord
47
46
  from imbi.common.plugins.base import (
@@ -396,13 +395,13 @@ ResolveUser = collections.abc.Callable[
396
395
  # are cached: within a single sync :func:`_resolve_author_users` already
397
396
  # de-dupes subjects, and leaving misses uncached means a contributor who
398
397
  # links their Imbi identity later is picked up on the next sync instead
399
- # of being stuck unresolved for the process's lifetime. An
400
- # ``OrderedDict`` gives LRU eviction once the cache exceeds
401
- # ``_USER_CACHE_MAX`` entries.
402
- _USER_CACHE: collections.OrderedDict[tuple[str, str], str] = (
403
- collections.OrderedDict()
404
- )
398
+ # of being stuck unresolved for the process's lifetime. Entries never
399
+ # expire -- a resolved identity does not go stale -- so the cache is
400
+ # bounded only by ``_USER_CACHE_MAX``, evicting least-recently-used.
405
401
  _USER_CACHE_MAX = 8192
402
+ _USER_CACHE: cache.LRUCache[tuple[str, str], str] = cache.LRUCache(
403
+ _USER_CACHE_MAX
404
+ )
406
405
 
407
406
 
408
407
  async def _resolve_user(
@@ -418,16 +417,12 @@ async def _resolve_user(
418
417
  process's lifetime.
419
418
  """
420
419
  key = (base, subject)
421
- if key in _USER_CACHE:
422
- _USER_CACHE.move_to_end(key)
423
- return _USER_CACHE[key]
420
+ if (cached := _USER_CACHE.get(key)) is not None:
421
+ return cached
424
422
  email = await resolver(subject)
425
423
  if email is None:
426
424
  return None
427
- _USER_CACHE[key] = email
428
- _USER_CACHE.move_to_end(key)
429
- if len(_USER_CACHE) > _USER_CACHE_MAX:
430
- _USER_CACHE.popitem(last=False)
425
+ _USER_CACHE.set(key, email)
431
426
  return email
432
427
 
433
428