imbi-plugin-github 2.8.0__py3-none-any.whl → 2.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- imbi_plugin_github/_hosts.py +17 -0
- imbi_plugin_github/_repos.py +25 -12
- imbi_plugin_github/commits.py +593 -0
- imbi_plugin_github/deployment.py +6 -7
- imbi_plugin_github/lifecycle.py +78 -42
- {imbi_plugin_github-2.8.0.dist-info → imbi_plugin_github-2.9.0.dist-info}/METADATA +2 -2
- imbi_plugin_github-2.9.0.dist-info/RECORD +12 -0
- {imbi_plugin_github-2.8.0.dist-info → imbi_plugin_github-2.9.0.dist-info}/entry_points.txt +1 -0
- imbi_plugin_github-2.8.0.dist-info/RECORD +0 -11
- {imbi_plugin_github-2.8.0.dist-info → imbi_plugin_github-2.9.0.dist-info}/WHEEL +0 -0
- {imbi_plugin_github-2.8.0.dist-info → imbi_plugin_github-2.9.0.dist-info}/licenses/LICENSE +0 -0
imbi_plugin_github/_hosts.py
CHANGED
|
@@ -48,3 +48,20 @@ def require_ghec_tenant_host(host: str, label: str) -> str:
|
|
|
48
48
|
f'got {host!r}'
|
|
49
49
|
)
|
|
50
50
|
return host
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def host_to_api_base(host: str) -> str:
|
|
54
|
+
"""Map a resolved GitHub host to its REST API base.
|
|
55
|
+
|
|
56
|
+
The single source of truth for GitHub's flavor routing:
|
|
57
|
+
``github.com`` -> ``api.github.com``, a ``*.ghe.com`` tenant ->
|
|
58
|
+
``api.<tenant>.ghe.com``, and a GHES appliance -> ``<host>/api/v3``.
|
|
59
|
+
Shared by the deployment plugins (which resolve the host per call
|
|
60
|
+
via ``_resolve_host``) and the commit-sync webhook action (which
|
|
61
|
+
resolves it from connected-plugin options at runtime).
|
|
62
|
+
"""
|
|
63
|
+
if host == 'github.com':
|
|
64
|
+
return 'https://api.github.com'
|
|
65
|
+
if host.endswith('.ghe.com'):
|
|
66
|
+
return f'https://api.{host}'
|
|
67
|
+
return f'https://{host}/api/v3'
|
imbi_plugin_github/_repos.py
CHANGED
|
@@ -66,23 +66,32 @@ def parse_owner_repo(url: str, target_host: str) -> tuple[str, str] | None:
|
|
|
66
66
|
|
|
67
67
|
|
|
68
68
|
def derive_owner_repo_from_links(
|
|
69
|
-
links: dict[str, str],
|
|
69
|
+
links: dict[str, str],
|
|
70
|
+
host: str,
|
|
71
|
+
*,
|
|
72
|
+
preferred_key: str | None = None,
|
|
70
73
|
) -> tuple[str, str] | None:
|
|
71
74
|
"""Find a project link pointing at ``host`` and parse owner/repo.
|
|
72
75
|
|
|
73
|
-
Prefers
|
|
74
|
-
present and
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
Prefers the dashboard link keyed by ``preferred_key`` (the bound
|
|
77
|
+
third-party-service slug) when present and pointing at ``host``,
|
|
78
|
+
then the legacy ``github-repository`` link key, then scans the
|
|
79
|
+
remaining same-host links and returns the first usable one.
|
|
80
|
+
Returns ``None`` when nothing matches.
|
|
77
81
|
"""
|
|
78
82
|
target = host.lower()
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
tried: set[str] = set()
|
|
84
|
+
for key in (preferred_key, 'github-repository'):
|
|
85
|
+
if not key or key in tried:
|
|
86
|
+
continue
|
|
87
|
+
tried.add(key)
|
|
88
|
+
url = links.get(key)
|
|
89
|
+
if url is not None:
|
|
90
|
+
owner_repo = parse_owner_repo(url, target)
|
|
91
|
+
if owner_repo is not None:
|
|
92
|
+
return owner_repo
|
|
84
93
|
for key, url in links.items():
|
|
85
|
-
if key
|
|
94
|
+
if key in tried:
|
|
86
95
|
continue
|
|
87
96
|
owner_repo = parse_owner_repo(url, target)
|
|
88
97
|
if owner_repo is not None:
|
|
@@ -110,7 +119,11 @@ def resolve_owner_repo(
|
|
|
110
119
|
so callers reacting to a slug rename (e.g. ``on_project_updated``)
|
|
111
120
|
can still locate the pre-rename repo on GitHub.
|
|
112
121
|
"""
|
|
113
|
-
derived = derive_owner_repo_from_links(
|
|
122
|
+
derived = derive_owner_repo_from_links(
|
|
123
|
+
ctx.project_links,
|
|
124
|
+
host,
|
|
125
|
+
preferred_key=ctx.third_party_service_slug,
|
|
126
|
+
)
|
|
114
127
|
if derived is not None:
|
|
115
128
|
return derived
|
|
116
129
|
if ctx.project_type_slugs:
|
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
"""GitHub commit / tag history sync (webhook action plugin).
|
|
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:
|
|
10
|
+
|
|
11
|
+
1. ``api_base_url`` from the rule's ``handler_config`` (explicit
|
|
12
|
+
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
|
|
19
|
+
URL) as a last resort.
|
|
20
|
+
|
|
21
|
+
Commit / tag rows are written to the shared ClickHouse ``commits`` /
|
|
22
|
+
``tags`` tables via :func:`imbi_common.clickhouse.insert`. Writes are
|
|
23
|
+
best-effort: a storage failure is logged and swallowed so an analytics
|
|
24
|
+
hiccup never 5xxs the webhook, exactly as the gateway's own event
|
|
25
|
+
recording behaves.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import datetime
|
|
31
|
+
import logging
|
|
32
|
+
import typing
|
|
33
|
+
import urllib.parse
|
|
34
|
+
|
|
35
|
+
import httpx
|
|
36
|
+
import jsonpointer
|
|
37
|
+
import pydantic
|
|
38
|
+
from imbi_common import clickhouse
|
|
39
|
+
from imbi_common.json_pointer import JsonPointer
|
|
40
|
+
from imbi_common.models import CommitRecord, TagRecord
|
|
41
|
+
from imbi_common.plugins.base import (
|
|
42
|
+
ActionDescriptor,
|
|
43
|
+
CredentialField,
|
|
44
|
+
PluginContext,
|
|
45
|
+
PluginManifest,
|
|
46
|
+
ServicePlugin,
|
|
47
|
+
WebhookActionPlugin,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
from imbi_plugin_github._hosts import (
|
|
51
|
+
host_to_api_base,
|
|
52
|
+
normalize_host,
|
|
53
|
+
require_ghec_tenant_host,
|
|
54
|
+
)
|
|
55
|
+
from imbi_plugin_github.deployment import (
|
|
56
|
+
_auth_headers, # pyright: ignore[reportPrivateUsage]
|
|
57
|
+
_next_page_url, # pyright: ignore[reportPrivateUsage]
|
|
58
|
+
_parse_iso, # pyright: ignore[reportPrivateUsage]
|
|
59
|
+
_query_param, # pyright: ignore[reportPrivateUsage]
|
|
60
|
+
_raise_on_401, # pyright: ignore[reportPrivateUsage]
|
|
61
|
+
_short_sha, # pyright: ignore[reportPrivateUsage]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
LOGGER = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
_HTTP_TIMEOUT_SECONDS = 10.0
|
|
67
|
+
_ZERO_SHA = '0' * 40
|
|
68
|
+
# GitHub's compare endpoint caps ``commits[]`` at 250 per page and
|
|
69
|
+
# paginates the rest; bound the walk so a pathological single push
|
|
70
|
+
# (force-push of thousands of commits) can't pin us on one endpoint.
|
|
71
|
+
# 250 * 20 = 5000 commits is far more than any realistic push.
|
|
72
|
+
_MAX_COMPARE_PAGES = 20
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _token(credentials: dict[str, str]) -> str:
|
|
76
|
+
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']``"
|
|
81
|
+
)
|
|
82
|
+
return token
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve(pointer: jsonpointer.JsonPointer, payload: object) -> object:
|
|
86
|
+
"""Resolve a JSON Pointer against the payload, ``None`` if absent."""
|
|
87
|
+
return typing.cast(
|
|
88
|
+
'object',
|
|
89
|
+
pointer.resolve(payload, None), # pyright: ignore[reportUnknownMemberType]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _branch_short_name(ref: str) -> str:
|
|
94
|
+
prefix = 'refs/heads/'
|
|
95
|
+
return ref.removeprefix(prefix)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _github_plugin_host(plugin: ServicePlugin) -> str | None:
|
|
99
|
+
"""Resolve the GitHub host from a connected plugin's slug + options.
|
|
100
|
+
|
|
101
|
+
Returns ``None`` for non-GitHub plugins and for GitHub plugins whose
|
|
102
|
+
required ``host`` option is missing or invalid (logged) so the caller
|
|
103
|
+
can fall through to the next resolution source.
|
|
104
|
+
"""
|
|
105
|
+
slug = plugin.slug
|
|
106
|
+
if not slug.startswith('github'):
|
|
107
|
+
return None
|
|
108
|
+
label = f'github-commit-sync (via {slug})'
|
|
109
|
+
try:
|
|
110
|
+
if slug.endswith('-ec') or slug == 'github-enterprise-cloud':
|
|
111
|
+
return require_ghec_tenant_host(
|
|
112
|
+
normalize_host(plugin.options.get('host'), label), label
|
|
113
|
+
)
|
|
114
|
+
if slug.endswith('-es') or slug == 'github-enterprise-server':
|
|
115
|
+
return normalize_host(plugin.options.get('host'), label)
|
|
116
|
+
except ValueError as exc:
|
|
117
|
+
LOGGER.warning(
|
|
118
|
+
'Connected GitHub plugin %r has an unusable host option: %s',
|
|
119
|
+
slug,
|
|
120
|
+
exc,
|
|
121
|
+
)
|
|
122
|
+
return None
|
|
123
|
+
return 'github.com'
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _api_base_from_repo_url(repo_url: object) -> str | None:
|
|
127
|
+
"""Derive the API base from the push payload's ``repository.url``.
|
|
128
|
+
|
|
129
|
+
The repo URL is the flavor-correct API URL
|
|
130
|
+
(``https://api.github.com/repos/owner/repo`` on github.com,
|
|
131
|
+
``https://host/api/v3/repos/owner/repo`` on GHES); strip everything
|
|
132
|
+
from ``/repos/`` onward to recover the base.
|
|
133
|
+
"""
|
|
134
|
+
if not isinstance(repo_url, str):
|
|
135
|
+
return None
|
|
136
|
+
marker = '/repos/'
|
|
137
|
+
idx = repo_url.find(marker)
|
|
138
|
+
if idx == -1:
|
|
139
|
+
return None
|
|
140
|
+
return repo_url[:idx]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _resolve_api_base(
|
|
144
|
+
ctx: PluginContext,
|
|
145
|
+
explicit: str | None,
|
|
146
|
+
repo_url_pointer: jsonpointer.JsonPointer,
|
|
147
|
+
payload: object,
|
|
148
|
+
) -> str | None:
|
|
149
|
+
"""Pick the GitHub API base for this call (see module docstring)."""
|
|
150
|
+
if explicit:
|
|
151
|
+
return explicit.rstrip('/')
|
|
152
|
+
for plugin in ctx.service_plugins:
|
|
153
|
+
host = _github_plugin_host(plugin)
|
|
154
|
+
if host:
|
|
155
|
+
return host_to_api_base(host)
|
|
156
|
+
endpoint = ctx.assignment_options.get('service_endpoint')
|
|
157
|
+
if isinstance(endpoint, str) and endpoint:
|
|
158
|
+
return endpoint.rstrip('/')
|
|
159
|
+
base = _api_base_from_repo_url(_resolve(repo_url_pointer, payload))
|
|
160
|
+
if base:
|
|
161
|
+
LOGGER.info(
|
|
162
|
+
'github-commit-sync falling back to payload repository.url for '
|
|
163
|
+
'the API base; no api_base_url, connected GitHub plugin, or '
|
|
164
|
+
'service_endpoint was available'
|
|
165
|
+
)
|
|
166
|
+
return base
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _owner_repo(
|
|
171
|
+
selector: jsonpointer.JsonPointer, payload: object
|
|
172
|
+
) -> tuple[str, str] | None:
|
|
173
|
+
full_name = _resolve(selector, payload)
|
|
174
|
+
if not isinstance(full_name, str) or '/' not in full_name:
|
|
175
|
+
return None
|
|
176
|
+
owner, _, repo = full_name.partition('/')
|
|
177
|
+
if not owner or not repo:
|
|
178
|
+
return None
|
|
179
|
+
return owner, repo
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _resolve_repo_and_base(
|
|
183
|
+
ctx: PluginContext,
|
|
184
|
+
action_config: SyncCommitsConfig | SyncTagsConfig,
|
|
185
|
+
payload: object,
|
|
186
|
+
) -> tuple[str, str, str] | None:
|
|
187
|
+
"""Resolve ``(owner, repo, api_base)`` for an action.
|
|
188
|
+
|
|
189
|
+
Returns ``None`` (after logging) when the owner/repo or API base
|
|
190
|
+
can't be determined, so both action callables share one short-circuit.
|
|
191
|
+
"""
|
|
192
|
+
owner_repo = _owner_repo(action_config.repository_selector, payload)
|
|
193
|
+
if owner_repo is None:
|
|
194
|
+
LOGGER.warning('github-commit-sync: no owner/repo in push payload')
|
|
195
|
+
return None
|
|
196
|
+
base = _resolve_api_base(
|
|
197
|
+
ctx,
|
|
198
|
+
action_config.api_base_url,
|
|
199
|
+
action_config.repo_api_url_selector,
|
|
200
|
+
payload,
|
|
201
|
+
)
|
|
202
|
+
if base is None:
|
|
203
|
+
LOGGER.warning(
|
|
204
|
+
'github-commit-sync: could not resolve a GitHub API base for '
|
|
205
|
+
'project %s',
|
|
206
|
+
ctx.project_id,
|
|
207
|
+
)
|
|
208
|
+
return None
|
|
209
|
+
owner, repo = owner_repo
|
|
210
|
+
return owner, repo, base
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _client(base: str, owner: str, repo: str, token: str) -> httpx.AsyncClient:
|
|
214
|
+
return httpx.AsyncClient(
|
|
215
|
+
base_url=f'{base}/repos/{owner}/{repo}',
|
|
216
|
+
headers=_auth_headers(token),
|
|
217
|
+
timeout=_HTTP_TIMEOUT_SECONDS,
|
|
218
|
+
event_hooks={'response': [_raise_on_401]},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _commit_record(
|
|
223
|
+
item: dict[str, typing.Any],
|
|
224
|
+
*,
|
|
225
|
+
project_id: str,
|
|
226
|
+
ref: str,
|
|
227
|
+
pushed_at: datetime.datetime,
|
|
228
|
+
) -> CommitRecord:
|
|
229
|
+
"""Map a GitHub commit object onto a :class:`CommitRecord`.
|
|
230
|
+
|
|
231
|
+
Reads the full set of fields the ``commits`` table carries (author
|
|
232
|
+
email + linked login + committer), which the UI-facing
|
|
233
|
+
``Commit``/``_commit_from_payload`` mapping drops, so it maps the raw
|
|
234
|
+
item directly while reusing the low-level ``_short_sha`` / ``_parse_iso``
|
|
235
|
+
helpers.
|
|
236
|
+
"""
|
|
237
|
+
sha = str(item.get('sha') or '')
|
|
238
|
+
commit_meta: dict[str, typing.Any] = item.get('commit') or {}
|
|
239
|
+
author_meta: dict[str, typing.Any] = commit_meta.get('author') or {}
|
|
240
|
+
committer_meta: dict[str, typing.Any] = commit_meta.get('committer') or {}
|
|
241
|
+
gh_author: dict[str, typing.Any] = item.get('author') or {}
|
|
242
|
+
authored_at = _parse_iso(author_meta.get('date'))
|
|
243
|
+
return CommitRecord(
|
|
244
|
+
project_id=project_id,
|
|
245
|
+
sha=sha,
|
|
246
|
+
short_sha=_short_sha(sha),
|
|
247
|
+
ref=ref,
|
|
248
|
+
message=str(commit_meta.get('message') or ''),
|
|
249
|
+
author_name=str(author_meta.get('name') or ''),
|
|
250
|
+
author_email=str(author_meta.get('email') or ''),
|
|
251
|
+
author_login=str(gh_author.get('login') or ''),
|
|
252
|
+
committer_name=str(committer_meta.get('name') or ''),
|
|
253
|
+
authored_at=authored_at or pushed_at,
|
|
254
|
+
committed_at=_parse_iso(committer_meta.get('date')),
|
|
255
|
+
url=str(item.get('html_url') or ''),
|
|
256
|
+
pushed_at=pushed_at,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
async def _fetch_compare_commits(
|
|
261
|
+
client: httpx.AsyncClient, base: str, head: str
|
|
262
|
+
) -> list[dict[str, typing.Any]]:
|
|
263
|
+
"""``GET /compare/{base}...{head}`` following the Link pagination."""
|
|
264
|
+
quoted = urllib.parse.quote(f'{base}...{head}', safe='.')
|
|
265
|
+
path = f'/compare/{quoted}'
|
|
266
|
+
params: dict[str, str] = {'per_page': '250'}
|
|
267
|
+
commits: list[dict[str, typing.Any]] = []
|
|
268
|
+
for page in range(1, _MAX_COMPARE_PAGES + 1):
|
|
269
|
+
resp = await client.get(path, params=params)
|
|
270
|
+
resp.raise_for_status()
|
|
271
|
+
payload = typing.cast('dict[str, typing.Any]', resp.json())
|
|
272
|
+
commits.extend(payload.get('commits') or [])
|
|
273
|
+
next_url = _next_page_url(resp.headers.get('link'))
|
|
274
|
+
if next_url is None:
|
|
275
|
+
return commits
|
|
276
|
+
next_page = _query_param(next_url, 'page')
|
|
277
|
+
if next_page is None:
|
|
278
|
+
return commits
|
|
279
|
+
params['page'] = next_page
|
|
280
|
+
if page == _MAX_COMPARE_PAGES:
|
|
281
|
+
LOGGER.warning(
|
|
282
|
+
'github-commit-sync truncated compare at %d pages (%d '
|
|
283
|
+
'commits); a wider range will not be fully captured',
|
|
284
|
+
_MAX_COMPARE_PAGES,
|
|
285
|
+
len(commits),
|
|
286
|
+
)
|
|
287
|
+
return commits
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
async def _last_known_sha(project_id: str) -> str | None:
|
|
291
|
+
"""Best-effort lookup of the project's most recently pushed commit.
|
|
292
|
+
|
|
293
|
+
Heals gaps from missed deliveries when a push arrives with a zero
|
|
294
|
+
``before`` (new branch). Any failure is swallowed -- the caller
|
|
295
|
+
falls back to a bounded recent-history fetch.
|
|
296
|
+
"""
|
|
297
|
+
try:
|
|
298
|
+
rows = await clickhouse.query(
|
|
299
|
+
'SELECT argMax(sha, pushed_at) AS sha FROM commits '
|
|
300
|
+
'WHERE project_id = {pid:String}',
|
|
301
|
+
{'pid': project_id},
|
|
302
|
+
)
|
|
303
|
+
except Exception: # noqa: BLE001
|
|
304
|
+
LOGGER.debug(
|
|
305
|
+
'github-commit-sync last-known-sha lookup failed for %s',
|
|
306
|
+
project_id,
|
|
307
|
+
exc_info=True,
|
|
308
|
+
)
|
|
309
|
+
return None
|
|
310
|
+
if rows and rows[0].get('sha'):
|
|
311
|
+
return str(rows[0]['sha'])
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
async def _fetch_recent_commits(
|
|
316
|
+
client: httpx.AsyncClient, head: str, limit: int
|
|
317
|
+
) -> list[dict[str, typing.Any]]:
|
|
318
|
+
"""Bounded ``GET /commits?sha={head}`` fallback for new branches."""
|
|
319
|
+
resp = await client.get(
|
|
320
|
+
'/commits',
|
|
321
|
+
params={'sha': head, 'per_page': str(max(1, min(limit, 100)))},
|
|
322
|
+
)
|
|
323
|
+
resp.raise_for_status()
|
|
324
|
+
return typing.cast('list[dict[str, typing.Any]]', resp.json())
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class SyncCommitsConfig(pydantic.BaseModel):
|
|
328
|
+
"""``WebhookRule.handler_config`` for ``sync_commits``."""
|
|
329
|
+
|
|
330
|
+
before_selector: JsonPointer = pydantic.Field(
|
|
331
|
+
default_factory=lambda: jsonpointer.JsonPointer('/before')
|
|
332
|
+
)
|
|
333
|
+
after_selector: JsonPointer = pydantic.Field(
|
|
334
|
+
default_factory=lambda: jsonpointer.JsonPointer('/after')
|
|
335
|
+
)
|
|
336
|
+
ref_selector: JsonPointer = pydantic.Field(
|
|
337
|
+
default_factory=lambda: jsonpointer.JsonPointer('/ref')
|
|
338
|
+
)
|
|
339
|
+
repository_selector: JsonPointer = pydantic.Field(
|
|
340
|
+
default_factory=lambda: jsonpointer.JsonPointer(
|
|
341
|
+
'/repository/full_name'
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
api_base_url: str | None = None
|
|
345
|
+
repo_api_url_selector: JsonPointer = pydantic.Field(
|
|
346
|
+
default_factory=lambda: jsonpointer.JsonPointer('/repository/url')
|
|
347
|
+
)
|
|
348
|
+
initial_limit: int = 100
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class SyncTagsConfig(pydantic.BaseModel):
|
|
352
|
+
"""``WebhookRule.handler_config`` for ``sync_tags``."""
|
|
353
|
+
|
|
354
|
+
ref_selector: JsonPointer = pydantic.Field(
|
|
355
|
+
default_factory=lambda: jsonpointer.JsonPointer('/ref')
|
|
356
|
+
)
|
|
357
|
+
after_selector: JsonPointer = pydantic.Field(
|
|
358
|
+
default_factory=lambda: jsonpointer.JsonPointer('/after')
|
|
359
|
+
)
|
|
360
|
+
repository_selector: JsonPointer = pydantic.Field(
|
|
361
|
+
default_factory=lambda: jsonpointer.JsonPointer(
|
|
362
|
+
'/repository/full_name'
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
api_base_url: str | None = None
|
|
366
|
+
repo_api_url_selector: JsonPointer = pydantic.Field(
|
|
367
|
+
default_factory=lambda: jsonpointer.JsonPointer('/repository/url')
|
|
368
|
+
)
|
|
369
|
+
reconcile_all: bool = False
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
async def sync_commits(
|
|
373
|
+
*,
|
|
374
|
+
ctx: PluginContext,
|
|
375
|
+
credentials: dict[str, str],
|
|
376
|
+
external_identifier: str,
|
|
377
|
+
action_config: SyncCommitsConfig,
|
|
378
|
+
payload: object,
|
|
379
|
+
) -> None:
|
|
380
|
+
"""Sync the commits in a ``push`` delivery into the ``commits`` table.
|
|
381
|
+
|
|
382
|
+
Branch gating is the rule's CEL ``filter_expression``'s job; this
|
|
383
|
+
action does not filter refs itself.
|
|
384
|
+
"""
|
|
385
|
+
del external_identifier
|
|
386
|
+
after = _resolve(action_config.after_selector, payload)
|
|
387
|
+
if not isinstance(after, str) or not after or after == _ZERO_SHA:
|
|
388
|
+
return # branch delete / no head -- nothing to sync
|
|
389
|
+
resolved = _resolve_repo_and_base(ctx, action_config, payload)
|
|
390
|
+
if resolved is None:
|
|
391
|
+
return
|
|
392
|
+
owner, repo, base = resolved
|
|
393
|
+
ref_raw = _resolve(action_config.ref_selector, payload)
|
|
394
|
+
ref = _branch_short_name(ref_raw if isinstance(ref_raw, str) else '')
|
|
395
|
+
before = _resolve(action_config.before_selector, payload)
|
|
396
|
+
pushed_at = datetime.datetime.now(datetime.UTC)
|
|
397
|
+
async with _client(base, owner, repo, _token(credentials)) as client:
|
|
398
|
+
if isinstance(before, str) and before and before != _ZERO_SHA:
|
|
399
|
+
raw = await _fetch_compare_commits(client, before, after)
|
|
400
|
+
else:
|
|
401
|
+
last = await _last_known_sha(ctx.project_id)
|
|
402
|
+
if last:
|
|
403
|
+
raw = await _fetch_compare_commits(client, last, after)
|
|
404
|
+
else:
|
|
405
|
+
raw = await _fetch_recent_commits(
|
|
406
|
+
client, after, action_config.initial_limit
|
|
407
|
+
)
|
|
408
|
+
records: list[pydantic.BaseModel] = [
|
|
409
|
+
_commit_record(
|
|
410
|
+
item, project_id=ctx.project_id, ref=ref, pushed_at=pushed_at
|
|
411
|
+
)
|
|
412
|
+
for item in raw
|
|
413
|
+
if item.get('sha')
|
|
414
|
+
]
|
|
415
|
+
if not records:
|
|
416
|
+
return
|
|
417
|
+
try:
|
|
418
|
+
await clickhouse.insert('commits', records)
|
|
419
|
+
except Exception:
|
|
420
|
+
LOGGER.exception(
|
|
421
|
+
'github-commit-sync: failed to record %d commits for project %s',
|
|
422
|
+
len(records),
|
|
423
|
+
ctx.project_id,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
async def _annotated_tag(
|
|
428
|
+
client: httpx.AsyncClient, sha: str
|
|
429
|
+
) -> dict[str, typing.Any] | None:
|
|
430
|
+
"""Fetch annotated-tag metadata, ``None`` for a lightweight tag."""
|
|
431
|
+
resp = await client.get(f'/git/tags/{sha}')
|
|
432
|
+
if resp.status_code != 200:
|
|
433
|
+
return None
|
|
434
|
+
return typing.cast('dict[str, typing.Any]', resp.json())
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _tag_record(
|
|
438
|
+
*,
|
|
439
|
+
project_id: str,
|
|
440
|
+
name: str,
|
|
441
|
+
sha: str,
|
|
442
|
+
annotated: dict[str, typing.Any] | None = None,
|
|
443
|
+
) -> TagRecord:
|
|
444
|
+
if annotated is None:
|
|
445
|
+
return TagRecord(project_id=project_id, name=name, sha=sha)
|
|
446
|
+
tagger: dict[str, typing.Any] = annotated.get('tagger') or {}
|
|
447
|
+
return TagRecord(
|
|
448
|
+
project_id=project_id,
|
|
449
|
+
name=name,
|
|
450
|
+
sha=sha,
|
|
451
|
+
message=str(annotated.get('message') or ''),
|
|
452
|
+
tagger_name=str(tagger.get('name') or ''),
|
|
453
|
+
tagger_email=str(tagger.get('email') or ''),
|
|
454
|
+
tagged_at=_parse_iso(tagger.get('date')),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
async def _reconcile_tags(
|
|
459
|
+
client: httpx.AsyncClient, project_id: str
|
|
460
|
+
) -> list[TagRecord]:
|
|
461
|
+
"""Upsert the repo's full tag list (lightweight); ``ReplacingMergeTree``
|
|
462
|
+
dedupes against rows recorded from individual pushes."""
|
|
463
|
+
out: list[TagRecord] = []
|
|
464
|
+
params: dict[str, str] = {'per_page': '100'}
|
|
465
|
+
while True:
|
|
466
|
+
resp = await client.get('/tags', params=params)
|
|
467
|
+
resp.raise_for_status()
|
|
468
|
+
rows = typing.cast('list[dict[str, typing.Any]]', resp.json())
|
|
469
|
+
for row in rows:
|
|
470
|
+
name = str(row.get('name') or '')
|
|
471
|
+
commit: dict[str, typing.Any] = row.get('commit') or {}
|
|
472
|
+
sha = str(commit.get('sha') or '')
|
|
473
|
+
if name and sha:
|
|
474
|
+
out.append(
|
|
475
|
+
_tag_record(project_id=project_id, name=name, sha=sha)
|
|
476
|
+
)
|
|
477
|
+
next_url = _next_page_url(resp.headers.get('link'))
|
|
478
|
+
if next_url is None:
|
|
479
|
+
return out
|
|
480
|
+
next_page = _query_param(next_url, 'page')
|
|
481
|
+
if next_page is None:
|
|
482
|
+
return out
|
|
483
|
+
params['page'] = next_page
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
async def sync_tags(
|
|
487
|
+
*,
|
|
488
|
+
ctx: PluginContext,
|
|
489
|
+
credentials: dict[str, str],
|
|
490
|
+
external_identifier: str,
|
|
491
|
+
action_config: SyncTagsConfig,
|
|
492
|
+
payload: object,
|
|
493
|
+
) -> None:
|
|
494
|
+
"""Sync the tag in a ``push`` delivery into the ``tags`` table."""
|
|
495
|
+
del external_identifier
|
|
496
|
+
ref_raw = _resolve(action_config.ref_selector, payload)
|
|
497
|
+
prefix = 'refs/tags/'
|
|
498
|
+
if not isinstance(ref_raw, str) or not ref_raw.startswith(prefix):
|
|
499
|
+
return
|
|
500
|
+
name = ref_raw[len(prefix) :]
|
|
501
|
+
after = _resolve(action_config.after_selector, payload)
|
|
502
|
+
if (
|
|
503
|
+
not name
|
|
504
|
+
or not isinstance(after, str)
|
|
505
|
+
or not after
|
|
506
|
+
or after == _ZERO_SHA
|
|
507
|
+
):
|
|
508
|
+
return # tag delete / no target
|
|
509
|
+
resolved = _resolve_repo_and_base(ctx, action_config, payload)
|
|
510
|
+
if resolved is None:
|
|
511
|
+
return
|
|
512
|
+
owner, repo, base = resolved
|
|
513
|
+
async with _client(base, owner, repo, _token(credentials)) as client:
|
|
514
|
+
annotated = await _annotated_tag(client, after)
|
|
515
|
+
records: list[pydantic.BaseModel] = [
|
|
516
|
+
_tag_record(
|
|
517
|
+
project_id=ctx.project_id,
|
|
518
|
+
name=name,
|
|
519
|
+
sha=after,
|
|
520
|
+
annotated=annotated,
|
|
521
|
+
)
|
|
522
|
+
]
|
|
523
|
+
if action_config.reconcile_all:
|
|
524
|
+
extra = await _reconcile_tags(client, ctx.project_id)
|
|
525
|
+
seen = {name}
|
|
526
|
+
records.extend(r for r in extra if r.name not in seen)
|
|
527
|
+
try:
|
|
528
|
+
await clickhouse.insert('tags', records)
|
|
529
|
+
except Exception:
|
|
530
|
+
LOGGER.exception(
|
|
531
|
+
'github-commit-sync: failed to record %d tags for project %s',
|
|
532
|
+
len(records),
|
|
533
|
+
ctx.project_id,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
sync_commits_descriptor = ActionDescriptor(
|
|
538
|
+
name='sync_commits',
|
|
539
|
+
label='Sync Commit History',
|
|
540
|
+
description=(
|
|
541
|
+
'Fetch the full set of commits in a push (via the GitHub compare '
|
|
542
|
+
'API) and record them in the ClickHouse commits table.'
|
|
543
|
+
),
|
|
544
|
+
callable=typing.cast(
|
|
545
|
+
'typing.Any', 'imbi_plugin_github.commits:sync_commits'
|
|
546
|
+
),
|
|
547
|
+
config_model=typing.cast(
|
|
548
|
+
'typing.Any', 'imbi_plugin_github.commits:SyncCommitsConfig'
|
|
549
|
+
),
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
sync_tags_descriptor = ActionDescriptor(
|
|
553
|
+
name='sync_tags',
|
|
554
|
+
label='Sync Tag History',
|
|
555
|
+
description=(
|
|
556
|
+
'Record the pushed tag (and, when reconcile_all is set, the full '
|
|
557
|
+
'tag list) in the ClickHouse tags table.'
|
|
558
|
+
),
|
|
559
|
+
callable=typing.cast('typing.Any', 'imbi_plugin_github.commits:sync_tags'),
|
|
560
|
+
config_model=typing.cast(
|
|
561
|
+
'typing.Any', 'imbi_plugin_github.commits:SyncTagsConfig'
|
|
562
|
+
),
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
class GitHubCommitSyncPlugin(WebhookActionPlugin):
|
|
567
|
+
"""Webhook-action plugin syncing GitHub commit / tag history.
|
|
568
|
+
|
|
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.
|
|
572
|
+
"""
|
|
573
|
+
|
|
574
|
+
manifest = PluginManifest(
|
|
575
|
+
slug='github-commit-sync',
|
|
576
|
+
name='GitHub Commit History Sync',
|
|
577
|
+
description=(
|
|
578
|
+
'Syncs commit and tag history from GitHub push webhooks into '
|
|
579
|
+
'ClickHouse for analytics.'
|
|
580
|
+
),
|
|
581
|
+
plugin_type='webhook',
|
|
582
|
+
credentials=[
|
|
583
|
+
CredentialField(
|
|
584
|
+
name='access_token',
|
|
585
|
+
label='GitHub Token',
|
|
586
|
+
description='Service token used to fetch commit history.',
|
|
587
|
+
)
|
|
588
|
+
],
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
@classmethod
|
|
592
|
+
def actions(cls) -> list[ActionDescriptor]:
|
|
593
|
+
return [sync_commits_descriptor, sync_tags_descriptor]
|
imbi_plugin_github/deployment.py
CHANGED
|
@@ -56,7 +56,11 @@ from imbi_common.plugins.base import (
|
|
|
56
56
|
)
|
|
57
57
|
from imbi_common.plugins.errors import PluginAuthenticationFailed
|
|
58
58
|
|
|
59
|
-
from imbi_plugin_github._hosts import
|
|
59
|
+
from imbi_plugin_github._hosts import (
|
|
60
|
+
host_to_api_base,
|
|
61
|
+
normalize_host,
|
|
62
|
+
require_ghec_tenant_host,
|
|
63
|
+
)
|
|
60
64
|
from imbi_plugin_github._repos import (
|
|
61
65
|
derive_owner_repo_from_links,
|
|
62
66
|
parse_owner_repo,
|
|
@@ -299,12 +303,7 @@ class _DeploymentBase(DeploymentPlugin):
|
|
|
299
303
|
raise NotImplementedError
|
|
300
304
|
|
|
301
305
|
def _api_base(self, options: dict[str, typing.Any]) -> str:
|
|
302
|
-
|
|
303
|
-
if host == 'github.com':
|
|
304
|
-
return 'https://api.github.com'
|
|
305
|
-
if host.endswith('.ghe.com'):
|
|
306
|
-
return f'https://api.{host}'
|
|
307
|
-
return f'https://{host}/api/v3'
|
|
306
|
+
return host_to_api_base(self._resolve_host(options))
|
|
308
307
|
|
|
309
308
|
def _owner_repo(self, ctx: PluginContext) -> tuple[str, str]:
|
|
310
309
|
return resolve_owner_repo(
|
imbi_plugin_github/lifecycle.py
CHANGED
|
@@ -59,6 +59,7 @@ from imbi_common.plugins.base import (
|
|
|
59
59
|
PluginManifest,
|
|
60
60
|
PluginOption,
|
|
61
61
|
RelocationTarget,
|
|
62
|
+
ServiceWriteback,
|
|
62
63
|
)
|
|
63
64
|
from imbi_common.plugins.errors import PluginAuthenticationFailed
|
|
64
65
|
from imbi_common.plugins.templates import expand_template
|
|
@@ -212,15 +213,10 @@ class _LifecycleBase(LifecyclePlugin):
|
|
|
212
213
|
)
|
|
213
214
|
if existing is not None:
|
|
214
215
|
# Already provisioned (e.g. retry after a partial failure):
|
|
215
|
-
# adopt the existing repo's URL
|
|
216
|
-
#
|
|
217
|
-
html_url =
|
|
218
|
-
existing
|
|
219
|
-
or self._repo_html_url(host, target_org, ctx.project_slug)
|
|
220
|
-
)
|
|
221
|
-
ctx.link_writeback = LinkWriteback(
|
|
222
|
-
link_key='github-repository',
|
|
223
|
-
new_url=html_url,
|
|
216
|
+
# adopt the existing repo's URL/edge so the operator can
|
|
217
|
+
# wire it up without a second attempt.
|
|
218
|
+
html_url = self._record_repo(
|
|
219
|
+
ctx, host, target_org, ctx.project_slug, existing
|
|
224
220
|
)
|
|
225
221
|
return LifecycleResult(
|
|
226
222
|
status='skipped',
|
|
@@ -231,13 +227,8 @@ class _LifecycleBase(LifecyclePlugin):
|
|
|
231
227
|
artifacts={'repo_url': html_url},
|
|
232
228
|
)
|
|
233
229
|
created = await self._create_repo(client, target_org, ctx)
|
|
234
|
-
html_url =
|
|
235
|
-
created
|
|
236
|
-
or self._repo_html_url(host, target_org, ctx.project_slug)
|
|
237
|
-
)
|
|
238
|
-
ctx.link_writeback = LinkWriteback(
|
|
239
|
-
link_key='github-repository',
|
|
240
|
-
new_url=html_url,
|
|
230
|
+
html_url = self._record_repo(
|
|
231
|
+
ctx, host, target_org, ctx.project_slug, created
|
|
241
232
|
)
|
|
242
233
|
return LifecycleResult(
|
|
243
234
|
status='ok',
|
|
@@ -277,20 +268,24 @@ class _LifecycleBase(LifecyclePlugin):
|
|
|
277
268
|
homepage=ctx.project_ui_url or '',
|
|
278
269
|
)
|
|
279
270
|
new_repo = str(patched.get('name') or current_repo)
|
|
280
|
-
new_url = str(
|
|
281
|
-
patched.get('html_url')
|
|
282
|
-
or self._repo_html_url(host, current_owner, new_repo)
|
|
283
|
-
)
|
|
284
271
|
# If the patch itself renamed the repo (we asked GitHub to
|
|
285
272
|
# set ``name`` to a new slug), record the writeback even when
|
|
286
273
|
# the external-rename check above didn't.
|
|
287
274
|
if new_repo != current_repo:
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
275
|
+
new_url = self._record_repo(
|
|
276
|
+
ctx,
|
|
277
|
+
host,
|
|
278
|
+
current_owner,
|
|
279
|
+
new_repo,
|
|
280
|
+
patched,
|
|
291
281
|
old_owner_repo=f'{current_owner}/{current_repo}',
|
|
292
282
|
new_owner_repo=f'{current_owner}/{new_repo}',
|
|
293
283
|
)
|
|
284
|
+
else:
|
|
285
|
+
new_url = str(
|
|
286
|
+
patched.get('html_url')
|
|
287
|
+
or self._repo_html_url(host, current_owner, new_repo)
|
|
288
|
+
)
|
|
294
289
|
return LifecycleResult(
|
|
295
290
|
status='ok',
|
|
296
291
|
message=f'Updated {current_owner}/{new_repo}',
|
|
@@ -361,13 +356,12 @@ class _LifecycleBase(LifecyclePlugin):
|
|
|
361
356
|
async with self._client(ctx, credentials) as client:
|
|
362
357
|
transferred = await self._transfer(client, owner, repo, new_owner)
|
|
363
358
|
final_repo = str(transferred.get('name') or repo)
|
|
364
|
-
html_url =
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
new_url=html_url,
|
|
359
|
+
html_url = self._record_repo(
|
|
360
|
+
ctx,
|
|
361
|
+
host,
|
|
362
|
+
new_owner,
|
|
363
|
+
final_repo,
|
|
364
|
+
transferred,
|
|
371
365
|
old_owner_repo=f'{owner}/{repo}',
|
|
372
366
|
new_owner_repo=f'{new_owner}/{final_repo}',
|
|
373
367
|
)
|
|
@@ -540,23 +534,21 @@ class _LifecycleBase(LifecyclePlugin):
|
|
|
540
534
|
|
|
541
535
|
Compares the link-derived ``<owner>/<repo>`` against the repo's
|
|
542
536
|
canonical name from ``payload``. When they differ the repo was
|
|
543
|
-
renamed (or its owner renamed) outside Imbi, so
|
|
544
|
-
|
|
545
|
-
|
|
537
|
+
renamed (or its owner renamed) outside Imbi, so record the repo
|
|
538
|
+
on ``ctx`` (via :meth:`_record_repo`) for the host to persist the
|
|
539
|
+
refreshed dashboard link / ``EXISTS_IN`` edge. No-op when they
|
|
540
|
+
match.
|
|
546
541
|
"""
|
|
547
542
|
old_owner_repo = f'{link_owner}/{link_repo}'
|
|
548
543
|
new_owner_repo = f'{current_owner}/{current_repo}'
|
|
549
544
|
if new_owner_repo.lower() == old_owner_repo.lower():
|
|
550
545
|
return
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
ctx.link_writeback = LinkWriteback(
|
|
558
|
-
link_key='github-repository',
|
|
559
|
-
new_url=new_url,
|
|
546
|
+
self._record_repo(
|
|
547
|
+
ctx,
|
|
548
|
+
host,
|
|
549
|
+
current_owner,
|
|
550
|
+
current_repo,
|
|
551
|
+
payload,
|
|
560
552
|
old_owner_repo=old_owner_repo,
|
|
561
553
|
new_owner_repo=new_owner_repo,
|
|
562
554
|
)
|
|
@@ -565,6 +557,50 @@ class _LifecycleBase(LifecyclePlugin):
|
|
|
565
557
|
def _repo_html_url(host: str, owner: str, repo: str) -> str:
|
|
566
558
|
return f'https://{host}/{owner}/{repo}'
|
|
567
559
|
|
|
560
|
+
def _record_repo(
|
|
561
|
+
self,
|
|
562
|
+
ctx: PluginContext,
|
|
563
|
+
host: str,
|
|
564
|
+
owner: str,
|
|
565
|
+
repo: str,
|
|
566
|
+
payload: dict[str, typing.Any] | None = None,
|
|
567
|
+
*,
|
|
568
|
+
old_owner_repo: str | None = None,
|
|
569
|
+
new_owner_repo: str | None = None,
|
|
570
|
+
) -> str:
|
|
571
|
+
"""Record the repo on ``ctx`` for the host to persist.
|
|
572
|
+
|
|
573
|
+
Returns the dashboard (human) URL. When the plugin is bound to a
|
|
574
|
+
third-party service and the GitHub payload carries the numeric
|
|
575
|
+
repo id, emit a :class:`ServiceWriteback` that maintains the
|
|
576
|
+
``EXISTS_IN`` edge -- the id plus the rename-stable
|
|
577
|
+
``/repositories/{id}`` API URL -- and a dashboard link keyed by
|
|
578
|
+
the service slug. Otherwise fall back to the legacy
|
|
579
|
+
``github-repository`` :class:`LinkWriteback` so a project not
|
|
580
|
+
wired to a service still gets its stored link.
|
|
581
|
+
"""
|
|
582
|
+
data = payload or {}
|
|
583
|
+
html_url = str(
|
|
584
|
+
data.get('html_url') or self._repo_html_url(host, owner, repo)
|
|
585
|
+
)
|
|
586
|
+
slug = ctx.third_party_service_slug
|
|
587
|
+
repo_id = data.get('id')
|
|
588
|
+
if slug and isinstance(repo_id, int):
|
|
589
|
+
api_base = self._api_base(ctx.assignment_options)
|
|
590
|
+
ctx.service_writeback = ServiceWriteback(
|
|
591
|
+
identifier=str(repo_id),
|
|
592
|
+
canonical_url=f'{api_base}/repositories/{repo_id}',
|
|
593
|
+
dashboard_links={slug: html_url},
|
|
594
|
+
)
|
|
595
|
+
else:
|
|
596
|
+
ctx.link_writeback = LinkWriteback(
|
|
597
|
+
link_key='github-repository',
|
|
598
|
+
new_url=html_url,
|
|
599
|
+
old_owner_repo=old_owner_repo,
|
|
600
|
+
new_owner_repo=new_owner_repo,
|
|
601
|
+
)
|
|
602
|
+
return html_url
|
|
603
|
+
|
|
568
604
|
async def _get_repo(
|
|
569
605
|
self, client: httpx.AsyncClient, owner: str, repo: str
|
|
570
606
|
) -> dict[str, typing.Any]:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: imbi-plugin-github
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.9.0
|
|
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==2.
|
|
16
|
+
Requires-Dist: imbi-common[databases]==2.9.0
|
|
17
17
|
Requires-Dist: pydantic>=2
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
imbi_plugin_github/__init__.py,sha256=8ZVhm8XpGqgYRUtpHl0uuZTT3gmMyHNhKd9k6qoatB4,298
|
|
2
|
+
imbi_plugin_github/_hosts.py,sha256=jQ1Z6yEpjAa4b7LocLfEelnBcRYYQLNLBNCdWZtAXUc,2258
|
|
3
|
+
imbi_plugin_github/_repos.py,sha256=agp4ZgRuqbrGBRMFeHomnUf1HvvaNvapBCWHQebdviQ,4559
|
|
4
|
+
imbi_plugin_github/commits.py,sha256=Foz2c3YaL0Iil6RkwPEf-BH8-wm1Vz61p9CtFLDk_CE,20479
|
|
5
|
+
imbi_plugin_github/deployment.py,sha256=V6YlZX24m33BPJWkC-vCZZLSsH71etkl0xkhkDsKASg,47914
|
|
6
|
+
imbi_plugin_github/identity.py,sha256=RSs1cLQpZmfyQ_0KQkohDIZEBYVBnre6RJIt166EtdQ,15525
|
|
7
|
+
imbi_plugin_github/lifecycle.py,sha256=48mLKjFPG7BOanpA7jWnmbWOdsDLh1F39g2AoxDLL7M,33211
|
|
8
|
+
imbi_plugin_github-2.9.0.dist-info/METADATA,sha256=AdkNjhDiuyHFAr42DpWqbuHlnHTklB53gMBnor-9MGI,3355
|
|
9
|
+
imbi_plugin_github-2.9.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
imbi_plugin_github-2.9.0.dist-info/entry_points.txt,sha256=RavN_FYCeS97iauHgCG3tHFdVULbEMXmQfvgCgLeGkI,805
|
|
11
|
+
imbi_plugin_github-2.9.0.dist-info/licenses/LICENSE,sha256=el9B20zOqEcv_mRrHgbdkVyxSC6I8np7am0D6mO2A9Y,1491
|
|
12
|
+
imbi_plugin_github-2.9.0.dist-info/RECORD,,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
[imbi.plugins]
|
|
2
2
|
github = imbi_plugin_github.identity:GitHubPlugin
|
|
3
|
+
github-commit-sync = imbi_plugin_github.commits:GitHubCommitSyncPlugin
|
|
3
4
|
github-deployment = imbi_plugin_github.deployment:GitHubDeploymentPlugin
|
|
4
5
|
github-deployment-ec = imbi_plugin_github.deployment:GitHubEnterpriseCloudDeploymentPlugin
|
|
5
6
|
github-deployment-es = imbi_plugin_github.deployment:GitHubEnterpriseServerDeploymentPlugin
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
imbi_plugin_github/__init__.py,sha256=8ZVhm8XpGqgYRUtpHl0uuZTT3gmMyHNhKd9k6qoatB4,298
|
|
2
|
-
imbi_plugin_github/_hosts.py,sha256=85HWZpXfFsFUUCkUQaH_89um6JZoqX0CBohMxijgYiw,1576
|
|
3
|
-
imbi_plugin_github/_repos.py,sha256=KpWGFVTv9q00rW-ZQnK8bVnD2FKPQxCIaynKj798AxQ,4220
|
|
4
|
-
imbi_plugin_github/deployment.py,sha256=UbOJDWqqyYzVnhZqmuJ5I7mq1BsiZX_aQUQcKOpI48A,48057
|
|
5
|
-
imbi_plugin_github/identity.py,sha256=RSs1cLQpZmfyQ_0KQkohDIZEBYVBnre6RJIt166EtdQ,15525
|
|
6
|
-
imbi_plugin_github/lifecycle.py,sha256=7oUV2Shzxwj1Owdzz0WFAtLAaz8AaRqy7nB9zJSFIPE,32031
|
|
7
|
-
imbi_plugin_github-2.8.0.dist-info/METADATA,sha256=O8ANKLI8E2wN1MI20Gmv2w-AU9qtbuz_El9Cbva-jPA,3344
|
|
8
|
-
imbi_plugin_github-2.8.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
-
imbi_plugin_github-2.8.0.dist-info/entry_points.txt,sha256=fgeKtu8dSQWrnmIhuqZ6Bhon1NiibOMAlIoIThJimBg,734
|
|
10
|
-
imbi_plugin_github-2.8.0.dist-info/licenses/LICENSE,sha256=el9B20zOqEcv_mRrHgbdkVyxSC6I8np7am0D6mO2A9Y,1491
|
|
11
|
-
imbi_plugin_github-2.8.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|