imbi-plugin-github 2.11.5__tar.gz → 2.12.1__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 (33) hide show
  1. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/CLAUDE.md +31 -21
  2. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/PKG-INFO +2 -2
  3. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/pyproject.toml +3 -16
  4. imbi_plugin_github-2.12.1/src/imbi_plugin_github/__init__.py +13 -0
  5. imbi_plugin_github-2.12.1/src/imbi_plugin_github/_hosts.py +113 -0
  6. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/src/imbi_plugin_github/_repos.py +2 -2
  7. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/src/imbi_plugin_github/commits.py +59 -154
  8. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/src/imbi_plugin_github/deployment.py +25 -173
  9. imbi_plugin_github-2.12.1/src/imbi_plugin_github/doctor.py +676 -0
  10. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/src/imbi_plugin_github/identity.py +31 -192
  11. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/src/imbi_plugin_github/lifecycle.py +34 -200
  12. imbi_plugin_github-2.12.1/src/imbi_plugin_github/plugin.py +335 -0
  13. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/src/imbi_plugin_github/pull_requests.py +32 -67
  14. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/tests/test_commits.py +67 -105
  15. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/tests/test_deployment.py +137 -124
  16. imbi_plugin_github-2.12.1/tests/test_doctor.py +531 -0
  17. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/tests/test_hosts.py +28 -0
  18. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/tests/test_identity.py +96 -74
  19. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/tests/test_lifecycle.py +113 -107
  20. imbi_plugin_github-2.12.1/tests/test_pull_requests.py +184 -0
  21. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/uv.lock +6 -6
  22. imbi_plugin_github-2.11.5/src/imbi_plugin_github/__init__.py +0 -13
  23. imbi_plugin_github-2.11.5/src/imbi_plugin_github/_hosts.py +0 -67
  24. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/.github/workflows/publish.yml +0 -0
  25. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/.github/workflows/test.yml +0 -0
  26. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/.gitignore +0 -0
  27. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/.pre-commit-config.yaml +0 -0
  28. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/LICENSE +0 -0
  29. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/README.md +0 -0
  30. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/justfile +0 -0
  31. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/src/imbi_plugin_github/_app_auth.py +0 -0
  32. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/tests/__init__.py +0 -0
  33. {imbi_plugin_github-2.11.5 → imbi_plugin_github-2.12.1}/tests/test_repos.py +0 -0
@@ -5,10 +5,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
5
5
  ## What This Is
6
6
 
7
7
  A set of GitHub plugins for the Imbi platform, distributed as a single
8
- Python package (`imbi_plugin_github`). It ships **three plugin types**
9
- (identity, deployment, lifecycle), each in **three host flavors**
10
- (github.com, GitHub Enterprise Cloud, GitHub Enterprise Server) — nine
11
- plugins total. The Imbi host discovers them through the
8
+ Python package (`imbi_plugin_github`). It ships **one connection plugin**
9
+ plus **one host-agnostic plugin per behavior** `github-connection`,
10
+ `github-identity`, `github-deployment`, `github-lifecycle`,
11
+ `github-commit-sync`, `github-pr-sync` (six total). There are no longer
12
+ per-flavor (github.com / GHEC / GHES) variants: the operator picks the
13
+ flavor + host once on the connection plugin, and every behavioral plugin
14
+ reads it from there. The Imbi host discovers them through the
12
15
  `imbi.plugins` entry points declared in `pyproject.toml`; that table is
13
16
  the registration surface — adding a plugin class means adding an entry
14
17
  point there.
@@ -38,21 +41,25 @@ over `src`. Python is pinned to **3.14**; ruff uses single quotes and a
38
41
 
39
42
  ## Architecture
40
43
 
41
- ### The base/subclass/host-flavor pattern
44
+ ### The connection-plugin / host-agnostic pattern
42
45
 
43
- Every plugin type follows the same shape: one `_*Base` class holds all
44
- behavior, and three thin concrete subclasses differ *only* in their
45
- `_resolve_host(options)` classmethod:
46
+ A single `github-connection` plugin (a `ConnectionPlugin` from
47
+ `imbi_common`, with no behavior) carries the `flavor` option
48
+ (`github.com` / `ghec` / `ghes`), the `host` option, and the shared
49
+ App/PAT credentials for the service. Every behavioral plugin is
50
+ host-agnostic and resolves its host from that connection plugin via the
51
+ `github-connection` entry on `ctx.service_plugins`:
46
52
 
