imbi-plugin-github 2.9.1__tar.gz → 2.9.2__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.2}/.pre-commit-config.yaml +3 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/PKG-INFO +47 -2
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/README.md +44 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/pyproject.toml +4 -3
- imbi_plugin_github-2.9.2/src/imbi_plugin_github/_app_auth.py +195 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/src/imbi_plugin_github/commits.py +243 -16
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/tests/test_commits.py +439 -4
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/uv.lock +13 -6
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/.github/workflows/publish.yml +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/.github/workflows/test.yml +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/.gitignore +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/CLAUDE.md +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/LICENSE +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/justfile +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/src/imbi_plugin_github/__init__.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/src/imbi_plugin_github/_hosts.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/src/imbi_plugin_github/_repos.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/src/imbi_plugin_github/deployment.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/src/imbi_plugin_github/identity.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/src/imbi_plugin_github/lifecycle.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/tests/test_deployment.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.2}/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.2
|
|
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.2"
|
|
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
|
|
@@ -18,6 +18,12 @@ order:
|
|
|
18
18
|
4. the push payload's ``repository.url`` (already the flavor-correct API
|
|
19
19
|
URL) as a last resort.
|
|
20
20
|
|
|
21
|
+
The same plugin also exposes :meth:`GitHubCommitSyncPlugin.sync_all_history`
|
|
22
|
+
for an on-demand, host-invoked backfill: there is no push payload, so the
|
|
23
|
+
GitHub host is read from ``ctx.service_plugins`` and ``(owner, repo)`` from
|
|
24
|
+
the project links; it walks the full default-branch history and the
|
|
25
|
+
complete tag list rather than a single push delta.
|
|
26
|
+
|
|
21
27
|
Commit / tag rows are written to the shared ClickHouse ``commits`` /
|
|
22
28
|
``tags`` tables via :func:`imbi_common.clickhouse.insert`. Writes are
|
|
23
29
|
best-effort: a storage failure is logged and swallowed so an analytics
|
|
@@ -47,11 +53,13 @@ from imbi_common.plugins.base import (
|
|
|
47
53
|
WebhookActionPlugin,
|
|
48
54
|
)
|
|
49
55
|
|
|
56
|
+
from imbi_plugin_github import _app_auth
|
|
50
57
|
from imbi_plugin_github._hosts import (
|
|
51
58
|
host_to_api_base,
|
|
52
59
|
normalize_host,
|
|
53
60
|
require_ghec_tenant_host,
|
|
54
61
|
)
|
|
62
|
+
from imbi_plugin_github._repos import resolve_owner_repo
|
|
55
63
|
from imbi_plugin_github.deployment import (
|
|
56
64
|
_auth_headers, # pyright: ignore[reportPrivateUsage]
|
|
57
65
|
_next_page_url, # pyright: ignore[reportPrivateUsage]
|
|
@@ -65,21 +73,51 @@ LOGGER = logging.getLogger(__name__)
|
|
|
65
73
|
|
|
66
74
|
_HTTP_TIMEOUT_SECONDS = 10.0
|
|
67
75
|
_ZERO_SHA = '0' * 40
|
|
76
|
+
# This plugin's own slug; skipped when reading the GitHub host/flavor
|
|
77
|
+
# from connected ``service_plugins`` so the commit-sync entry can't
|
|
78
|
+
# masquerade as a github.com host on a GHEC/GHES service.
|
|
79
|
+
_SELF_SLUG = 'github-commit-sync'
|
|
68
80
|
# GitHub's compare endpoint caps ``commits[]`` at 250 per page and
|
|
69
81
|
# paginates the rest; bound the walk so a pathological single push
|
|
70
82
|
# (force-push of thousands of commits) can't pin us on one endpoint.
|
|
71
83
|
# 250 * 20 = 5000 commits is far more than any realistic push.
|
|
72
84
|
_MAX_COMPARE_PAGES = 20
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
85
|
+
# On-demand full-history sync walks ``GET /commits`` from the default
|
|
86
|
+
# branch head. 100 per page * 100 pages = 10k commits caps a one-shot
|
|
87
|
+
# backfill so a very deep repo can't pin the worker indefinitely; the
|
|
88
|
+
# walk logs a truncation warning when it hits the cap.
|
|
89
|
+
_MAX_HISTORY_PAGES = 100
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _resolve_bearer(
|
|
93
|
+
credentials: dict[str, str], base: str, owner: str, repo: str
|
|
94
|
+
) -> str:
|
|
95
|
+
"""Resolve the Bearer token used for the repo's GitHub API calls.
|
|
96
|
+
|
|
97
|
+
Prefers an explicit PAT (``access_token``/``token``). Otherwise
|
|
98
|
+
mints a short-lived GitHub App installation token from ``app_id`` +
|
|
99
|
+
``private_key`` (with an optional ``installation_id`` that skips
|
|
100
|
+
per-repo installation discovery). Tokens are cached process-wide by
|
|
101
|
+
:mod:`imbi_plugin_github._app_auth`.
|
|
102
|
+
"""
|
|
76
103
|
token = credentials.get('access_token') or credentials.get('token')
|
|
77
|
-
if
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
104
|
+
if token:
|
|
105
|
+
return token
|
|
106
|
+
app_id = credentials.get('app_id')
|
|
107
|
+
private_key = credentials.get('private_key')
|
|
108
|
+
if app_id and private_key:
|
|
109
|
+
return await _app_auth.installation_token(
|
|
110
|
+
base=base,
|
|
111
|
+
app_id=app_id,
|
|
112
|
+
private_key=private_key,
|
|
113
|
+
installation_id=credentials.get('installation_id') or None,
|
|
114
|
+
owner=owner,
|
|
115
|
+
repo=repo,
|
|
81
116
|
)
|
|
82
|
-
|
|
117
|
+
raise ValueError(
|
|
118
|
+
'github-commit-sync requires either an access_token (PAT) or '
|
|
119
|
+
'app_id + private_key (GitHub App) credentials'
|
|
120
|
+
)
|
|
83
121
|
|
|
84
122
|
|
|
85
123
|
def _resolve(pointer: jsonpointer.JsonPointer, payload: object) -> object:
|
|
@@ -150,6 +188,11 @@ def _resolve_api_base(
|
|
|
150
188
|
if explicit:
|
|
151
189
|
return explicit.rstrip('/')
|
|
152
190
|
for plugin in ctx.service_plugins:
|
|
191
|
+
# Skip our own entry: its slug starts with "github" but carries
|
|
192
|
+
# no host option, so _github_plugin_host would mis-resolve it to
|
|
193
|
+
# github.com on a GHEC/GHES service.
|
|
194
|
+
if plugin.slug == _SELF_SLUG:
|
|
195
|
+
continue
|
|
153
196
|
host = _github_plugin_host(plugin)
|
|
154
197
|
if host:
|
|
155
198
|
return host_to_api_base(host)
|
|
@@ -324,6 +367,93 @@ async def _fetch_recent_commits(
|
|
|
324
367
|
return typing.cast('list[dict[str, typing.Any]]', resp.json())
|
|
325
368
|
|
|
326
369
|
|
|
370
|
+
def _resolve_host_for_context(ctx: PluginContext) -> str | None:
|
|
371
|
+
"""Resolve the GitHub web host for an on-demand sync (no payload).
|
|
372
|
+
|
|
373
|
+
Walks the connected GitHub plugins on ``ctx.service_plugins`` and
|
|
374
|
+
returns the first usable host (github.com, a GHEC tenant, or a GHES
|
|
375
|
+
appliance), skipping this plugin's own entry so a commit-sync row on a
|
|
376
|
+
GHEC/GHES service can't be read as github.com. Unlike the webhook
|
|
377
|
+
path there is no push payload to fall back to, so the absence of a
|
|
378
|
+
connected GitHub plugin yields ``None``.
|
|
379
|
+
"""
|
|
380
|
+
for plugin in ctx.service_plugins:
|
|
381
|
+
if plugin.slug == _SELF_SLUG:
|
|
382
|
+
continue
|
|
383
|
+
host = _github_plugin_host(plugin)
|
|
384
|
+
if host:
|
|
385
|
+
return host
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
async def _fetch_default_branch(client: httpx.AsyncClient) -> str:
|
|
390
|
+
"""Return the repo's default branch name (``main`` when unknown)."""
|
|
391
|
+
# httpx normalises ``base_url`` with a trailing slash; GHEC's gateway
|
|
392
|
+
# 404s on ``/repos/<o>/<r>/`` so request the absolute URL with the
|
|
393
|
+
# trailing slash stripped, matching the deployment plugin.
|
|
394
|
+
url = str(client.base_url).rstrip('/')
|
|
395
|
+
resp = await client.get(url)
|
|
396
|
+
resp.raise_for_status()
|
|
397
|
+
meta = typing.cast('dict[str, typing.Any]', resp.json())
|
|
398
|
+
return str(meta.get('default_branch') or 'main')
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
async def _fetch_all_commits(
|
|
402
|
+
client: httpx.AsyncClient, branch: str
|
|
403
|
+
) -> list[dict[str, typing.Any]]:
|
|
404
|
+
"""Walk every commit reachable from ``branch`` via Link pagination.
|
|
405
|
+
|
|
406
|
+
Capped at ``_MAX_HISTORY_PAGES`` (logged on truncation) so a very deep
|
|
407
|
+
repo can't pin a one-shot backfill indefinitely.
|
|
408
|
+
"""
|
|
409
|
+
params: dict[str, str] = {'sha': branch, 'per_page': '100'}
|
|
410
|
+
out: list[dict[str, typing.Any]] = []
|
|
411
|
+
for page in range(1, _MAX_HISTORY_PAGES + 1):
|
|
412
|
+
resp = await client.get('/commits', params=params)
|
|
413
|
+
resp.raise_for_status()
|
|
414
|
+
out.extend(typing.cast('list[dict[str, typing.Any]]', resp.json()))
|
|
415
|
+
next_url = _next_page_url(resp.headers.get('link'))
|
|
416
|
+
if next_url is None:
|
|
417
|
+
return out
|
|
418
|
+
next_page = _query_param(next_url, 'page')
|
|
419
|
+
if next_page is None:
|
|
420
|
+
return out
|
|
421
|
+
params['page'] = next_page
|
|
422
|
+
if page == _MAX_HISTORY_PAGES:
|
|
423
|
+
LOGGER.warning(
|
|
424
|
+
'github-commit-sync truncated history at %d pages (%d '
|
|
425
|
+
'commits); older commits will not be recorded',
|
|
426
|
+
_MAX_HISTORY_PAGES,
|
|
427
|
+
len(out),
|
|
428
|
+
)
|
|
429
|
+
return out
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
async def _insert_best_effort(
|
|
433
|
+
table: str, records: list[pydantic.BaseModel], project_id: str
|
|
434
|
+
) -> int:
|
|
435
|
+
"""Insert ``records`` into ``table``; return rows written (0 on fail).
|
|
436
|
+
|
|
437
|
+
``imbi_common.clickhouse.insert`` rejects an empty list, so an empty
|
|
438
|
+
set short-circuits to 0. A storage failure is logged and swallowed,
|
|
439
|
+
mirroring the webhook actions -- an analytics hiccup must not fail the
|
|
440
|
+
sync.
|
|
441
|
+
"""
|
|
442
|
+
if not records:
|
|
443
|
+
return 0
|
|
444
|
+
try:
|
|
445
|
+
await clickhouse.insert(table, records)
|
|
446
|
+
except Exception:
|
|
447
|
+
LOGGER.exception(
|
|
448
|
+
'github-commit-sync: failed to record %d %s rows for project %s',
|
|
449
|
+
len(records),
|
|
450
|
+
table,
|
|
451
|
+
project_id,
|
|
452
|
+
)
|
|
453
|
+
return 0
|
|
454
|
+
return len(records)
|
|
455
|
+
|
|
456
|
+
|
|
327
457
|
class SyncCommitsConfig(pydantic.BaseModel):
|
|
328
458
|
"""``WebhookRule.handler_config`` for ``sync_commits``."""
|
|
329
459
|
|
|
@@ -394,7 +524,8 @@ async def sync_commits(
|
|
|
394
524
|
ref = _branch_short_name(ref_raw if isinstance(ref_raw, str) else '')
|
|
395
525
|
before = _resolve(action_config.before_selector, payload)
|
|
396
526
|
pushed_at = datetime.datetime.now(datetime.UTC)
|
|
397
|
-
|
|
527
|
+
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
528
|
+
async with _client(base, owner, repo, token) as client:
|
|
398
529
|
if isinstance(before, str) and before and before != _ZERO_SHA:
|
|
399
530
|
raw = await _fetch_compare_commits(client, before, after)
|
|
400
531
|
else:
|
|
@@ -510,7 +641,8 @@ async def sync_tags(
|
|
|
510
641
|
if resolved is None:
|
|
511
642
|
return
|
|
512
643
|
owner, repo, base = resolved
|
|
513
|
-
|
|
644
|
+
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
645
|
+
async with _client(base, owner, repo, token) as client:
|
|
514
646
|
annotated = await _annotated_tag(client, after)
|
|
515
647
|
records: list[pydantic.BaseModel] = [
|
|
516
648
|
_tag_record(
|
|
@@ -566,9 +698,15 @@ sync_tags_descriptor = ActionDescriptor(
|
|
|
566
698
|
class GitHubCommitSyncPlugin(WebhookActionPlugin):
|
|
567
699
|
"""Webhook-action plugin syncing GitHub commit / tag history.
|
|
568
700
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
701
|
+
Carries its own service credential -- it is *not* folded into the
|
|
702
|
+
identity / deployment / lifecycle plugins, which run as the acting
|
|
703
|
+
user. Two mutually exclusive auth modes are supported (resolved by
|
|
704
|
+
:func:`_resolve_bearer`):
|
|
705
|
+
|
|
706
|
+
* **PAT** -- a static ``access_token``.
|
|
707
|
+
* **GitHub App** -- ``app_id`` + ``private_key`` (raw or base64 PEM),
|
|
708
|
+
with an optional ``installation_id``; the plugin mints a
|
|
709
|
+
short-lived installation token per call and caches it.
|
|
572
710
|
"""
|
|
573
711
|
|
|
574
712
|
manifest = PluginManifest(
|
|
@@ -582,12 +720,101 @@ class GitHubCommitSyncPlugin(WebhookActionPlugin):
|
|
|
582
720
|
credentials=[
|
|
583
721
|
CredentialField(
|
|
584
722
|
name='access_token',
|
|
585
|
-
label='GitHub Token',
|
|
586
|
-
description=
|
|
587
|
-
|
|
723
|
+
label='GitHub Token (PAT)',
|
|
724
|
+
description=(
|
|
725
|
+
'Static personal/service access token. Use this *or* '
|
|
726
|
+
'the GitHub App fields below.'
|
|
727
|
+
),
|
|
728
|
+
required=False,
|
|
729
|
+
),
|
|
730
|
+
CredentialField(
|
|
731
|
+
name='app_id',
|
|
732
|
+
label='GitHub App ID',
|
|
733
|
+
description=(
|
|
734
|
+
'GitHub App identifier; with a private key the plugin '
|
|
735
|
+
'mints short-lived installation tokens.'
|
|
736
|
+
),
|
|
737
|
+
required=False,
|
|
738
|
+
),
|
|
739
|
+
CredentialField(
|
|
740
|
+
name='private_key',
|
|
741
|
+
label='GitHub App Private Key',
|
|
742
|
+
description=(
|
|
743
|
+
'App private key, raw PEM or base64-encoded PEM.'
|
|
744
|
+
),
|
|
745
|
+
required=False,
|
|
746
|
+
),
|
|
747
|
+
CredentialField(
|
|
748
|
+
name='installation_id',
|
|
749
|
+
label='GitHub App Installation ID',
|
|
750
|
+
description=(
|
|
751
|
+
'Optional. When unset, the installation is discovered '
|
|
752
|
+
'from the pushed repository.'
|
|
753
|
+
),
|
|
754
|
+
required=False,
|
|
755
|
+
),
|
|
588
756
|
],
|
|
589
757
|
)
|
|
590
758
|
|
|
591
759
|
@classmethod
|
|
592
760
|
def actions(cls) -> list[ActionDescriptor]:
|
|
593
761
|
return [sync_commits_descriptor, sync_tags_descriptor]
|
|
762
|
+
|
|
763
|
+
async def sync_all_history(
|
|
764
|
+
self,
|
|
765
|
+
*,
|
|
766
|
+
ctx: PluginContext,
|
|
767
|
+
credentials: dict[str, str],
|
|
768
|
+
) -> tuple[int, int]:
|
|
769
|
+
"""Record the project's full default-branch history and all tags.
|
|
770
|
+
|
|
771
|
+
Host-invoked (no webhook payload): the host instantiates the
|
|
772
|
+
plugin, builds a :class:`PluginContext` carrying the project's
|
|
773
|
+
links and the connected ``service_plugins``, resolves this
|
|
774
|
+
plugin's service ``credentials``, and awaits this method. The
|
|
775
|
+
GitHub host/flavor is read from ``service_plugins``, the
|
|
776
|
+
``(owner, repo)`` from the project links, and the bearer token
|
|
777
|
+
from the same PAT-or-App resolution the webhook actions use.
|
|
778
|
+
|
|
779
|
+
Walks every commit reachable from the default branch head plus the
|
|
780
|
+
repo's complete (lightweight) tag list, maps them onto
|
|
781
|
+
``CommitRecord`` / ``TagRecord``, and upserts into the ClickHouse
|
|
782
|
+
``commits`` / ``tags`` tables. ``ReplacingMergeTree`` dedupes
|
|
783
|
+
against rows the webhook already recorded, so re-running is safe.
|
|
784
|
+
|
|
785
|
+
Returns ``(commits_recorded, tags_recorded)``. Raises
|
|
786
|
+
:class:`ValueError` only when the host or repository can't be
|
|
787
|
+
resolved; ClickHouse failures are swallowed (the count reflects
|
|
788
|
+
what was written).
|
|
789
|
+
"""
|
|
790
|
+
host = _resolve_host_for_context(ctx)
|
|
791
|
+
if host is None:
|
|
792
|
+
raise ValueError(
|
|
793
|
+
'github-commit-sync could not resolve a GitHub host for an '
|
|
794
|
+
'on-demand sync: connect a GitHub plugin to the service'
|
|
795
|
+
)
|
|
796
|
+
base = host_to_api_base(host)
|
|
797
|
+
owner, repo = resolve_owner_repo(ctx, host, 'github-commit-sync')
|
|
798
|
+
pushed_at = datetime.datetime.now(datetime.UTC)
|
|
799
|
+
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
800
|
+
async with _client(base, owner, repo, token) as client:
|
|
801
|
+
branch = await _fetch_default_branch(client)
|
|
802
|
+
raw_commits = await _fetch_all_commits(client, branch)
|
|
803
|
+
tags = await _reconcile_tags(client, ctx.project_id)
|
|
804
|
+
commit_records: list[pydantic.BaseModel] = [
|
|
805
|
+
_commit_record(
|
|
806
|
+
item,
|
|
807
|
+
project_id=ctx.project_id,
|
|
808
|
+
ref=branch,
|
|
809
|
+
pushed_at=pushed_at,
|
|
810
|
+
)
|
|
811
|
+
for item in raw_commits
|
|
812
|
+
if item.get('sha')
|
|
813
|
+
]
|
|
814
|
+
commits_recorded = await _insert_best_effort(
|
|
815
|
+
'commits', commit_records, ctx.project_id
|
|
816
|
+
)
|
|
817
|
+
tags_recorded = await _insert_best_effort(
|
|
818
|
+
'tags', list(tags), ctx.project_id
|
|
819
|
+
)
|
|
820
|
+
return commits_recorded, tags_recorded
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
"""Tests for the GitHub commit / tag history sync webhook plugin."""
|
|
2
2
|
|
|
3
|
+
import base64
|
|
3
4
|
import typing
|
|
4
5
|
import unittest
|
|
5
6
|
from unittest import mock
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
8
9
|
import respx
|
|
10
|
+
from cryptography.hazmat.primitives import serialization
|
|
11
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
9
12
|
from imbi_common.models import CommitRecord, TagRecord
|
|
10
13
|
from imbi_common.plugins.base import PluginContext, ServicePlugin
|
|
11
14
|
from imbi_common.plugins.errors import PluginAuthenticationFailed
|
|
12
15
|
|
|
13
|
-
from imbi_plugin_github import commits
|
|
16
|
+
from imbi_plugin_github import _app_auth, commits
|
|
14
17
|
|
|
15
18
|
_ZERO = '0' * 40
|
|
16
19
|
_CREDS = {'access_token': 'gho_test'}
|
|
@@ -18,6 +21,20 @@ _INSERT = 'imbi_plugin_github.commits.clickhouse.insert'
|
|
|
18
21
|
_QUERY = 'imbi_plugin_github.commits.clickhouse.query'
|
|
19
22
|
|
|
20
23
|
|
|
24
|
+
def _gen_pem() -> str:
|
|
25
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
26
|
+
return key.private_bytes(
|
|
27
|
+
serialization.Encoding.PEM,
|
|
28
|
+
serialization.PrivateFormat.PKCS8,
|
|
29
|
+
serialization.NoEncryption(),
|
|
30
|
+
).decode()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Generated once for the module; signing only needs a valid RSA key.
|
|
34
|
+
_APP_KEY_PEM = _gen_pem()
|
|
35
|
+
_FAR_FUTURE = '2099-01-01T00:00:00Z'
|
|
36
|
+
|
|
37
|
+
|
|
21
38
|
def _await_args(m: mock.AsyncMock) -> tuple[typing.Any, ...]:
|
|
22
39
|
assert m.await_args is not None
|
|
23
40
|
return m.await_args.args
|
|
@@ -152,6 +169,21 @@ class ApiBaseResolutionTestCase(unittest.TestCase):
|
|
|
152
169
|
)
|
|
153
170
|
self.assertEqual('https://ghe.example.com/api/v3', self._base(ctx=ctx))
|
|
154
171
|
|
|
172
|
+
def test_own_commit_sync_slug_skipped_on_ghec(self) -> None:
|
|
173
|
+
# A commit-sync entry's slug starts with "github" but has no host
|
|
174
|
+
# option; it must not resolve to github.com on a GHEC service —
|
|
175
|
+
# the real GHEC plugin behind it wins.
|
|
176
|
+
ctx = _ctx(
|
|
177
|
+
service_plugins=[
|
|
178
|
+
ServicePlugin(slug='github-commit-sync', options={}),
|
|
179
|
+
ServicePlugin(
|
|
180
|
+
slug='github-deployment-ec',
|
|
181
|
+
options={'host': 'tenant.ghe.com'},
|
|
182
|
+
),
|
|
183
|
+
]
|
|
184
|
+
)
|
|
185
|
+
self.assertEqual('https://api.tenant.ghe.com', self._base(ctx=ctx))
|
|
186
|
+
|
|
155
187
|
def test_invalid_connected_plugin_host_falls_through(self) -> None:
|
|
156
188
|
ctx = _ctx(
|
|
157
189
|
service_plugins=[
|
|
@@ -361,9 +393,11 @@ class SyncCommitsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
361
393
|
payload=_push(before=base, after=head),
|
|
362
394
|
)
|
|
363
395
|
|
|
364
|
-
async def
|
|
396
|
+
async def test_missing_credentials_raises(self) -> None:
|
|
365
397
|
with self.assertRaises(ValueError):
|
|
366
|
-
commits.
|
|
398
|
+
await commits._resolve_bearer(
|
|
399
|
+
{}, 'https://api.github.com', 'octo', 'demo'
|
|
400
|
+
)
|
|
367
401
|
|
|
368
402
|
|
|
369
403
|
class SyncTagsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
@@ -537,7 +571,12 @@ class ManifestTestCase(unittest.TestCase):
|
|
|
537
571
|
self.assertEqual('github-commit-sync', manifest.slug)
|
|
538
572
|
self.assertEqual('webhook', manifest.plugin_type)
|
|
539
573
|
self.assertEqual(
|
|
540
|
-
['access_token'
|
|
574
|
+
['access_token', 'app_id', 'private_key', 'installation_id'],
|
|
575
|
+
[c.name for c in manifest.credentials],
|
|
576
|
+
)
|
|
577
|
+
self.assertTrue(
|
|
578
|
+
all(not c.required for c in manifest.credentials),
|
|
579
|
+
'all auth credentials are optional (PAT or App mode)',
|
|
541
580
|
)
|
|
542
581
|
|
|
543
582
|
def test_actions(self) -> None:
|
|
@@ -547,3 +586,399 @@ class ManifestTestCase(unittest.TestCase):
|
|
|
547
586
|
# ImportString validation resolved the callables at construction.
|
|
548
587
|
self.assertIs(descriptors[0].callable, commits.sync_commits)
|
|
549
588
|
self.assertIs(descriptors[1].callable, commits.sync_tags)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
class PrivateKeyTestCase(unittest.TestCase):
|
|
592
|
+
def test_raw_pem_passthrough(self) -> None:
|
|
593
|
+
self.assertEqual(
|
|
594
|
+
_APP_KEY_PEM.strip(), _app_auth._load_private_key(_APP_KEY_PEM)
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
def test_base64_pem_decoded(self) -> None:
|
|
598
|
+
b64 = base64.b64encode(_APP_KEY_PEM.encode()).decode()
|
|
599
|
+
self.assertEqual(_APP_KEY_PEM, _app_auth._load_private_key(b64))
|
|
600
|
+
|
|
601
|
+
def test_garbage_raises(self) -> None:
|
|
602
|
+
with self.assertRaises(ValueError):
|
|
603
|
+
_app_auth._load_private_key('not a key at all !!!')
|
|
604
|
+
|
|
605
|
+
def test_base64_of_non_pem_raises(self) -> None:
|
|
606
|
+
b64 = base64.b64encode(b'just some bytes').decode()
|
|
607
|
+
with self.assertRaises(ValueError):
|
|
608
|
+
_app_auth._load_private_key(b64)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
class TokenDeadlineTestCase(unittest.TestCase):
|
|
612
|
+
def test_missing_expiry_uses_default_ttl(self) -> None:
|
|
613
|
+
# No expires_at -> a positive deadline (default ~55m) is returned.
|
|
614
|
+
self.assertGreater(_app_auth._token_deadline(None), 0.0)
|
|
615
|
+
|
|
616
|
+
def test_unparseable_expiry_uses_default_ttl(self) -> None:
|
|
617
|
+
self.assertGreater(_app_auth._token_deadline('not-a-date'), 0.0)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
class ResolveBearerTestCase(unittest.IsolatedAsyncioTestCase):
|
|
621
|
+
async def test_pat_preferred_over_app(self) -> None:
|
|
622
|
+
# A PAT short-circuits before any App minting / network call.
|
|
623
|
+
token = await commits._resolve_bearer(
|
|
624
|
+
{'access_token': 'gho_x', 'app_id': '971', 'private_key': 'k'},
|
|
625
|
+
'https://api.github.com',
|
|
626
|
+
'octo',
|
|
627
|
+
'demo',
|
|
628
|
+
)
|
|
629
|
+
self.assertEqual('gho_x', token)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
class AppAuthSyncTestCase(unittest.IsolatedAsyncioTestCase):
|
|
633
|
+
def setUp(self) -> None:
|
|
634
|
+
_app_auth.reset_cache()
|
|
635
|
+
|
|
636
|
+
def tearDown(self) -> None:
|
|
637
|
+
_app_auth.reset_cache()
|
|
638
|
+
|
|
639
|
+
def _app_creds(
|
|
640
|
+
self, *, installation_id: str | None = '42'
|
|
641
|
+
) -> dict[str, str]:
|
|
642
|
+
creds = {'app_id': '971', 'private_key': _APP_KEY_PEM}
|
|
643
|
+
if installation_id is not None:
|
|
644
|
+
creds['installation_id'] = installation_id
|
|
645
|
+
return creds
|
|
646
|
+
|
|
647
|
+
def _mock_token(self, token: str = 'ghs_minted') -> respx.Route:
|
|
648
|
+
return respx.post(
|
|
649
|
+
'https://api.github.com/app/installations/42/access_tokens'
|
|
650
|
+
).mock(
|
|
651
|
+
return_value=httpx.Response(
|
|
652
|
+
201, json={'token': token, 'expires_at': _FAR_FUTURE}
|
|
653
|
+
)
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
def _mock_compare(self) -> respx.Route:
|
|
657
|
+
base, head = 'a' * 40, 'b' * 40
|
|
658
|
+
return respx.get(
|
|
659
|
+
f'https://api.github.com/repos/octo/demo/compare/{base}...{head}'
|
|
660
|
+
).mock(
|
|
661
|
+
return_value=httpx.Response(
|
|
662
|
+
200, json={'commits': [_commit('c' * 40)]}
|
|
663
|
+
)
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
@respx.mock
|
|
667
|
+
async def test_app_mints_and_uses_token(self) -> None:
|
|
668
|
+
token_route = self._mock_token()
|
|
669
|
+
compare = self._mock_compare()
|
|
670
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
|
|
671
|
+
await commits.sync_commits(
|
|
672
|
+
ctx=_ctx(),
|
|
673
|
+
credentials=self._app_creds(),
|
|
674
|
+
external_identifier='',
|
|
675
|
+
action_config=commits.SyncCommitsConfig(),
|
|
676
|
+
payload=_push(),
|
|
677
|
+
)
|
|
678
|
+
insert.assert_awaited_once()
|
|
679
|
+
self.assertEqual(1, token_route.call_count)
|
|
680
|
+
self.assertEqual(
|
|
681
|
+
'Bearer ghs_minted',
|
|
682
|
+
compare.calls.last.request.headers['authorization'],
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
@respx.mock
|
|
686
|
+
async def test_explicit_installation_skips_discovery(self) -> None:
|
|
687
|
+
discovery = respx.get(
|
|
688
|
+
'https://api.github.com/repos/octo/demo/installation'
|
|
689
|
+
)
|
|
690
|
+
self._mock_token()
|
|
691
|
+
self._mock_compare()
|
|
692
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()):
|
|
693
|
+
await commits.sync_commits(
|
|
694
|
+
ctx=_ctx(),
|
|
695
|
+
credentials=self._app_creds(installation_id='42'),
|
|
696
|
+
external_identifier='',
|
|
697
|
+
action_config=commits.SyncCommitsConfig(),
|
|
698
|
+
payload=_push(),
|
|
699
|
+
)
|
|
700
|
+
self.assertFalse(discovery.called)
|
|
701
|
+
|
|
702
|
+
@respx.mock
|
|
703
|
+
async def test_discovers_installation_when_absent(self) -> None:
|
|
704
|
+
discovery = respx.get(
|
|
705
|
+
'https://api.github.com/repos/octo/demo/installation'
|
|
706
|
+
).mock(return_value=httpx.Response(200, json={'id': 42}))
|
|
707
|
+
token_route = self._mock_token()
|
|
708
|
+
self._mock_compare()
|
|
709
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()):
|
|
710
|
+
await commits.sync_commits(
|
|
711
|
+
ctx=_ctx(),
|
|
712
|
+
credentials=self._app_creds(installation_id=None),
|
|
713
|
+
external_identifier='',
|
|
714
|
+
action_config=commits.SyncCommitsConfig(),
|
|
715
|
+
payload=_push(),
|
|
716
|
+
)
|
|
717
|
+
self.assertEqual(1, discovery.call_count)
|
|
718
|
+
self.assertEqual(1, token_route.call_count)
|
|
719
|
+
|
|
720
|
+
@respx.mock
|
|
721
|
+
async def test_token_cached_across_calls(self) -> None:
|
|
722
|
+
token_route = self._mock_token()
|
|
723
|
+
self._mock_compare()
|
|
724
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
|
|
725
|
+
for _ in range(2):
|
|
726
|
+
await commits.sync_commits(
|
|
727
|
+
ctx=_ctx(),
|
|
728
|
+
credentials=self._app_creds(),
|
|
729
|
+
external_identifier='',
|
|
730
|
+
action_config=commits.SyncCommitsConfig(),
|
|
731
|
+
payload=_push(),
|
|
732
|
+
)
|
|
733
|
+
self.assertEqual(2, insert.await_count)
|
|
734
|
+
# Minted once, reused on the second delivery.
|
|
735
|
+
self.assertEqual(1, token_route.call_count)
|
|
736
|
+
|
|
737
|
+
@respx.mock
|
|
738
|
+
async def test_discovered_installation_and_token_cached(self) -> None:
|
|
739
|
+
discovery = respx.get(
|
|
740
|
+
'https://api.github.com/repos/octo/demo/installation'
|
|
741
|
+
).mock(return_value=httpx.Response(200, json={'id': 42}))
|
|
742
|
+
token_route = self._mock_token()
|
|
743
|
+
self._mock_compare()
|
|
744
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()):
|
|
745
|
+
for _ in range(2):
|
|
746
|
+
await commits.sync_commits(
|
|
747
|
+
ctx=_ctx(),
|
|
748
|
+
credentials=self._app_creds(installation_id=None),
|
|
749
|
+
external_identifier='',
|
|
750
|
+
action_config=commits.SyncCommitsConfig(),
|
|
751
|
+
payload=_push(),
|
|
752
|
+
)
|
|
753
|
+
# Discovery and minting both happen once, then served from cache.
|
|
754
|
+
self.assertEqual(1, discovery.call_count)
|
|
755
|
+
self.assertEqual(1, token_route.call_count)
|
|
756
|
+
|
|
757
|
+
@respx.mock
|
|
758
|
+
async def test_stale_cached_install_rediscovered_on_404(self) -> None:
|
|
759
|
+
# Seed the install cache with a now-stale id, then make minting
|
|
760
|
+
# against it 404 (uninstall/reinstall). The token call should
|
|
761
|
+
# evict the stale id, rediscover, and mint against the new one.
|
|
762
|
+
_app_auth._INSTALL_CACHE[
|
|
763
|
+
('971', 'https://api.github.com', 'octo', 'demo')
|
|
764
|
+
] = '7'
|
|
765
|
+
stale = respx.post(
|
|
766
|
+
'https://api.github.com/app/installations/7/access_tokens'
|
|
767
|
+
).mock(return_value=httpx.Response(404, json={'message': 'gone'}))
|
|
768
|
+
discovery = respx.get(
|
|
769
|
+
'https://api.github.com/repos/octo/demo/installation'
|
|
770
|
+
).mock(return_value=httpx.Response(200, json={'id': 42}))
|
|
771
|
+
fresh = self._mock_token()
|
|
772
|
+
token = await _app_auth.installation_token(
|
|
773
|
+
base='https://api.github.com',
|
|
774
|
+
app_id='971',
|
|
775
|
+
private_key=_APP_KEY_PEM,
|
|
776
|
+
installation_id=None,
|
|
777
|
+
owner='octo',
|
|
778
|
+
repo='demo',
|
|
779
|
+
)
|
|
780
|
+
self.assertEqual('ghs_minted', token)
|
|
781
|
+
self.assertEqual(1, stale.call_count)
|
|
782
|
+
self.assertEqual(1, discovery.call_count)
|
|
783
|
+
self.assertEqual(1, fresh.call_count)
|
|
784
|
+
self.assertEqual(
|
|
785
|
+
'42',
|
|
786
|
+
_app_auth._INSTALL_CACHE[
|
|
787
|
+
('971', 'https://api.github.com', 'octo', 'demo')
|
|
788
|
+
],
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
@respx.mock
|
|
792
|
+
async def test_base64_private_key_accepted(self) -> None:
|
|
793
|
+
self._mock_token()
|
|
794
|
+
self._mock_compare()
|
|
795
|
+
creds = self._app_creds()
|
|
796
|
+
creds['private_key'] = base64.b64encode(_APP_KEY_PEM.encode()).decode()
|
|
797
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
|
|
798
|
+
await commits.sync_commits(
|
|
799
|
+
ctx=_ctx(),
|
|
800
|
+
credentials=creds,
|
|
801
|
+
external_identifier='',
|
|
802
|
+
action_config=commits.SyncCommitsConfig(),
|
|
803
|
+
payload=_push(),
|
|
804
|
+
)
|
|
805
|
+
insert.assert_awaited_once()
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
class HostForContextTestCase(unittest.TestCase):
|
|
809
|
+
def test_resolves_from_connected_github_plugin(self) -> None:
|
|
810
|
+
ctx = _ctx(service_plugins=[ServicePlugin(slug='github', options={})])
|
|
811
|
+
self.assertEqual('github.com', commits._resolve_host_for_context(ctx))
|
|
812
|
+
|
|
813
|
+
def test_skips_own_slug_for_ghec(self) -> None:
|
|
814
|
+
# A commit-sync entry must not be read as github.com on a GHEC
|
|
815
|
+
# service; the real GHEC plugin behind it wins.
|
|
816
|
+
ctx = _ctx(
|
|
817
|
+
service_plugins=[
|
|
818
|
+
ServicePlugin(slug='github-commit-sync', options={}),
|
|
819
|
+
ServicePlugin(
|
|
820
|
+
slug='github-deployment-ec',
|
|
821
|
+
options={'host': 'tenant.ghe.com'},
|
|
822
|
+
),
|
|
823
|
+
]
|
|
824
|
+
)
|
|
825
|
+
self.assertEqual(
|
|
826
|
+
'tenant.ghe.com', commits._resolve_host_for_context(ctx)
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
def test_no_github_plugin_returns_none(self) -> None:
|
|
830
|
+
ctx = _ctx(service_plugins=[ServicePlugin(slug='sonarqube')])
|
|
831
|
+
self.assertIsNone(commits._resolve_host_for_context(ctx))
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
class SyncAllHistoryTestCase(unittest.IsolatedAsyncioTestCase):
|
|
835
|
+
_REPO = 'https://api.github.com/repos/octo/demo'
|
|
836
|
+
|
|
837
|
+
def _ctx(self) -> PluginContext:
|
|
838
|
+
return _ctx(service_plugins=[ServicePlugin(slug='github', options={})])
|
|
839
|
+
|
|
840
|
+
def _mock_default_branch(self, branch: str = 'main') -> respx.Route:
|
|
841
|
+
return respx.get(self._REPO).mock(
|
|
842
|
+
return_value=httpx.Response(200, json={'default_branch': branch})
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
@respx.mock
|
|
846
|
+
async def test_records_full_history_and_tags(self) -> None:
|
|
847
|
+
self._mock_default_branch()
|
|
848
|
+
commit_route = respx.get(f'{self._REPO}/commits').mock(
|
|
849
|
+
return_value=httpx.Response(
|
|
850
|
+
200, json=[_commit('c' * 40), _commit('d' * 40)]
|
|
851
|
+
)
|
|
852
|
+
)
|
|
853
|
+
respx.get(f'{self._REPO}/tags').mock(
|
|
854
|
+
return_value=httpx.Response(
|
|
855
|
+
200,
|
|
856
|
+
json=[
|
|
857
|
+
{'name': 'v1.0.0', 'commit': {'sha': 'z' * 40}},
|
|
858
|
+
{'name': 'v1.1.0', 'commit': {'sha': 'y' * 40}},
|
|
859
|
+
],
|
|
860
|
+
)
|
|
861
|
+
)
|
|
862
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
|
|
863
|
+
result = await commits.GitHubCommitSyncPlugin().sync_all_history(
|
|
864
|
+
ctx=self._ctx(), credentials=_CREDS
|
|
865
|
+
)
|
|
866
|
+
self.assertEqual((2, 2), result)
|
|
867
|
+
self.assertEqual(
|
|
868
|
+
'main', commit_route.calls.last.request.url.params['sha']
|
|
869
|
+
)
|
|
870
|
+
self.assertEqual(2, insert.await_count)
|
|
871
|
+
tables = {call.args[0] for call in insert.await_args_list}
|
|
872
|
+
self.assertEqual({'commits', 'tags'}, tables)
|
|
873
|
+
commit_call = next(
|
|
874
|
+
c for c in insert.await_args_list if c.args[0] == 'commits'
|
|
875
|
+
)
|
|
876
|
+
records = commit_call.args[1]
|
|
877
|
+
self.assertIsInstance(records[0], CommitRecord)
|
|
878
|
+
self.assertEqual('main', records[0].ref)
|
|
879
|
+
|
|
880
|
+
@respx.mock
|
|
881
|
+
async def test_paginates_commits(self) -> None:
|
|
882
|
+
self._mock_default_branch()
|
|
883
|
+
url = f'{self._REPO}/commits'
|
|
884
|
+
respx.get(url).mock(
|
|
885
|
+
side_effect=[
|
|
886
|
+
httpx.Response(
|
|
887
|
+
200,
|
|
888
|
+
json=[_commit('1' * 40)],
|
|
889
|
+
headers={'link': f'<{url}?page=2>; rel="next"'},
|
|
890
|
+
),
|
|
891
|
+
httpx.Response(200, json=[_commit('2' * 40)]),
|
|
892
|
+
]
|
|
893
|
+
)
|
|
894
|
+
respx.get(f'{self._REPO}/tags').mock(
|
|
895
|
+
return_value=httpx.Response(200, json=[])
|
|
896
|
+
)
|
|
897
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
|
|
898
|
+
(
|
|
899
|
+
commits_recorded,
|
|
900
|
+
tags_recorded,
|
|
901
|
+
) = await commits.GitHubCommitSyncPlugin().sync_all_history(
|
|
902
|
+
ctx=self._ctx(), credentials=_CREDS
|
|
903
|
+
)
|
|
904
|
+
self.assertEqual(2, commits_recorded)
|
|
905
|
+
self.assertEqual(0, tags_recorded)
|
|
906
|
+
# Only the commits insert ran; the empty tag list short-circuits.
|
|
907
|
+
insert.assert_awaited_once()
|
|
908
|
+
|
|
909
|
+
@respx.mock
|
|
910
|
+
async def test_history_truncation_logs(self) -> None:
|
|
911
|
+
self._mock_default_branch()
|
|
912
|
+
url = f'{self._REPO}/commits'
|
|
913
|
+
respx.get(url).mock(
|
|
914
|
+
return_value=httpx.Response(
|
|
915
|
+
200,
|
|
916
|
+
json=[_commit('1' * 40)],
|
|
917
|
+
headers={'link': f'<{url}?page=2>; rel="next"'},
|
|
918
|
+
)
|
|
919
|
+
)
|
|
920
|
+
respx.get(f'{self._REPO}/tags').mock(
|
|
921
|
+
return_value=httpx.Response(200, json=[])
|
|
922
|
+
)
|
|
923
|
+
with mock.patch.object(commits, '_MAX_HISTORY_PAGES', 1):
|
|
924
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()):
|
|
925
|
+
with self.assertLogs(commits.LOGGER, level='WARNING') as cm:
|
|
926
|
+
await commits.GitHubCommitSyncPlugin().sync_all_history(
|
|
927
|
+
ctx=self._ctx(), credentials=_CREDS
|
|
928
|
+
)
|
|
929
|
+
self.assertTrue(any('truncated history' in x for x in cm.output))
|
|
930
|
+
|
|
931
|
+
@respx.mock
|
|
932
|
+
async def test_no_github_host_raises(self) -> None:
|
|
933
|
+
with self.assertRaises(ValueError):
|
|
934
|
+
await commits.GitHubCommitSyncPlugin().sync_all_history(
|
|
935
|
+
ctx=_ctx(), credentials=_CREDS
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
@respx.mock
|
|
939
|
+
async def test_clickhouse_failure_swallowed(self) -> None:
|
|
940
|
+
self._mock_default_branch()
|
|
941
|
+
respx.get(f'{self._REPO}/commits').mock(
|
|
942
|
+
return_value=httpx.Response(200, json=[_commit('c' * 40)])
|
|
943
|
+
)
|
|
944
|
+
respx.get(f'{self._REPO}/tags').mock(
|
|
945
|
+
return_value=httpx.Response(200, json=[])
|
|
946
|
+
)
|
|
947
|
+
failing = mock.AsyncMock(side_effect=RuntimeError('clickhouse down'))
|
|
948
|
+
with mock.patch(_INSERT, new=failing):
|
|
949
|
+
with self.assertLogs(commits.LOGGER, level='ERROR'):
|
|
950
|
+
result = await (
|
|
951
|
+
commits.GitHubCommitSyncPlugin().sync_all_history(
|
|
952
|
+
ctx=self._ctx(), credentials=_CREDS
|
|
953
|
+
)
|
|
954
|
+
)
|
|
955
|
+
self.assertEqual((0, 0), result)
|
|
956
|
+
|
|
957
|
+
@respx.mock
|
|
958
|
+
async def test_app_auth_mints_token(self) -> None:
|
|
959
|
+
_app_auth.reset_cache()
|
|
960
|
+
self.addCleanup(_app_auth.reset_cache)
|
|
961
|
+
token_route = respx.post(
|
|
962
|
+
'https://api.github.com/app/installations/42/access_tokens'
|
|
963
|
+
).mock(
|
|
964
|
+
return_value=httpx.Response(
|
|
965
|
+
201, json={'token': 'ghs_minted', 'expires_at': _FAR_FUTURE}
|
|
966
|
+
)
|
|
967
|
+
)
|
|
968
|
+
self._mock_default_branch()
|
|
969
|
+
respx.get(f'{self._REPO}/commits').mock(
|
|
970
|
+
return_value=httpx.Response(200, json=[_commit('c' * 40)])
|
|
971
|
+
)
|
|
972
|
+
respx.get(f'{self._REPO}/tags').mock(
|
|
973
|
+
return_value=httpx.Response(200, json=[])
|
|
974
|
+
)
|
|
975
|
+
creds = {
|
|
976
|
+
'app_id': '971',
|
|
977
|
+
'private_key': _APP_KEY_PEM,
|
|
978
|
+
'installation_id': '42',
|
|
979
|
+
}
|
|
980
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()):
|
|
981
|
+
await commits.GitHubCommitSyncPlugin().sync_all_history(
|
|
982
|
+
ctx=self._ctx(), credentials=creds
|
|
983
|
+
)
|
|
984
|
+
self.assertEqual(1, token_route.call_count)
|
|
@@ -667,7 +667,7 @@ wheels = [
|
|
|
667
667
|
|
|
668
668
|
[[package]]
|
|
669
669
|
name = "imbi-common"
|
|
670
|
-
version = "2.9.
|
|
670
|
+
version = "2.9.2"
|
|
671
671
|
source = { registry = "https://pypi.org/simple" }
|
|
672
672
|
dependencies = [
|
|
673
673
|
{ name = "cryptography" },
|
|
@@ -683,9 +683,9 @@ dependencies = [
|
|
|
683
683
|
{ name = "python-slugify" },
|
|
684
684
|
{ name = "typer" },
|
|
685
685
|
]
|
|
686
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
686
|
+
sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/39325e67d4fa8de59ee2962339cc5c7dcd0891298c034b23950b003d120e/imbi_common-2.9.2.tar.gz", hash = "sha256:236b0a7baee0125c4af9449c1a99326d49813466e7b0732b4ebbcb618a7f8cf6", size = 338283, upload-time = "2026-06-04T17:42:41.652Z" }
|
|
687
687
|
wheels = [
|
|
688
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
688
|
+
{ url = "https://files.pythonhosted.org/packages/ec/86/88ff71441a35836c791fbd7565e1a2359bddf60b91376eb132fd0d2d3dce/imbi_common-2.9.2-py3-none-any.whl", hash = "sha256:f72db0695a0e7406e99e2a7c4497883f76f3025af82e904e3bfd2d2c013d52de", size = 108246, upload-time = "2026-06-04T17:42:40.216Z" },
|
|
689
689
|
]
|
|
690
690
|
|
|
691
691
|
[package.optional-dependencies]
|
|
@@ -703,12 +703,13 @@ server = [
|
|
|
703
703
|
|
|
704
704
|
[[package]]
|
|
705
705
|
name = "imbi-plugin-github"
|
|
706
|
-
version = "2.9.
|
|
706
|
+
version = "2.9.2"
|
|
707
707
|
source = { editable = "." }
|
|
708
708
|
dependencies = [
|
|
709
709
|
{ name = "httpx" },
|
|
710
710
|
{ name = "imbi-common", extra = ["databases"] },
|
|
711
711
|
{ name = "pydantic" },
|
|
712
|
+
{ name = "pyjwt", extra = ["crypto"] },
|
|
712
713
|
]
|
|
713
714
|
|
|
714
715
|
[package.dev-dependencies]
|
|
@@ -731,15 +732,16 @@ dist = [
|
|
|
731
732
|
[package.metadata]
|
|
732
733
|
requires-dist = [
|
|
733
734
|
{ name = "httpx", specifier = ">=0.27" },
|
|
734
|
-
{ name = "imbi-common", extras = ["databases"], specifier = "==2.9.
|
|
735
|
+
{ name = "imbi-common", extras = ["databases"], specifier = "==2.9.2" },
|
|
735
736
|
{ name = "pydantic", specifier = ">=2" },
|
|
737
|
+
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.8" },
|
|
736
738
|
]
|
|
737
739
|
|
|
738
740
|
[package.metadata.requires-dev]
|
|
739
741
|
dev = [
|
|
740
742
|
{ name = "basedpyright" },
|
|
741
743
|
{ name = "coverage", extras = ["toml"] },
|
|
742
|
-
{ name = "imbi-common", extras = ["server", "databases"], specifier = "==2.9.
|
|
744
|
+
{ name = "imbi-common", extras = ["server", "databases"], specifier = "==2.9.2" },
|
|
743
745
|
{ name = "pre-commit" },
|
|
744
746
|
{ name = "pytest" },
|
|
745
747
|
{ name = "pytest-asyncio" },
|
|
@@ -1430,6 +1432,11 @@ wheels = [
|
|
|
1430
1432
|
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
|
|
1431
1433
|
]
|
|
1432
1434
|
|
|
1435
|
+
[package.optional-dependencies]
|
|
1436
|
+
crypto = [
|
|
1437
|
+
{ name = "cryptography" },
|
|
1438
|
+
]
|
|
1439
|
+
|
|
1433
1440
|
[[package]]
|
|
1434
1441
|
name = "pyproject-hooks"
|
|
1435
1442
|
version = "1.2.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|