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.
Files changed (27) hide show
  1. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/.pre-commit-config.yaml +3 -0
  2. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/PKG-INFO +47 -2
  3. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/README.md +44 -0
  4. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/pyproject.toml +4 -3
  5. imbi_plugin_github-2.9.3/src/imbi_plugin_github/_app_auth.py +195 -0
  6. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/commits.py +299 -26
  7. imbi_plugin_github-2.9.3/tests/test_commits.py +1097 -0
  8. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/uv.lock +13 -6
  9. imbi_plugin_github-2.9.1/tests/test_commits.py +0 -549
  10. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/.github/workflows/publish.yml +0 -0
  11. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/.github/workflows/test.yml +0 -0
  12. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/.gitignore +0 -0
  13. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/CLAUDE.md +0 -0
  14. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/LICENSE +0 -0
  15. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/justfile +0 -0
  16. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/__init__.py +0 -0
  17. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/_hosts.py +0 -0
  18. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/_repos.py +0 -0
  19. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/deployment.py +0 -0
  20. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/identity.py +0 -0
  21. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/lifecycle.py +0 -0
  22. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/tests/__init__.py +0 -0
  23. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/tests/test_deployment.py +0 -0
  24. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/tests/test_hosts.py +0 -0
  25. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/tests/test_identity.py +0 -0
  26. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/tests/test_lifecycle.py +0 -0
  27. {imbi_plugin_github-2.9.1 → imbi_plugin_github-2.9.3}/tests/test_repos.py +0 -0
@@ -21,6 +21,9 @@ repos:
21
21
  rev: v0.7.25
22
22
  hooks:
23
23
  - id: tombi-format
24
+ # uv owns uv.lock's formatting; tombi reflows it (2- vs 4-space),
25
+ # producing whole-file churn on every dependency change.
26
+ exclude: ^uv\.lock$
24
27
 
25
28
  - repo: local
26
29
  hooks:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: imbi-plugin-github
3
- Version: 2.9.1
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.1
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.1"
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.1",
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.1",
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