47
- - github.com flavor returns `'github.com'`.
48
- - GHEC flavor → reads the required `host` option, validates it's a
49
- `*.ghe.com` tenant via `require_ghec_tenant_host`.
50
- - GHES flavor reads the required `host` option, normalized only.
53
+ - identity / deployment / lifecycle call `_resolve_host(ctx)` →
54
+ `resolve_connection_host(ctx.service_plugins, label)`.
55
+ - commit-sync / pr-sync resolve it through `_connection_host` /
56
+ `_resolve_api_base` (the webhook path also falls back to the explicit
57
+ `api_base_url`, the service endpoint, and the push payload's
58
+ `repository.url`, in that order).
51
59
 
52
- `_resolve_host` feeds URL construction, and the three backends route
53
- differently this mapping is the single most important thing to keep
54
- consistent and is **duplicated** across the three modules
55
- (`identity._endpoints`, `deployment._api_base`, `lifecycle._api_base`):
60
+ `resolve_connection_host` returns a bare host; `host_to_api_base` maps it
61
+ to the REST API base. Routing (the single source of truth in
62
+ `host_to_api_base`, with OAuth URLs built in `identity._endpoints`):
56
63
 
57
64
  | Host | REST API base | OAuth base (identity only) |
58
65
  | ------------------- | ------------------------------ | --------------------------------- |
@@ -60,14 +67,17 @@ consistent and is **duplicated** across the three modules
60
67
  | `<tenant>.ghe.com` | `https://api.<tenant>.ghe.com` | `https://<tenant>.ghe.com/login/oauth` |
61
68
  | GHES `<host>` | `https://<host>/api/v3` | `https://<host>/login/oauth` |
62
69
 
63
- When you change routing for one plugin type, check whether the other two
64
- need the same change.
70
+ The connection plugin must be populated onto `ctx.service_plugins` by the
71
+ host: imbi-gateway already surfaces every service plugin for the webhook
72
+ path; imbi-api populates it for identity/deployment/lifecycle calls.
65
73
 
66
74
  ### Shared helpers (single sources of truth)
67
75
 
68
- - `_hosts.py` — `normalize_host` (strip/validate a bare hostname, reject
69
- paths/ports/queries) and `require_ghec_tenant_host`. All host-option
70
- validation lives here.
76
+ - `_hosts.py` — `normalize_host` / `require_ghec_tenant_host` (host-option
77
+ validation), `flavor_host` (validate a connection plugin's
78
+ `flavor`+`host` to a bare host), `find_connection` /
79
+ `resolve_connection_host` (locate the `github-connection` sibling on
80
+ `service_plugins`), and `host_to_api_base` (bare host → REST API base).
71
81
  - `_repos.py` — `resolve_owner_repo(ctx, host, label)` derives the target
72
82
  `(owner, repo)` for deployment and lifecycle calls: it scans
