imbi-plugin-github 2.9.0__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.
Files changed (26) hide show
  1. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/.pre-commit-config.yaml +3 -0
  2. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/PKG-INFO +47 -2
  3. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/README.md +44 -0
  4. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/pyproject.toml +4 -3
  5. imbi_plugin_github-2.9.2/src/imbi_plugin_github/_app_auth.py +195 -0
  6. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/src/imbi_plugin_github/commits.py +243 -16
  7. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/tests/test_commits.py +439 -4
  8. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/uv.lock +13 -6
  9. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/.github/workflows/publish.yml +0 -0
  10. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/.github/workflows/test.yml +0 -0
  11. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/.gitignore +0 -0
  12. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/CLAUDE.md +0 -0
  13. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/LICENSE +0 -0
  14. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/justfile +0 -0
  15. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/src/imbi_plugin_github/__init__.py +0 -0
  16. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/src/imbi_plugin_github/_hosts.py +0 -0
  17. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/src/imbi_plugin_github/_repos.py +0 -0
  18. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/src/imbi_plugin_github/deployment.py +0 -0
  19. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/src/imbi_plugin_github/identity.py +0 -0
  20. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/src/imbi_plugin_github/lifecycle.py +0 -0
  21. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/tests/__init__.py +0 -0
  22. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/tests/test_deployment.py +0 -0
  23. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/tests/test_hosts.py +0 -0
  24. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/tests/test_identity.py +0 -0
  25. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/tests/test_lifecycle.py +0 -0
  26. {imbi_plugin_github-2.9.0 → imbi_plugin_github-2.9.2}/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.0
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.0
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.0"
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.0",
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.0",
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
- def _token(credentials: dict[str, str]) -> str:
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 not token:
78
- raise ValueError(
79
- 'github-commit-sync requires a service token; expected '
80
- "``credentials['access_token']``"
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
- return token
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
- async with _client(base, owner, repo, _token(credentials)) as client:
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
- async with _client(base, owner, repo, _token(credentials)) as client:
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
- Declares its own ``access_token`` service credential -- it is *not*
570
- folded into the identity / deployment / lifecycle plugins, which run
571
- as the acting user and carry no service token.
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='Service token used to fetch commit history.',
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 test_missing_token_raises(self) -> None:
396
+ async def test_missing_credentials_raises(self) -> None:
365
397
  with self.assertRaises(ValueError):
366
- commits._token({})
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'], [c.name for c in manifest.credentials]
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.0"
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/ff/5e/e7306a4bafa6e23ad8fd6dcdaef552d646233b974b4cfd3352750ff57631/imbi_common-2.9.0.tar.gz", hash = "sha256:6687219808b824bc65af7229a6193cf2bf86e5b348ad2d3928126446c288b9ef", size = 336785, upload-time = "2026-06-03T15:52:11.958Z" }
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/c2/dc/3c3e782709e5557620aa37e089233a267b0d73c9da44dbcfad963805f646/imbi_common-2.9.0-py3-none-any.whl", hash = "sha256:a8fad461cc7759e61ba101fa44f047610690805163ab43f8e10c8b97d91f8f0c", size = 107509, upload-time = "2026-06-03T15:52:10.159Z" },
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.0"
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.0" },
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.0" },
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"