imbi-plugin-github 2.9.1__tar.gz → 2.9.3__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.9.1 → imbi_plugin_github-2.9.3}/.pre-commit-config.yaml +3 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/PKG-INFO +47 -2
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/README.md +44 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/pyproject.toml +4 -3
- imbi_plugin_github-2.9.3/src/imbi_plugin_github/_app_auth.py +195 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/commits.py +299 -26
- imbi_plugin_github-2.9.3/tests/test_commits.py +1097 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/uv.lock +13 -6
- imbi_plugin_github-2.9.1/tests/test_commits.py +0 -549
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/.github/workflows/publish.yml +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/.github/workflows/test.yml +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/.gitignore +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/CLAUDE.md +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/LICENSE +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/justfile +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/__init__.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/_hosts.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/_repos.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/deployment.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/identity.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/lifecycle.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/tests/test_deployment.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/tests/test_repos.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: imbi-plugin-github
|
|
3
|
-
Version: 2.9.
|
|
3
|
+
Version: 2.9.3
|
|
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,8 +13,9 @@ 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.
|
|
16
|
+
Requires-Dist: imbi-common[databases]==2.9.2
|
|
17
17
|
Requires-Dist: pydantic>=2
|
|
18
|
+
Requires-Dist: pyjwt[crypto]>=2.8
|
|
18
19
|
Description-Content-Type: text/markdown
|
|
19
20
|
|
|
20
21
|
# imbi-plugin-github
|
|
@@ -30,6 +31,7 @@ projects to the right backend.
|
|
|
30
31
|
| Identity | `github`, `github-enterprise-cloud`, `github-enterprise-server` |
|
|
31
32
|
| Deployment | `github-deployment`, `github-deployment-ec`, `github-deployment-es` |
|
|
32
33
|
| Lifecycle | `github-lifecycle`, `github-lifecycle-ec`, `github-lifecycle-es` |
|
|
34
|
+
| Webhook | `github-commit-sync` |
|
|
33
35
|
|
|
34
36
|
### Identity
|
|
35
37
|
|
|
@@ -63,6 +65,37 @@ org.
|
|
|
63
65
|
Archiving requires admin scope on the repo; transferring additionally
|
|
64
66
|
requires admin permission on the target organization.
|
|
65
67
|
|
|
68
|
+
### Webhook (commit / tag sync)
|
|
69
|
+
|
|
70
|
+
A single `github-commit-sync` webhook-action plugin exposes two actions
|
|
71
|
+
the gateway dispatches on `push` deliveries:
|
|
72
|
+
|
|
73
|
+
| Action | Handler | Records into ClickHouse |
|
|
74
|
+
| -------------- | -------------------------------- | ----------------------- |
|
|
75
|
+
| `sync_commits` | `github-commit-sync#sync_commits`| `commits` |
|
|
76
|
+
| `sync_tags` | `github-commit-sync#sync_tags` | `tags` |
|
|
77
|
+
|
|
78
|
+
`sync_commits` fetches the full set of commits in a push via the compare
|
|
79
|
+
API (paginated, so it isn't capped by the 20-commit inline payload limit);
|
|
80
|
+
`sync_tags` records the pushed tag and, with `reconcile_all`, the repo's
|
|
81
|
+
full tag list. Branch/tag gating is the rule's CEL `filter_expression`
|
|
82
|
+
(e.g. `ref == "refs/heads/main"`, `ref.startsWith("refs/tags/")`). The API
|
|
83
|
+
flavor (github.com / GHEC / GHES) is resolved at runtime — explicit
|
|
84
|
+
`api_base_url`, else a connected GitHub plugin on the same service, else
|
|
85
|
+
the service endpoint, else the payload's `repository.url`.
|
|
86
|
+
|
|
87
|
+
Unlike identity/deployment/lifecycle (which act as the OAuth user),
|
|
88
|
+
commit-sync runs without an actor and authenticates with a **service**
|
|
89
|
+
credential in one of two modes, resolved per call:
|
|
90
|
+
|
|
91
|
+
- **PAT** — a static `access_token`.
|
|
92
|
+
- **GitHub App** — `app_id` + `private_key`; the plugin signs an App JWT
|
|
93
|
+
and mints a short-lived **installation token** (cached process-wide
|
|
94
|
+
until shortly before it expires), so no static, expiring token is
|
|
95
|
+
stored. `installation_id` is optional — when unset it is discovered
|
|
96
|
+
from the pushed repository (`GET /repos/{owner}/{repo}/installation`).
|
|
97
|
+
The App needs **Contents: Read-only**.
|
|
98
|
+
|
|
66
99
|
## Manifest options (identity)
|
|
67
100
|
|
|
68
101
|
| Option | Required | Description |
|
|
@@ -77,6 +110,18 @@ requires admin permission on the target organization.
|
|
|
77
110
|
| `client_id` | yes |
|
|
78
111
|
| `client_secret` | yes |
|
|
79
112
|
|
|
113
|
+
## Credentials (commit-sync)
|
|
114
|
+
|
|
115
|
+
Provide **either** the PAT field **or** the GitHub App fields (all
|
|
116
|
+
individually optional; validated per call):
|
|
117
|
+
|
|
118
|
+
| Field | Mode | Description |
|
|
119
|
+
| ----------------- | ---- | ------------------------------------------------------ |
|
|
120
|
+
| `access_token` | PAT | Static personal/service token. |
|
|
121
|
+
| `app_id` | App | GitHub App identifier. |
|
|
122
|
+
| `private_key` | App | App private key — raw PEM or base64-encoded PEM. |
|
|
123
|
+
| `installation_id` | App | Optional; discovered from the repo when unset. |
|
|
124
|
+
|
|
80
125
|
## License
|
|
81
126
|
|
|
82
127
|
BSD-3-Clause.
|
|
@@ -11,6 +11,7 @@ projects to the right backend.
|
|
|
11
11
|
| Identity | `github`, `github-enterprise-cloud`, `github-enterprise-server` |
|
|
12
12
|
| Deployment | `github-deployment`, `github-deployment-ec`, `github-deployment-es` |
|
|
13
13
|
| Lifecycle | `github-lifecycle`, `github-lifecycle-ec`, `github-lifecycle-es` |
|
|
14
|
+
| Webhook | `github-commit-sync` |
|
|
14
15
|
|
|
15
16
|
### Identity
|
|
16
17
|
|
|
@@ -44,6 +45,37 @@ org.
|
|
|
44
45
|
Archiving requires admin scope on the repo; transferring additionally
|
|
45
46
|
requires admin permission on the target organization.
|
|
46
47
|
|
|
48
|
+
### Webhook (commit / tag sync)
|
|
49
|
+
|
|
50
|
+
A single `github-commit-sync` webhook-action plugin exposes two actions
|
|
51
|
+
the gateway dispatches on `push` deliveries:
|
|
52
|
+
|
|
53
|
+
| Action | Handler | Records into ClickHouse |
|
|
54
|
+
| -------------- | -------------------------------- | ----------------------- |
|
|
55
|
+
| `sync_commits` | `github-commit-sync#sync_commits`| `commits` |
|
|
56
|
+
| `sync_tags` | `github-commit-sync#sync_tags` | `tags` |
|
|
57
|
+
|
|
58
|
+
`sync_commits` fetches the full set of commits in a push via the compare
|
|
59
|
+
API (paginated, so it isn't capped by the 20-commit inline payload limit);
|
|
60
|
+
`sync_tags` records the pushed tag and, with `reconcile_all`, the repo's
|
|
61
|
+
full tag list. Branch/tag gating is the rule's CEL `filter_expression`
|
|
62
|
+
(e.g. `ref == "refs/heads/main"`, `ref.startsWith("refs/tags/")`). The API
|
|
63
|
+
flavor (github.com / GHEC / GHES) is resolved at runtime — explicit
|
|
64
|
+
`api_base_url`, else a connected GitHub plugin on the same service, else
|
|
65
|
+
the service endpoint, else the payload's `repository.url`.
|
|
66
|
+
|
|
67
|
+
Unlike identity/deployment/lifecycle (which act as the OAuth user),
|
|
68
|
+
commit-sync runs without an actor and authenticates with a **service**
|
|
69
|
+
credential in one of two modes, resolved per call:
|
|
70
|
+
|
|
71
|
+
- **PAT** — a static `access_token`.
|
|
72
|
+
- **GitHub App** — `app_id` + `private_key`; the plugin signs an App JWT
|
|
73
|
+
and mints a short-lived **installation token** (cached process-wide
|
|
74
|
+
until shortly before it expires), so no static, expiring token is
|
|
75
|
+
stored. `installation_id` is optional — when unset it is discovered
|
|
76
|
+
from the pushed repository (`GET /repos/{owner}/{repo}/installation`).
|
|
77
|
+
The App needs **Contents: Read-only**.
|
|
78
|
+
|
|
47
79
|
## Manifest options (identity)
|
|
48
80
|
|
|
49
81
|
| Option | Required | Description |
|
|
@@ -58,6 +90,18 @@ requires admin permission on the target organization.
|
|
|
58
90
|
| `client_id` | yes |
|
|
59
91
|
| `client_secret` | yes |
|
|
60
92
|
|
|
93
|
+
## Credentials (commit-sync)
|
|
94
|
+
|
|
95
|
+
Provide **either** the PAT field **or** the GitHub App fields (all
|
|
96
|
+
individually optional; validated per call):
|
|
97
|
+
|
|
98
|
+
| Field | Mode | Description |
|
|
99
|
+
| ----------------- | ---- | ------------------------------------------------------ |
|
|
100
|
+
| `access_token` | PAT | Static personal/service token. |
|
|
101
|
+
| `app_id` | App | GitHub App identifier. |
|
|
102
|
+
| `private_key` | App | App private key — raw PEM or base64-encoded PEM. |
|
|
103
|
+
| `installation_id` | App | Optional; discovered from the repo when unset. |
|
|
104
|
+
|
|
61
105
|
## License
|
|
62
106
|
|
|
63
107
|
BSD-3-Clause.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "imbi-plugin-github"
|
|
3
|
-
version = "2.9.
|
|
3
|
+
version = "2.9.3"
|
|
4
4
|
description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -16,8 +16,9 @@ classifiers = [
|
|
|
16
16
|
]
|
|
17
17
|
dependencies = [
|
|
18
18
|
"httpx>=0.27",
|
|
19
|
-
"imbi-common[databases]==2.9.
|
|
19
|
+
"imbi-common[databases]==2.9.2",
|
|
20
20
|
"pydantic>=2",
|
|
21
|
+
"pyjwt[crypto]>=2.8",
|
|
21
22
|
]
|
|
22
23
|
|
|
23
24
|
[project.entry-points."imbi.plugins"]
|
|
@@ -36,7 +37,7 @@ github-commit-sync = "imbi_plugin_github.commits:GitHubCommitSyncPlugin"
|
|
|
36
37
|
dev = [
|
|
37
38
|
"basedpyright",
|
|
38
39
|
"coverage[toml]",
|
|
39
|
-
"imbi-common[server,databases]==2.9.
|
|
40
|
+
"imbi-common[server,databases]==2.9.2",
|
|
40
41
|
"pre-commit",
|
|
41
42
|
"pytest",
|
|
42
43
|
"pytest-asyncio",
|
|
@@ -0,0 +1,195 @@
|
|
|
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_plugin_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 datetime
|
|
22
|
+
import logging
|
|
23
|
+
import time
|
|
24
|
+
import typing
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
import jwt
|
|
28
|
+
|
|
29
|
+
from imbi_plugin_github.deployment import (
|
|
30
|
+
_auth_headers, # pyright: ignore[reportPrivateUsage]
|
|
31
|
+
_raise_on_401, # pyright: ignore[reportPrivateUsage]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
LOGGER = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
_HTTP_TIMEOUT_SECONDS = 10.0
|
|
37
|
+
# GitHub rejects an App JWT whose ``exp`` is more than 10 minutes out;
|
|
38
|
+
# sign for 9 to leave room for clock skew between us and GitHub.
|
|
39
|
+
_JWT_TTL_SECONDS = 540
|
|
40
|
+
# Re-mint an installation token this many seconds before it actually
|
|
41
|
+
# expires so an in-flight request never races the expiry boundary.
|
|
42
|
+
_TOKEN_REFRESH_MARGIN_SECONDS = 300.0
|
|
43
|
+
# Installation tokens last an hour; assume ~55 minutes when GitHub omits
|
|
44
|
+
# (or we can't parse) the ``expires_at`` field.
|
|
45
|
+
_DEFAULT_TOKEN_TTL_SECONDS = 3300.0
|
|
46
|
+
|
|
47
|
+
# Process-wide caches. Token cache values are ``(token, deadline)`` where
|
|
48
|
+
# ``deadline`` is a ``time.monotonic()`` instant; the installation cache
|
|
49
|
+
# avoids re-discovering the installation id on every delivery.
|
|
50
|
+
_TOKEN_CACHE: dict[tuple[str, str, str], tuple[str, float]] = {}
|
|
51
|
+
_INSTALL_CACHE: dict[tuple[str, str, str, str], str] = {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def reset_cache() -> None:
|
|
55
|
+
"""Clear the process-wide token / installation caches (tests)."""
|
|
56
|
+
_TOKEN_CACHE.clear()
|
|
57
|
+
_INSTALL_CACHE.clear()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_private_key(raw: str) -> str:
|
|
61
|
+
"""Return a PEM private key from raw PEM or a base64-encoded PEM.
|
|
62
|
+
|
|
63
|
+
Operators may paste the key GitHub generated directly, or a
|
|
64
|
+
single-line base64 encoding of it (handy where the config UI lacks a
|
|
65
|
+
multi-line field). Raises ``ValueError`` for anything else.
|
|
66
|
+
"""
|
|
67
|
+
value = raw.strip()
|
|
68
|
+
if '-----BEGIN' in value:
|
|
69
|
+
return value
|
|
70
|
+
try:
|
|
71
|
+
decoded = base64.b64decode(value, validate=True).decode('utf-8')
|
|
72
|
+
except (binascii.Error, ValueError, UnicodeDecodeError) as exc:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
'github-commit-sync private_key is neither a PEM nor a '
|
|
75
|
+
'base64-encoded PEM'
|
|
76
|
+
) from exc
|
|
77
|
+
if '-----BEGIN' not in decoded:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
'github-commit-sync private_key decoded but is not a PEM'
|
|
80
|
+
)
|
|
81
|
+
return decoded
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _app_jwt(app_id: str, private_key: str) -> str:
|
|
85
|
+
now = int(time.time())
|
|
86
|
+
return jwt.encode(
|
|
87
|
+
{'iat': now - 60, 'exp': now + _JWT_TTL_SECONDS, 'iss': app_id},
|
|
88
|
+
_load_private_key(private_key),
|
|
89
|
+
algorithm='RS256',
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _token_deadline(expires_at: object) -> float:
|
|
94
|
+
"""Map GitHub's ISO ``expires_at`` to a monotonic cache deadline."""
|
|
95
|
+
now = time.monotonic()
|
|
96
|
+
if not isinstance(expires_at, str):
|
|
97
|
+
return now + _DEFAULT_TOKEN_TTL_SECONDS
|
|
98
|
+
try:
|
|
99
|
+
exp = datetime.datetime.fromisoformat(expires_at)
|
|
100
|
+
except ValueError:
|
|
101
|
+
return now + _DEFAULT_TOKEN_TTL_SECONDS
|
|
102
|
+
remaining = (exp - datetime.datetime.now(datetime.UTC)).total_seconds()
|
|
103
|
+
return now + max(0.0, remaining - _TOKEN_REFRESH_MARGIN_SECONDS)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _cached_token(key: tuple[str, str, str]) -> str | None:
|
|
107
|
+
entry = _TOKEN_CACHE.get(key)
|
|
108
|
+
if entry is not None and entry[1] > time.monotonic():
|
|
109
|
+
return entry[0]
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def _discover_installation_id(
|
|
114
|
+
client: httpx.AsyncClient, owner: str, repo: str
|
|
115
|
+
) -> str:
|
|
116
|
+
resp = await client.get(f'/repos/{owner}/{repo}/installation')
|
|
117
|
+
resp.raise_for_status()
|
|
118
|
+
data = typing.cast('dict[str, typing.Any]', resp.json())
|
|
119
|
+
install_id = data.get('id')
|
|
120
|
+
if install_id is None:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f'no GitHub App installation found for {owner}/{repo}'
|
|
123
|
+
)
|
|
124
|
+
return str(install_id)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def _mint(
|
|
128
|
+
client: httpx.AsyncClient, installation_id: str
|
|
129
|
+
) -> tuple[str, object]:
|
|
130
|
+
resp = await client.post(
|
|
131
|
+
f'/app/installations/{installation_id}/access_tokens'
|
|
132
|
+
)
|
|
133
|
+
resp.raise_for_status()
|
|
134
|
+
data = typing.cast('dict[str, typing.Any]', resp.json())
|
|
135
|
+
return str(data['token']), data.get('expires_at')
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def installation_token(
|
|
139
|
+
*,
|
|
140
|
+
base: str,
|
|
141
|
+
app_id: str,
|
|
142
|
+
private_key: str,
|
|
143
|
+
installation_id: str | None,
|
|
144
|
+
owner: str,
|
|
145
|
+
repo: str,
|
|
146
|
+
) -> str:
|
|
147
|
+
"""Return a valid installation token, minting/caching as needed.
|
|
148
|
+
|
|
149
|
+
``installation_id`` may be ``None``, in which case the installation
|
|
150
|
+
is discovered from the target repo (and cached). The resulting
|
|
151
|
+
token is cached until shortly before it expires.
|
|
152
|
+
"""
|
|
153
|
+
install = installation_id
|
|
154
|
+
if install is not None and (
|
|
155
|
+
cached := _cached_token((app_id, install, base))
|
|
156
|
+
):
|
|
157
|
+
return cached
|
|
158
|
+
if install is None:
|
|
159
|
+
install = _INSTALL_CACHE.get((app_id, base, owner, repo))
|
|
160
|
+
if install is not None and (
|
|
161
|
+
cached := _cached_token((app_id, install, base))
|
|
162
|
+
):
|
|
163
|
+
return cached
|
|
164
|
+
|
|
165
|
+
install_was_cached = install is not None and installation_id is None
|
|
166
|
+
app_token = _app_jwt(app_id, private_key)
|
|
167
|
+
async with httpx.AsyncClient(
|
|
168
|
+
base_url=base,
|
|
169
|
+
headers=_auth_headers(app_token),
|
|
170
|
+
timeout=_HTTP_TIMEOUT_SECONDS,
|
|
171
|
+
event_hooks={'response': [_raise_on_401]},
|
|
172
|
+
) as client:
|
|
173
|
+
if install is None:
|
|
174
|
+
install = await _discover_installation_id(client, owner, repo)
|
|
175
|
+
_INSTALL_CACHE[(app_id, base, owner, repo)] = install
|
|
176
|
+
if cached := _cached_token((app_id, install, base)):
|
|
177
|
+
return cached
|
|
178
|
+
try:
|
|
179
|
+
token, expires_at = await _mint(client, install)
|
|
180
|
+
except httpx.HTTPStatusError as exc:
|
|
181
|
+
# A 404 (or 401, surfaced as PluginAuthenticationFailed by the
|
|
182
|
+
# response hook) against a *cached* installation id means the
|
|
183
|
+
# app was uninstalled/reinstalled or transferred. Evict the
|
|
184
|
+
# stale id and rediscover once before giving up.
|
|
185
|
+
if not install_was_cached or exc.response.status_code != 404:
|
|
186
|
+
raise
|
|
187
|
+
_INSTALL_CACHE.pop((app_id, base, owner, repo), None)
|
|
188
|
+
install = await _discover_installation_id(client, owner, repo)
|
|
189
|
+
_INSTALL_CACHE[(app_id, base, owner, repo)] = install
|
|
190
|
+
token, expires_at = await _mint(client, install)
|
|
191
|
+
_TOKEN_CACHE[(app_id, install, base)] = (
|
|
192
|
+
token,
|
|
193
|
+
_token_deadline(expires_at),
|
|
194
|
+
)
|
|
195
|
+
return token
|