73
83
  `ctx.project_links` (preferring the `github-repository` key, skipping
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: imbi-plugin-github
3
- Version: 2.11.5
3
+ Version: 2.12.1
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,7 +13,7 @@ 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.11.5
16
+ Requires-Dist: imbi-common[databases]==2.12.1
17
17
  Requires-Dist: pydantic>=2
18
18
  Requires-Dist: pyjwt[crypto]>=2.8
19
19
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "imbi-plugin-github"
3
- version = "2.11.5"
3
+ version = "2.12.1"
4
4
  description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -16,29 +16,16 @@ classifiers = [
16
16
  ]
17
17
  dependencies = [
18
18
  "httpx>=0.27",
19
- "imbi-common[databases]==2.11.5",
19
+ "imbi-common[databases]==2.12.1",
20
20
  "pydantic>=2",
21
21
  "pyjwt[crypto]>=2.8",
22
22
  ]
23
23
 
24
- [project.entry-points."imbi.plugins"]
25
- github = "imbi_plugin_github.identity:GitHubPlugin"
26
- github-enterprise-cloud = "imbi_plugin_github.identity:GitHubEnterpriseCloudPlugin"
27
- github-enterprise-server = "imbi_plugin_github.identity:GitHubEnterpriseServerPlugin"
28
- github-deployment = "imbi_plugin_github.deployment:GitHubDeploymentPlugin"
29
- github-deployment-ec = "imbi_plugin_github.deployment:GitHubEnterpriseCloudDeploymentPlugin"
30
- github-deployment-es = "imbi_plugin_github.deployment:GitHubEnterpriseServerDeploymentPlugin"
31
- github-lifecycle = "imbi_plugin_github.lifecycle:GitHubLifecyclePlugin"
32
- github-lifecycle-ec = "imbi_plugin_github.lifecycle:GitHubEnterpriseCloudLifecyclePlugin"
33
- github-lifecycle-es = "imbi_plugin_github.lifecycle:GitHubEnterpriseServerLifecyclePlugin"
34
- github-commit-sync = "imbi_plugin_github.commits:GitHubCommitSyncPlugin"
35
- github-pr-sync = "imbi_plugin_github.pull_requests:GitHubPRSyncPlugin"
36
-
37
24
  [dependency-groups]
38
25
  dev = [
39
26
  "basedpyright",
40
27
  "coverage[toml]",
41
- "imbi-common[server,databases]==2.11.5",
28
+ "imbi-common[server,databases]==2.12.1",
42
29
  "pre-commit",
43
30
  "pytest",
44
31
  "pytest-asyncio",
@@ -0,0 +1,13 @@
1
+ """Imbi GitHub plugin (Architecture v3).
2
+
3
+ One :class:`~imbi_plugin_github.plugin.GitHubPlugin` backs every GitHub
4
+ Integration. The registry discovers this package by its ``imbi_plugin_*``
5
+ name and reads the module-level :data:`PLUGIN` attribute.
6
+ """
7
+
8
+ from imbi_plugin_github.plugin import GitHubPlugin
9
+
10
+ #: The package's single plugin, discovered by the registry convention scan.
11
+ PLUGIN = GitHubPlugin
12
+
13
+ __all__ = ['PLUGIN', 'GitHubPlugin']
@@ -0,0 +1,113 @@
1
+ """Shared host resolution utilities for the GitHub plugin.
2
+
3
+ Every capability resolves the GitHub host (github.com, a GHEC
4
+ ``*.ghe.com`` tenant, or a GHES appliance) from the Integration's
5
+ ``flavor`` + ``host`` option values, surfaced on
6
+ ``PluginContext.integration_options``. This module is the single source
7
+ of truth for validating those options and mapping the resolved host to
8
+ the REST API base.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import typing
15
+ import urllib.parse
16
+
17
+ LOGGER = logging.getLogger(__name__)
18
+
19
+
20
+ def normalize_host(raw: typing.Any, label: str) -> str:
21
+ """Validate and normalize an integration ``host`` value.
22
+
23
+ Strips whitespace, accepts an optional scheme, and rejects values
24
+ with paths / queries / fragments so callers can compose URLs from
25
+ the result without producing malformed endpoints.
26
+ """
27
+ host = str(raw or '').strip()
28
+ if not host:
29
+ raise ValueError(f'{label} requires the "host" option')
30
+ parsed = urllib.parse.urlsplit(
31
+ host if '://' in host else f'https://{host}'
32
+ )
33
+ if (
34
+ not parsed.hostname
35
+ or parsed.port is not None
36
+ or parsed.path not in ('', '/')
37
+ or parsed.query
38
+ or parsed.fragment
39
+ ):
40
+ raise ValueError(f'{label} got invalid host value: {host!r}')
41
+ return parsed.hostname
42
+
43
+
44
+ def require_ghec_tenant_host(host: str, label: str) -> str:
45
+ """Refuse anything that isn't a ``*.ghe.com`` tenant host."""
46
+ if (
47
+ not host.endswith('.ghe.com')
48
+ or host == '.ghe.com'
49
+ or host.startswith('api.')
50
+ ):
51
+ raise ValueError(
52
+ f'{label} requires a tenant host like "tenant.ghe.com"; '
53
+ f'got {host!r}'
54
+ )
55
+ return host
56
+
57
+
58
+ def host_to_api_base(host: str) -> str:
59
+ """Map a resolved GitHub host to its REST API base.
60
+
61
+ The single source of truth for GitHub's flavor routing:
62
+ ``github.com`` -> ``api.github.com``, a ``*.ghe.com`` tenant ->
63
+ ``api.<tenant>.ghe.com``, and a GHES appliance -> ``<host>/api/v3``.
64
+ """
65
+ if host == 'github.com':
66
+ return 'https://api.github.com'
67
+ if host.endswith('.ghe.com'):
68
+ return f'https://api.{host}'
69
+ return f'https://{host}/api/v3'
70
+
71
+
72
+ def flavor_host(options: dict[str, typing.Any], label: str) -> str:
73
+ """Validate the Integration's ``flavor`` + ``host`` to a bare host.
74
+
75
+ The operator picks an explicit ``flavor`` (``github`` / ``ghec`` /
76
+ ``ghes``); the ``host`` is required for the two enterprise flavors
77
+ and ignored for ``github``. Returns the bare hostname the rest of the
78
+ plugin composes URLs against (``github.com``, the validated
79
+ ``*.ghe.com`` tenant, or the normalized GHES appliance host).
80
+ """
81
+ flavor = str(options.get('flavor') or '').strip()
82
+ if flavor == 'github':
83
+ return 'github.com'
84
+ if flavor == 'ghec':
85
+ host = normalize_host(options.get('host'), label)
86
+ # Accept a bare tenant slug (e.g. ``aweber``) and compute the full
87
+ # tenant host; GHEC tenants always live under ``.ghe.com``.
88
+ if '.' not in host:
89
+ host = f'{host}.ghe.com'
90
+ return require_ghec_tenant_host(host, label)
91
+ if flavor == 'ghes':
92
+ return normalize_host(options.get('host'), label)
93
+ raise ValueError(
94
+ f'{label} got invalid integration flavor {flavor!r}; expected one '
95
+ f'of "github", "ghec", or "ghes"'
96
+ )
97
+
98
+
99
+ def resolve_host(
100
+ integration_options: dict[str, typing.Any], label: str
101
+ ) -> str | None:
102
+ """Resolve the GitHub host from the Integration options, or ``None``.
103
+
104
+ Returns ``None`` (after logging) when the flavor/host is missing or
105
+ unusable so callers on the webhook path can fall through to another
106
+ resolution source rather than failing the delivery. Callers that
107
+ require a host raise on the ``None``.
108
+ """
109
+ try:
110
+ return flavor_host(integration_options, label)
111
+ except ValueError as exc:
112
+ LOGGER.warning('%s: unusable integration flavor/host: %s', label, exc)
113
+ return None
@@ -74,7 +74,7 @@ def derive_owner_repo_from_links(
74
74
  """Find a project link pointing at ``host`` and parse owner/repo.
75
75
 
76
76
  Prefers the dashboard link keyed by ``preferred_key`` (the bound
77
- third-party-service slug) when present and pointing at ``host``,
77
+ Integration slug) when present and pointing at ``host``,
78
78
  then the legacy ``github-repository`` link key, then scans the
79
79
  remaining same-host links and returns the first usable one.
80
80
  Returns ``None`` when nothing matches.
@@ -122,7 +122,7 @@ def resolve_owner_repo(
122
122
  derived = derive_owner_repo_from_links(
123
123
  ctx.project_links,
124
124
  host,
125
- preferred_key=ctx.third_party_service_slug,
125
+ preferred_key=ctx.integration_slug,
126
126
  )
127
127
  if derived is not None:
128
128
  return derived
@@ -1,28 +1,23 @@
1
1
  """GitHub commit / tag history sync (webhook action plugin).
2
2
 
3
- A single :class:`GitHubCommitSyncPlugin` exposes two webhook actions --
4
- ``sync_commits`` and ``sync_tags`` -- dispatched by ``imbi-gateway`` on
5
- ``push`` deliveries. Unlike the identity / deployment / lifecycle
6
- families, the host (github.com vs. GHEC tenant vs. GHES appliance) is
7
- *runtime* data on the webhook path rather than a class attribute, so one
8
- callable serves all three flavors. The API base is resolved per call, in
9
- order:
3
+ The module exposes two webhook actions -- ``sync_commits`` and
4
+ ``sync_tags`` -- dispatched by ``imbi-gateway`` on ``push`` deliveries
5
+ (catalogued by the plugin's ``webhook-actions`` capability). The API base
6
+ is resolved per call, in order:
10
7
 
11
8
  1. ``api_base_url`` from the rule's ``handler_config`` (explicit
12
9
  override), else
13
- 2. the GitHub plugin connected to the same ``ThirdPartyService`` --
14
- surfaced on ``ctx.service_plugins`` (slug -> flavor,
15
- ``options['host']`` -> host), else
16
- 3. the ``ThirdPartyService.api_endpoint``
17
- (``ctx.assignment_options['service_endpoint']``), else
18
- 4. the push payload's ``repository.url`` (already the flavor-correct API
10
+ 2. the Integration's ``flavor`` + ``host`` options on
11
+ ``ctx.integration_options``, else
12
+ 3. the push payload's ``repository.url`` (already the flavor-correct API
19
13
  URL) as a last resort.
20
14
 
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.
15
+ The :class:`GitHubCommitSync` capability handler exposes
16
+ :meth:`~GitHubCommitSync.sync_all_history` for an on-demand, host-invoked
17
+ backfill: there is no push payload, so the GitHub host is read from
18
+ ``ctx.integration_options`` and ``(owner, repo)`` from the project links;
19
+ it walks the full default-branch history and the complete tag list rather
20
+ than a single push delta.
26
21
 
27
22
  Commit / tag rows are written to the shared ClickHouse ``commits`` /
28
23
  ``tags`` tables via :func:`imbi_common.clickhouse.insert`. Writes are
@@ -51,20 +46,13 @@ from imbi_common.models import CommitRecord, TagRecord
51
46
  from imbi_common.plugins.base import (
52
47
  ActionDescriptor,
53
48
  CheckStatus,
54
- CredentialField,
49
+ CommitSyncCapability,
55
50
  PluginContext,
56
- PluginManifest,
57
- ServicePlugin,
58
- WebhookActionPlugin,
59
51
  )
60
52
  from imbi_common.plugins.errors import PluginRateLimited
61
53
 
62
54
  from imbi_plugin_github import _app_auth
63
- from imbi_plugin_github._hosts import (
64
- host_to_api_base,
65
- normalize_host,
66
- require_ghec_tenant_host,
67
- )
55
+ from imbi_plugin_github._hosts import host_to_api_base, resolve_host
68
56
  from imbi_plugin_github._repos import resolve_owner_repo
69
57
  from imbi_plugin_github.deployment import (
70
58
  _auth_headers, # pyright: ignore[reportPrivateUsage]
@@ -82,10 +70,6 @@ LOGGER = logging.getLogger(__name__)
82
70
 
83
71
  _HTTP_TIMEOUT_SECONDS = 10.0
84
72
  _ZERO_SHA = '0' * 40
85
- # This plugin's own slug; skipped when reading the GitHub host/flavor
86
- # from connected ``service_plugins`` so the commit-sync entry can't
87
- # masquerade as a github.com host on a GHEC/GHES service.
88
- _SELF_SLUG = 'github-commit-sync'
89
73
  # GitHub's compare endpoint caps ``commits[]`` at 250 per page and
90
74
  # paginates the rest; bound the walk so a pathological single push
91
75
  # (force-push of thousands of commits) can't pin us on one endpoint.
@@ -309,34 +293,6 @@ def _branch_short_name(ref: str) -> str:
309
293
  return ref.removeprefix(prefix)
310
294
 
311
295
 
312
- def _github_plugin_host(plugin: ServicePlugin) -> str | None:
313
- """Resolve the GitHub host from a connected plugin's slug + options.
314
-
315
- Returns ``None`` for non-GitHub plugins and for GitHub plugins whose
316
- required ``host`` option is missing or invalid (logged) so the caller
317
- can fall through to the next resolution source.
318
- """
319
- slug = plugin.slug
320
- if not slug.startswith('github'):
321
- return None
322
- label = f'github-commit-sync (via {slug})'
323
- try:
324
- if slug.endswith('-ec') or slug == 'github-enterprise-cloud':
325
- return require_ghec_tenant_host(
326
- normalize_host(plugin.options.get('host'), label), label
327
- )
328
- if slug.endswith('-es') or slug == 'github-enterprise-server':
329
- return normalize_host(plugin.options.get('host'), label)
330
- except ValueError as exc:
331
- LOGGER.warning(
332
- 'Connected GitHub plugin %r has an unusable host option: %s',
333
- slug,
334
- exc,
335
- )
336
- return None
337
- return 'github.com'
338
-
339
-
340
296
  def _api_base_from_repo_url(repo_url: object) -> str | None:
341
297
  """Derive the API base from the push payload's ``repository.url``.
342
298
 
@@ -363,24 +319,15 @@ def _resolve_api_base(
363
319
  """Pick the GitHub API base for this call (see module docstring)."""
364
320
  if explicit:
365
321
  return explicit.rstrip('/')
366
- for plugin in ctx.service_plugins:
367
- # Skip our own entry: its slug starts with "github" but carries
368
- # no host option, so _github_plugin_host would mis-resolve it to
369
- # github.com on a GHEC/GHES service.
370
- if plugin.slug == _SELF_SLUG:
371
- continue
372
- host = _github_plugin_host(plugin)
373
- if host:
374
- return host_to_api_base(host)
375
- endpoint = ctx.assignment_options.get('service_endpoint')
376
- if isinstance(endpoint, str) and endpoint:
377
- return endpoint.rstrip('/')
322
+ host = resolve_host(ctx.integration_options, 'github-commit-sync')
323
+ if host:
324
+ return host_to_api_base(host)
378
325
  base = _api_base_from_repo_url(_resolve(repo_url_pointer, event))
379
326
  if base:
380
327
  LOGGER.info(
381
328
  "github-commit-sync falling back to the event's repository.url "
382
- 'for the API base; no api_base_url, connected GitHub plugin, or '
383
- 'service_endpoint was available'
329
+ 'for the API base; no api_base_url or resolvable Integration '
330
+ 'flavor/host was available'
384
331
  )
385
332
  return base
386
333
  return None
@@ -732,20 +679,13 @@ async def _hydrate_ci(
732
679
  def _resolve_host_for_context(ctx: PluginContext) -> str | None:
733
680
  """Resolve the GitHub web host for an on-demand sync (no payload).
734
681
 
735
- Walks the connected GitHub plugins on ``ctx.service_plugins`` and
736
- returns the first usable host (github.com, a GHEC tenant, or a GHES
737
- appliance), skipping this plugin's own entry so a commit-sync row on a
738
- GHEC/GHES service can't be read as github.com. Unlike the webhook
739
- path there is no push payload to fall back to, so the absence of a
740
- connected GitHub plugin yields ``None``.
682
+ Reads the Integration's ``flavor`` + ``host`` options from
683
+ ``ctx.integration_options`` and returns the resolved host (github.com,
684
+ a GHEC tenant, or a GHES appliance). Unlike the webhook path there is
685
+ no push payload to fall back to, so a missing/unusable flavor/host
686
+ yields ``None``.
741
687
  """
742
- for plugin in ctx.service_plugins:
743
- if plugin.slug == _SELF_SLUG:
744
- continue
745
- host = _github_plugin_host(plugin)
746
- if host:
747
- return host
748
- return None
688
+ return resolve_host(ctx.integration_options, 'github-commit-sync')
749
689
 
750
690
 
751
691
  async def _fetch_default_branch(
@@ -1278,70 +1218,35 @@ sync_tags_descriptor = ActionDescriptor(
1278
1218
  )
1279
1219
 
1280
1220
 
1281
- class GitHubCommitSyncPlugin(WebhookActionPlugin):
1282
- """Webhook-action plugin syncing GitHub commit / tag history.
1221
+ class GitHubCommitSync(CommitSyncCapability):
1222
+ """Commit / tag history sync capability handler.
1283
1223
 
1284
- Carries its own service credential -- it is *not* folded into the
1285
- identity / deployment / lifecycle plugins, which run as the acting
1286
- user. Two mutually exclusive auth modes are supported (resolved by
1287
- :func:`_resolve_bearer`):
1288
-
1289
- * **PAT** -- a static ``access_token``.
1290
- * **GitHub App** -- ``app_id`` + ``private_key`` (raw or base64 PEM),
1291
- with an optional ``installation_id``; the plugin mints a
1292
- short-lived installation token per call and caches it.
1224
+ Uses the Integration's shared credential blob (resolved by
1225
+ :func:`_resolve_bearer`): a static ``access_token`` PAT when present,
1226
+ otherwise a GitHub App (``app_id`` + ``private_key``, raw or base64
1227
+ PEM, with an optional ``installation_id``) whose short-lived
1228
+ installation token is minted per call and cached. The gateway-side
1229
+ incremental sync flows through the ``sync_commits`` / ``sync_tags``
1230
+ webhook actions catalogued by the plugin's ``webhook-actions``
1231
+ capability.
1293
1232
  """
1294
1233
 
1295
- manifest = PluginManifest(
1296
- slug='github-commit-sync',
1297
- name='GitHub Commit History Sync',
1298
- description=(
1299
- 'Syncs commit and tag history from GitHub push webhooks into '
1300
- 'ClickHouse for analytics.'
1301
- ),
1302
- plugin_type='webhook',
1303
- credentials=[
1304
- CredentialField(
1305
- name='access_token',
1306
- label='GitHub Token (PAT)',
1307
- description=(
1308
- 'Static personal/service access token. Use this *or* '
1309
- 'the GitHub App fields below.'
1310
- ),
1311
- required=False,
1312
- ),
1313
- CredentialField(
1314
- name='app_id',
1315
- label='GitHub App ID',
1316
- description=(
1317
- 'GitHub App identifier; with a private key the plugin '
1318
- 'mints short-lived installation tokens.'
1319
- ),
1320
- required=False,
1321
- ),
1322
- CredentialField(
1323
- name='private_key',
1324
- label='GitHub App Private Key',
1325
- description=(
1326
- 'App private key, raw PEM or base64-encoded PEM.'
1327
- ),
1328
- required=False,
1329
- ),
1330
- CredentialField(
1331
- name='installation_id',
1332
- label='GitHub App Installation ID',
1333
- description=(
1334
- 'Optional. When unset, the installation is discovered '
1335
- 'from the pushed repository.'
1336
- ),
1337
- required=False,
1338
- ),
1339
- ],
1340
- )
1341
-
1342
- @classmethod
1343
- def actions(cls) -> list[ActionDescriptor]:
1344
- return [sync_commits_descriptor, sync_tags_descriptor]
1234
+ async def check_available(
1235
+ self,
1236
+ *,
1237
+ ctx: PluginContext,
1238
+ credentials: dict[str, str],
1239
+ ) -> bool:
1240
+ """Whether an on-demand sync can resolve a host + repo for ``ctx``."""
1241
+ del credentials
1242
+ host = _resolve_host_for_context(ctx)
1243
+ if host is None:
1244
+ return False
1245
+ try:
1246
+ resolve_owner_repo(ctx, host, 'github-commit-sync')
1247
+ except ValueError:
1248
+ return False
1249
+ return True
1345
1250
 
1346
1251
  async def sync_all_history(
1347
1252
  self,
@@ -1352,12 +1257,12 @@ class GitHubCommitSyncPlugin(WebhookActionPlugin):
1352
1257
  """Record the project's full default-branch history and all tags.
1353
1258
 
1354
1259
  Host-invoked (no webhook payload): the host instantiates the
1355
- plugin, builds a :class:`PluginContext` carrying the project's
1356
- links and the connected ``service_plugins``, resolves this
1357
- plugin's service ``credentials``, and awaits this method. The
1358
- GitHub host/flavor is read from ``service_plugins``, the
1359
- ``(owner, repo)`` from the project links, and the bearer token
1360
- from the same PAT-or-App resolution the webhook actions use.
1260
+ handler, builds a :class:`PluginContext` carrying the project's
1261
+ links and the Integration options, resolves the Integration's
1262
+ ``credentials``, and awaits this method. The GitHub host/flavor
1263
+ is read from ``ctx.integration_options``, the ``(owner, repo)``
1264
+ from the project links, and the bearer token from the same
1265
+ PAT-or-App resolution the webhook actions use.
1361
1266
 
1362
1267
  Walks every commit reachable from the default branch head plus the
1363
1268
  repo's complete (lightweight) tag list, maps them onto
@@ -1377,7 +1282,7 @@ class GitHubCommitSyncPlugin(WebhookActionPlugin):
1377
1282
  if host is None:
1378
1283
  raise ValueError(
1379
1284
  'github-commit-sync could not resolve a GitHub host for an '
1380
- 'on-demand sync: connect a GitHub plugin to the service'
1285
+ 'on-demand sync: set the Integration flavor/host'
1381
1286
  )
1382
1287
  base = host_to_api_base(host)
1383
1288
  owner, repo = resolve_owner_repo(ctx, host, 'github-commit-sync')