imbi-plugin-github 2.29.4__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.
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/PKG-INFO +2 -2
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/pyproject.toml +2 -2
- imbi_plugin_github-2.31.0/src/imbi/plugins/github/_app_auth.py +468 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/commits.py +117 -31
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/deployment.py +190 -26
- imbi_plugin_github-2.31.0/tests/test_app_auth_scopes.py +359 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/tests/test_commits.py +270 -11
- imbi_plugin_github-2.29.4/src/imbi/plugins/github/_app_auth.py +0 -245
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/.gitignore +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/README.md +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/moon.yml +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/README.md +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/__init__.py +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/_hosts.py +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/_repos.py +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/doctor.py +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/identity.py +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/lifecycle.py +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/plugin.py +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/pull_requests.py +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/src/imbi/plugins/github/py.typed +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/tests/test_deployment.py +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/tests/test_doctor.py +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.29.4 → imbi_plugin_github-2.31.0}/tests/test_pull_requests.py +0 -0
- {imbi_plugin_github-2.29.4 → 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
)
|