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.
@@ -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'
@@ -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], host: 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 an explicit ``github-repository`` link key when one is
74
- present and points at ``host``; otherwise scans the remaining
75
- same-host links and returns the first usable one. Returns
76
- ``None`` when nothing matches.
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
- preferred = links.get('github-repository')
80
- if preferred is not None:
81
- owner_repo = parse_owner_repo(preferred, target)
82
- if owner_repo is not None:
83
- return owner_repo
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 == 'github-repository':
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(ctx.project_links, host)
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]
@@ -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 normalize_host, require_ghec_tenant_host
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
- host = self._resolve_host(options)
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(
@@ -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 via a link writeback so the
216
- # operator can wire it up without a second attempt.
217
- html_url = str(
218
- existing.get('html_url')
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 = str(
235
- created.get('html_url')
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
- ctx.link_writeback = LinkWriteback(
289
- link_key='github-repository',
290
- new_url=new_url,
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 = str(
365
- transferred.get('html_url')
366
- or self._repo_html_url(host, new_owner, final_repo)
367
- )
368
- ctx.link_writeback = LinkWriteback(
369
- link_key='github-repository',
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 stash a
544
- :class:`LinkWriteback` on ``ctx`` for the host to persist the
545
- new link. No-op when they match.
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
- html_url = payload.get('html_url')
552
- new_url = (
553
- html_url
554
- if isinstance(html_url, str) and html_url
555
- else self._repo_html_url(host, current_owner, current_repo)
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.8.0
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.8.0
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,,