imbi-plugin-github 0.1.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.
@@ -0,0 +1,999 @@
1
+ """GitHub deployment plugins.
2
+
3
+ Three concrete subclasses share a common base and differ only by host:
4
+
5
+ * :class:`GitHubDeploymentPlugin` — github.com.
6
+ * :class:`GitHubEnterpriseCloudDeploymentPlugin` — GHEC tenant on
7
+ ``*.ghe.com``.
8
+ * :class:`GitHubEnterpriseServerDeploymentPlugin` — operator-managed GHES.
9
+
10
+ Phase 1 implements ref / commit discovery, comparison, and workflow
11
+ dispatch. Tag and release creation arrive with the Promote tab in
12
+ Phase 2.
13
+
14
+ The plugin runs as the user via the paired ``IdentityPlugin``: callers
15
+ materialize an :class:`~imbi_common.plugins.base.IdentityCredentials`
16
+ and pass the access token through ``credentials['access_token']``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import collections.abc
23
+ import datetime
24
+ import hashlib
25
+ import logging
26
+ import time
27
+ import typing
28
+ import urllib.parse
29
+
30
+ import httpx
31
+ from imbi_common.plugins.base import (
32
+ CheckStatus,
33
+ Commit,
34
+ CompareResult,
35
+ CredentialField,
36
+ DeploymentPlugin,
37
+ DeploymentRun,
38
+ PluginContext,
39
+ PluginManifest,
40
+ PluginOption,
41
+ Ref,
42
+ RefInfo,
43
+ ReleaseInfo,
44
+ )
45
+ from imbi_common.plugins.errors import PluginAuthenticationFailed
46
+
47
+ from imbi_plugin_github._hosts import normalize_host, require_ghec_tenant_host
48
+
49
+ LOGGER = logging.getLogger(__name__)
50
+
51
+ _DEFAULT_WORKFLOW = 'deploy.yml'
52
+ _DEFAULT_ENVIRONMENT_INPUT = 'environment'
53
+ _DEFAULT_REF_INPUT = 'ref'
54
+ _HTTP_TIMEOUT_SECONDS = 10.0
55
+ # Cap pagination so a pathological repo (10k+ branches/tags) can't pin
56
+ # us indefinitely. 100 per page * 10 pages = 1000 refs is plenty for
57
+ # the deployment-plugin UI's purposes.
58
+ _MAX_REF_PAGES = 10
59
+
60
+ # Process-wide cache of (token, host, repo) tuples for which the
61
+ # GitHub ``/check-runs`` endpoint has already returned 403 (insufficient
62
+ # scope, or Actions disabled on the repo). Keys are short SHA-256
63
+ # digests over the bearer token plus the resolved host and
64
+ # ``<owner>/<repo>`` so a single forbidden repo doesn't suppress CI
65
+ # status for every other repo the same user opens. Values are the
66
+ # unix timestamp at which the entry was recorded. Hydrating commit
67
+ # CI status spawns one call per commit; without this cache a missing
68
+ # scope produces 25+ wasted 403s every time the deploy dialog opens.
69
+ _CHECKS_DISABLED_TOKENS: dict[str, float] = {}
70
+ # How long to remember a 403 before re-probing. Long enough that a
71
+ # scope fix takes effect on the next session, short enough that we
72
+ # don't spam after the user fixes the underlying scope.
73
+ _CHECKS_DISABLED_TTL_SECONDS = 600.0
74
+
75
+
76
+ async def _raise_on_401(response: httpx.Response) -> None:
77
+ """Convert a 401 from GitHub into :class:`PluginAuthenticationFailed`.
78
+
79
+ Installed as an httpx response hook on the deployment client so the
80
+ host's retry layer can refresh the actor's identity connection
81
+ once before failing the user-visible request. Other status codes
82
+ pass through to ``raise_for_status`` (or per-call swallowing) as
83
+ before.
84
+ """
85
+ if response.status_code != 401:
86
+ return
87
+ # The exception message is surfaced in API logs; reading the body
88
+ # keeps GitHub's ``message`` field (e.g. "Bad credentials") in
89
+ # the trail without leaking the bearer token.
90
+ await response.aread()
91
+ raise PluginAuthenticationFailed(
92
+ f'GitHub 401 from {response.request.url}: {response.text}'
93
+ )
94
+
95
+
96
+ def _accept_header() -> dict[str, str]:
97
+ return {'Accept': 'application/vnd.github+json'}
98
+
99
+
100
+ def _auth_headers(token: str) -> dict[str, str]:
101
+ return {
102
+ 'Authorization': f'Bearer {token}',
103
+ **_accept_header(),
104
+ }
105
+
106
+
107
+ def _short_sha(sha: str) -> str:
108
+ return sha[:7]
109
+
110
+
111
+ def _next_page_url(link_header: str | None) -> str | None:
112
+ """Extract the ``rel="next"`` URL from a GitHub ``Link`` header.
113
+
114
+ Returns ``None`` when no next page is advertised.
115
+ """
116
+ if not link_header:
117
+ return None
118
+ for part in link_header.split(','):
119
+ section = part.strip()
120
+ if not section.startswith('<'):
121
+ continue
122
+ end = section.find('>')
123
+ if end == -1:
124
+ continue
125
+ url = section[1:end]
126
+ params = section[end + 1 :]
127
+ if 'rel="next"' in params:
128
+ return url
129
+ return None
130
+
131
+
132
+ def _query_param(url: str, name: str) -> str | None:
133
+ """Return the first value of ``name`` in ``url``'s query string."""
134
+ qs = urllib.parse.urlsplit(url).query
135
+ values = urllib.parse.parse_qs(qs).get(name)
136
+ if not values:
137
+ return None
138
+ return values[0]
139
+
140
+
141
+ def _parse_iso(value: str | None) -> datetime.datetime | None:
142
+ if not value:
143
+ return None
144
+ try:
145
+ return datetime.datetime.fromisoformat(value)
146
+ except ValueError:
147
+ return None
148
+
149
+
150
+ def _checks_cache_key(
151
+ credentials: dict[str, str], host: str, owner: str, repo: str
152
+ ) -> str | None:
153
+ """Hash the bearer token together with the resolved host and
154
+ ``<owner>/<repo>`` so the 403 cache is scoped per repo+host+token.
155
+
156
+ Returns ``None`` when no token is present, which short-circuits
157
+ both ``_checks_disabled`` and ``_record_checks_disabled``.
158
+ """
159
+ token = credentials.get('access_token') or credentials.get('token')
160
+ if not token:
161
+ return None
162
+ material = f'{token}\n{host.lower()}\n{owner}/{repo}'
163
+ return hashlib.sha256(material.encode()).hexdigest()
164
+
165
+
166
+ def _checks_disabled(
167
+ credentials: dict[str, str], host: str, owner: str, repo: str
168
+ ) -> bool:
169
+ """Return ``True`` when this (token, host, repo) tuple has 403'd
170
+ on ``/check-runs`` recently enough that we shouldn't probe again.
171
+ """
172
+ key = _checks_cache_key(credentials, host, owner, repo)
173
+ if key is None:
174
+ return False
175
+ recorded = _CHECKS_DISABLED_TOKENS.get(key)
176
+ if recorded is None:
177
+ return False
178
+ if time.monotonic() - recorded > _CHECKS_DISABLED_TTL_SECONDS:
179
+ _CHECKS_DISABLED_TOKENS.pop(key, None)
180
+ return False
181
+ return True
182
+
183
+
184
+ def _record_checks_disabled(
185
+ credentials: dict[str, str], host: str, owner: str, repo: str
186
+ ) -> None:
187
+ """Mark this (token, host, repo) as forbidden from ``/check-runs``
188
+ for the TTL.
189
+
190
+ Also opportunistically evicts any entries whose TTL has expired so
191
+ the dict can't grow unbounded — ``_checks_disabled`` only prunes
192
+ the key it looks up, which leaves long-tail stale tuples sitting
193
+ around forever for tokens / repos that never get re-probed.
194
+ """
195
+ key = _checks_cache_key(credentials, host, owner, repo)
196
+ if key is None:
197
+ return
198
+ now = time.monotonic()
199
+ expired = [
200
+ k
201
+ for k, recorded in _CHECKS_DISABLED_TOKENS.items()
202
+ if now - recorded > _CHECKS_DISABLED_TTL_SECONDS
203
+ ]
204
+ for k in expired:
205
+ _CHECKS_DISABLED_TOKENS.pop(k, None)
206
+ _CHECKS_DISABLED_TOKENS[key] = now
207
+
208
+
209
+ def _commit_from_payload(payload: dict[str, typing.Any]) -> Commit:
210
+ """Convert a GitHub commit list/object payload into a :class:`Commit`."""
211
+ sha = str(payload.get('sha', ''))
212
+ commit_meta: dict[str, typing.Any] = payload.get('commit') or {}
213
+ author_meta: dict[str, typing.Any] = commit_meta.get('author') or {}
214
+ raw_message = str(commit_meta.get('message') or '')
215
+ message_lines = raw_message.splitlines()
216
+ return Commit(
217
+ sha=sha,
218
+ short_sha=_short_sha(sha),
219
+ message=message_lines[0] if message_lines else '',
220
+ author=author_meta.get('name'),
221
+ authored_at=_parse_iso(author_meta.get('date')),
222
+ url=payload.get('html_url'),
223
+ )
224
+
225
+
226
+ def _check_runs_to_status(
227
+ payload: dict[str, typing.Any],
228
+ ) -> typing.Literal['pass', 'fail', 'warn', 'unknown']:
229
+ """Roll up the GitHub /check-runs payload into a single status."""
230
+ raw_runs: list[dict[str, typing.Any]] = payload.get('check_runs') or []
231
+ if not raw_runs:
232
+ return 'unknown'
233
+ # Don't roll up while any run is still in flight — a mix of one
234
+ # ``success`` and one ``in_progress`` would otherwise surface as
235
+ # ``pass`` because the in-progress conclusion is ``None``.
236
+ if any(str(run.get('status') or '') != 'completed' for run in raw_runs):
237
+ return 'unknown'
238
+ conclusions = {str(run.get('conclusion') or '') for run in raw_runs}
239
+ failed = {'failure', 'timed_out', 'action_required'}
240
+ if conclusions & failed:
241
+ return 'fail'
242
+ if 'cancelled' in conclusions or 'stale' in conclusions:
243
+ return 'warn'
244
+ if conclusions <= {'success', 'neutral', 'skipped', ''}:
245
+ if 'success' in conclusions:
246
+ return 'pass'
247
+ return 'unknown'
248
+ return 'unknown'
249
+
250
+
251
+ class _DeploymentBase(DeploymentPlugin):
252
+ """Shared base for GitHub deployment plugins.
253
+
254
+ Subclasses set :attr:`manifest` and override :meth:`_resolve_host`.
255
+ Each plugin instance is single-shot: callers pass ``credentials``
256
+ (from the paired :class:`IdentityPlugin`) and ``ctx`` per call.
257
+ """
258
+
259
+ @classmethod
260
+ def _resolve_host(cls, options: dict[str, typing.Any]) -> str:
261
+ raise NotImplementedError
262
+
263
+ def _api_base(self, options: dict[str, typing.Any]) -> str:
264
+ host = self._resolve_host(options)
265
+ if host == 'github.com':
266
+ return 'https://api.github.com'
267
+ if host.endswith('.ghe.com'):
268
+ return f'https://api.{host}'
269
+ return f'https://{host}/api/v3'
270
+
271
+ # Reserved GitHub URL prefixes that share the host with repository
272
+ # URLs but never point at a real ``<owner>/<repo>`` pair. Any link
273
+ # whose first path segment matches one of these is skipped during
274
+ # owner/repo derivation so e.g. ``github.com/orgs/<org>`` or a
275
+ # marketplace link can't silently bind a deployment to the wrong
276
+ # target.
277
+ _RESERVED_LINK_PREFIXES = frozenset(
278
+ {
279
+ 'orgs',
280
+ 'marketplace',
281
+ 'settings',
282
+ 'enterprises',
283
+ 'features',
284
+ 'pricing',
285
+ 'about',
286
+ 'login',
287
+ 'logout',
288
+ 'signup',
289
+ 'sponsors',
290
+ 'topics',
291
+ 'collections',
292
+ 'trending',
293
+ 'codespaces',
294
+ 'notifications',
295
+ 'issues',
296
+ 'pulls',
297
+ 'search',
298
+ 'stars',
299
+ 'explore',
300
+ }
301
+ )
302
+
303
+ @classmethod
304
+ def _derive_owner_repo_from_links(
305
+ cls, links: dict[str, str], host: str
306
+ ) -> tuple[str, str] | None:
307
+ """Find a project link pointing at ``host`` and parse owner/repo.
308
+
309
+ Prefers an explicit ``github-repository`` link key when one is
310
+ present and points at ``host``; otherwise scans the remaining
311
+ same-host links and returns the first usable one. Path entries
312
+ whose first segment is a reserved GitHub route (e.g. ``orgs``,
313
+ ``marketplace``) are rejected so a non-repo URL can't silently
314
+ bind the deployment to a wrong target. A trailing ``.git`` is
315
+ stripped from the repo segment. Returns ``None`` when nothing
316
+ matches.
317
+ """
318
+ target = host.lower()
319
+ preferred = links.get('github-repository')
320
+ if preferred is not None:
321
+ owner_repo = cls._parse_owner_repo(preferred, target)
322
+ if owner_repo is not None:
323
+ return owner_repo
324
+ for key, url in links.items():
325
+ if key == 'github-repository':
326
+ continue
327
+ owner_repo = cls._parse_owner_repo(url, target)
328
+ if owner_repo is not None:
329
+ return owner_repo
330
+ return None
331
+
332
+ @classmethod
333
+ def _parse_owner_repo(
334
+ cls, url: str, target_host: str
335
+ ) -> tuple[str, str] | None:
336
+ """Extract ``(owner, repo)`` from ``url`` when it's a repo URL on
337
+ ``target_host``. Returns ``None`` for other hosts, short paths,
338
+ or reserved-prefix paths like ``/orgs/<org>``.
339
+ """
340
+ try:
341
+ parsed = urllib.parse.urlparse(url)
342
+ except ValueError:
343
+ return None
344
+ if (parsed.hostname or '').lower() != target_host:
345
+ return None
346
+ parts = [p for p in parsed.path.split('/') if p]
347
+ if len(parts) < 2:
348
+ return None
349
+ if parts[0].lower() in cls._RESERVED_LINK_PREFIXES:
350
+ return None
351
+ return parts[0], parts[1].removesuffix('.git')
352
+
353
+ def _owner_repo(self, ctx: PluginContext) -> tuple[str, str]:
354
+ derived = self._derive_owner_repo_from_links(
355
+ ctx.project_links, self._resolve_host(ctx.assignment_options)
356
+ )
357
+ if derived is not None:
358
+ return derived
359
+ # Final fallback: convention is ``<project_type_slug>/<project_slug>``.
360
+ # Most projects carry a single type, so the first slug is what
361
+ # callers expect; we don't probe each candidate against GitHub here
362
+ # because ``_owner_repo`` is consulted on every API call.
363
+ if ctx.project_type_slugs and ctx.project_slug:
364
+ return ctx.project_type_slugs[0], ctx.project_slug
365
+ raise ValueError(
366
+ 'GitHub deployment plugin could not determine the target '
367
+ "repository: set the project's GitHub Repository link or "
368
+ 'tag the project with a ProjectType'
369
+ )
370
+
371
+ def _repo_url(self, ctx: PluginContext) -> str:
372
+ owner, repo = self._owner_repo(ctx)
373
+ return f'{self._api_base(ctx.assignment_options)}/repos/{owner}/{repo}'
374
+
375
+ @staticmethod
376
+ def _option_str(
377
+ options: dict[str, typing.Any], key: str, default: str
378
+ ) -> str:
379
+ value = options.get(key)
380
+ if isinstance(value, str) and value.strip():
381
+ return value.strip()
382
+ return default
383
+
384
+ @staticmethod
385
+ def _token(credentials: dict[str, str]) -> str:
386
+ token = credentials.get('access_token') or credentials.get('token')
387
+ if not token:
388
+ raise ValueError(
389
+ 'GitHub deployment plugin requires an OAuth access token; '
390
+ 'expected ``credentials["access_token"]``'
391
+ )
392
+ return token
393
+
394
+ def _client(
395
+ self, ctx: PluginContext, credentials: dict[str, str]
396
+ ) -> httpx.AsyncClient:
397
+ return httpx.AsyncClient(
398
+ timeout=_HTTP_TIMEOUT_SECONDS,
399
+ headers=_auth_headers(self._token(credentials)),
400
+ base_url=self._repo_url(ctx),
401
+ event_hooks={'response': [_raise_on_401]},
402
+ )
403
+
404
+ # -- Refs ---------------------------------------------------------------
405
+
406
+ async def list_refs(
407
+ self,
408
+ ctx: PluginContext,
409
+ credentials: dict[str, str],
410
+ kind: typing.Literal['default', 'branch', 'tag', 'all'] = 'all',
411
+ query: str | None = None,
412
+ ) -> list[Ref]:
413
+ async with self._client(ctx, credentials) as client:
414
+ tasks: list[collections.abc.Awaitable[list[Ref]]] = []
415
+ if kind in ('default', 'branch', 'all'):
416
+ # Resolve the repo's actual default branch up front so
417
+ # ``_list_branches`` can suppress it without guessing —
418
+ # the manifest option is just a hint and may be stale.
419
+ default_branch = await self._fetch_default_branch(client)
420
+ if kind in ('default', 'all'):
421
+ tasks.append(
422
+ self._list_default_ref(client, default_branch)
423
+ )
424
+ if kind in ('branch', 'all'):
425
+ tasks.append(
426
+ self._list_branches(
427
+ client, default_branch, query=query
428
+ )
429
+ )
430
+ if kind in ('tag', 'all'):
431
+ tasks.append(self._list_tags(client, query=query))
432
+ groups = await asyncio.gather(*tasks)
433
+ return [ref for group in groups for ref in group]
434
+
435
+ async def _fetch_default_branch(self, client: httpx.AsyncClient) -> str:
436
+ # ``base_url`` is normalized with a trailing slash by httpx, so
437
+ # ``client.get('')`` produces ``.../repos/<owner>/<repo>/`` which
438
+ # GHEC's API gateway answers with a 404 even though the
439
+ # trailing-slash form succeeds on github.com. Pass the absolute
440
+ # URL with the trailing slash stripped so both backends agree.
441
+ url = str(client.base_url).rstrip('/')
442
+ repo_resp = await client.get(url)
443
+ repo_resp.raise_for_status()
444
+ repo_meta = typing.cast(dict[str, typing.Any], repo_resp.json())
445
+ return str(repo_meta.get('default_branch') or 'main')
446
+
447
+ async def _list_default_ref(
448
+ self, client: httpx.AsyncClient, default_branch: str
449
+ ) -> list[Ref]:
450
+ branch_resp = await client.get(f'/branches/{default_branch}')
451
+ if branch_resp.status_code != 200:
452
+ return []
453
+ branch = typing.cast(dict[str, typing.Any], branch_resp.json())
454
+ branch_commit: dict[str, typing.Any] = branch.get('commit') or {}
455
+ sha = str(branch_commit.get('sha') or '')
456
+ return [
457
+ Ref(
458
+ name=default_branch,
459
+ kind='default',
460
+ sha=sha,
461
+ is_default=True,
462
+ )
463
+ ]
464
+
465
+ async def _paginate(
466
+ self,
467
+ client: httpx.AsyncClient,
468
+ path: str,
469
+ params: dict[str, str],
470
+ ) -> list[dict[str, typing.Any]]:
471
+ """Walk GitHub's ``Link: rel="next"`` pagination chain.
472
+
473
+ Caps at ``_MAX_REF_PAGES`` so a pathological repo can't pin us
474
+ on a single endpoint indefinitely.
475
+ """
476
+ all_rows: list[dict[str, typing.Any]] = []
477
+ page_params: dict[str, str] = dict(params)
478
+ for _ in range(_MAX_REF_PAGES):
479
+ resp = await client.get(path, params=page_params)
480
+ resp.raise_for_status()
481
+ rows = typing.cast(list[dict[str, typing.Any]], resp.json())
482
+ all_rows.extend(rows)
483
+ next_url = _next_page_url(resp.headers.get('link'))
484
+ if next_url is None:
485
+ break
486
+ # Pull the ``page`` cursor out of the Link header rather
487
+ # than re-issuing against the absolute URL — keeps us on
488
+ # the existing client base_url and respx-matchable.
489
+ next_page = _query_param(next_url, 'page')
490
+ if next_page is None:
491
+ break
492
+ page_params['page'] = next_page
493
+ return all_rows
494
+
495
+ async def _list_branches(
496
+ self,
497
+ client: httpx.AsyncClient,
498
+ default_branch: str,
499
+ query: str | None = None,
500
+ ) -> list[Ref]:
501
+ rows = await self._paginate(client, '/branches', {'per_page': '100'})
502
+ out: list[Ref] = []
503
+ for row in rows:
504
+ name = str(row.get('name') or '')
505
+ if not name or name == default_branch:
506
+ continue
507
+ if query and query.lower() not in name.lower():
508
+ continue
509
+ commit: dict[str, typing.Any] = row.get('commit') or {}
510
+ sha = str(commit.get('sha') or '')
511
+ out.append(Ref(name=name, kind='branch', sha=sha))
512
+ return out
513
+
514
+ async def _list_tags(
515
+ self, client: httpx.AsyncClient, query: str | None = None
516
+ ) -> list[Ref]:
517
+ rows = await self._paginate(client, '/tags', {'per_page': '100'})
518
+ out: list[Ref] = []
519
+ for row in rows:
520
+ name = str(row.get('name') or '')
521
+ if not name:
522
+ continue
523
+ if query and query.lower() not in name.lower():
524
+ continue
525
+ commit: dict[str, typing.Any] = row.get('commit') or {}
526
+ sha = str(commit.get('sha') or '')
527
+ out.append(Ref(name=name, kind='tag', sha=sha))
528
+ return out
529
+
530
+ # -- Commits ------------------------------------------------------------
531
+
532
+ async def list_commits(
533
+ self,
534
+ ctx: PluginContext,
535
+ credentials: dict[str, str],
536
+ ref: str,
537
+ limit: int = 25,
538
+ ) -> list[Commit]:
539
+ params = {'sha': ref, 'per_page': str(max(1, min(limit, 100)))}
540
+ host = self._resolve_host(ctx.assignment_options)
541
+ owner, repo = self._owner_repo(ctx)
542
+ async with self._client(ctx, credentials) as client:
543
+ resp = await client.get('/commits', params=params)
544
+ resp.raise_for_status()
545
+ rows = typing.cast(list[dict[str, typing.Any]], resp.json())
546
+ commits = [_commit_from_payload(row) for row in rows]
547
+ if commits:
548
+ commits[0] = commits[0].model_copy(update={'is_head': True})
549
+ if not commits or _checks_disabled(credentials, host, owner, repo):
550
+ return commits
551
+ # Probe the head commit synchronously: if check-runs is
552
+ # forbidden for this token (missing scope or Actions
553
+ # disabled on the repo) we'd otherwise issue one wasted
554
+ # 403 per commit in parallel. Probing first lets the cache
555
+ # short-circuit the rest.
556
+ commits[0] = await self._hydrate_check_status(
557
+ client, credentials, host, owner, repo, commits[0]
558
+ )
559
+ if len(commits) == 1 or _checks_disabled(
560
+ credentials, host, owner, repo
561
+ ):
562
+ return commits
563
+ tail = await asyncio.gather(
564
+ *(
565
+ self._hydrate_check_status(
566
+ client, credentials, host, owner, repo, c
567
+ )
568
+ for c in commits[1:]
569
+ )
570
+ )
571
+ return [commits[0], *tail]
572
+
573
+ async def _hydrate_check_status(
574
+ self,
575
+ client: httpx.AsyncClient,
576
+ credentials: dict[str, str],
577
+ host: str,
578
+ owner: str,
579
+ repo: str,
580
+ commit: Commit,
581
+ ) -> Commit:
582
+ if _checks_disabled(credentials, host, owner, repo):
583
+ return commit
584
+ try:
585
+ resp = await client.get(f'/commits/{commit.sha}/check-runs')
586
+ except httpx.HTTPError:
587
+ return commit
588
+ if resp.status_code == 403:
589
+ _record_checks_disabled(credentials, host, owner, repo)
590
+ return commit
591
+ if resp.status_code != 200:
592
+ return commit
593
+ try:
594
+ payload = typing.cast(dict[str, typing.Any], resp.json())
595
+ except ValueError:
596
+ return commit
597
+ return commit.model_copy(
598
+ update={'ci_status': _check_runs_to_status(payload)}
599
+ )
600
+
601
+ async def resolve_committish(
602
+ self,
603
+ ctx: PluginContext,
604
+ credentials: dict[str, str],
605
+ committish: str,
606
+ ) -> Commit:
607
+ async with self._client(ctx, credentials) as client:
608
+ resp = await client.get(
609
+ f'/commits/{urllib.parse.quote(committish, safe="")}'
610
+ )
611
+ resp.raise_for_status()
612
+ payload = typing.cast(dict[str, typing.Any], resp.json())
613
+ return _commit_from_payload(payload)
614
+
615
+ # -- Compare ------------------------------------------------------------
616
+
617
+ async def compare(
618
+ self,
619
+ ctx: PluginContext,
620
+ credentials: dict[str, str],
621
+ base: str,
622
+ head: str,
623
+ ) -> CompareResult:
624
+ async with self._client(ctx, credentials) as client:
625
+ quoted = urllib.parse.quote(f'{base}...{head}', safe='.')
626
+ resp = await client.get(f'/compare/{quoted}')
627
+ resp.raise_for_status()
628
+ payload = typing.cast(dict[str, typing.Any], resp.json())
629
+ commits_raw: list[dict[str, typing.Any]] = (
630
+ payload.get('commits') or []
631
+ )
632
+ commits: list[Commit] = [
633
+ _commit_from_payload(item) for item in commits_raw
634
+ ]
635
+ files: list[dict[str, typing.Any]] = payload.get('files') or []
636
+ additions = sum(int(f.get('additions') or 0) for f in files)
637
+ deletions = sum(int(f.get('deletions') or 0) for f in files)
638
+ base_commit: dict[str, typing.Any] = (
639
+ payload.get('base_commit')
640
+ or payload.get('merge_base_commit')
641
+ or {}
642
+ )
643
+ base_sha = str(base_commit.get('sha') or base)
644
+ head_sha = commits[-1].sha if commits else head
645
+ return CompareResult(
646
+ base_sha=base_sha,
647
+ head_sha=head_sha,
648
+ ahead=int(payload.get('ahead_by') or 0),
649
+ behind=int(payload.get('behind_by') or 0),
650
+ commits=commits,
651
+ files_changed=len(files),
652
+ additions=additions,
653
+ deletions=deletions,
654
+ )
655
+
656
+ # -- Tags / Releases (Phase 2 — implemented here for completeness) -----
657
+
658
+ async def create_tag(
659
+ self,
660
+ ctx: PluginContext,
661
+ credentials: dict[str, str],
662
+ sha: str,
663
+ tag: str,
664
+ message: str,
665
+ ) -> RefInfo:
666
+ async with self._client(ctx, credentials) as client:
667
+ tag_resp = await client.post(
668
+ '/git/tags',
669
+ json={
670
+ 'tag': tag,
671
+ 'message': message,
672
+ 'object': sha,
673
+ 'type': 'commit',
674
+ },
675
+ )
676
+ tag_resp.raise_for_status()
677
+ tag_payload = typing.cast(dict[str, typing.Any], tag_resp.json())
678
+ ref_resp = await client.post(
679
+ '/git/refs',
680
+ json={
681
+ 'ref': f'refs/tags/{tag}',
682
+ 'sha': str(tag_payload.get('sha') or sha),
683
+ },
684
+ )
685
+ ref_resp.raise_for_status()
686
+ ref_payload = typing.cast(dict[str, typing.Any], ref_resp.json())
687
+ return RefInfo(
688
+ name=str(ref_payload.get('ref') or f'refs/tags/{tag}'),
689
+ sha=str(ref_payload.get('object', {}).get('sha') or sha),
690
+ url=ref_payload.get('url'),
691
+ )
692
+
693
+ async def create_release(
694
+ self,
695
+ ctx: PluginContext,
696
+ credentials: dict[str, str],
697
+ tag: str,
698
+ name: str,
699
+ body_markdown: str,
700
+ prerelease: bool = False,
701
+ ) -> ReleaseInfo:
702
+ async with self._client(ctx, credentials) as client:
703
+ resp = await client.post(
704
+ '/releases',
705
+ json={
706
+ 'tag_name': tag,
707
+ 'name': name,
708
+ 'body': body_markdown,
709
+ 'prerelease': prerelease,
710
+ },
711
+ )
712
+ resp.raise_for_status()
713
+ payload = typing.cast(dict[str, typing.Any], resp.json())
714
+ return ReleaseInfo(
715
+ id=str(payload.get('id') or ''),
716
+ tag=str(payload.get('tag_name') or tag),
717
+ name=payload.get('name'),
718
+ url=payload.get('url'),
719
+ html_url=payload.get('html_url'),
720
+ prerelease=bool(payload.get('prerelease', prerelease)),
721
+ )
722
+
723
+ # -- Workflow dispatch --------------------------------------------------
724
+
725
+ async def trigger_deployment(
726
+ self,
727
+ ctx: PluginContext,
728
+ credentials: dict[str, str],
729
+ ref_or_sha: str,
730
+ inputs: dict[str, str] | None = None,
731
+ ) -> DeploymentRun:
732
+ options = ctx.assignment_options
733
+ workflow = self._option_str(options, 'workflow', _DEFAULT_WORKFLOW)
734
+ env_input = self._option_str(
735
+ options, 'environment_input', _DEFAULT_ENVIRONMENT_INPUT
736
+ )
737
+ ref_input = self._option_str(options, 'ref_input', _DEFAULT_REF_INPUT)
738
+ if not ctx.environment:
739
+ raise ValueError(
740
+ 'trigger_deployment requires PluginContext.environment'
741
+ )
742
+ # Caller-supplied inputs come first; the reserved env/ref keys
743
+ # are written last so the plugin's contract (deploy *this* ref
744
+ # to *this* environment) can't be subverted by a stray entry.
745
+ body_inputs: dict[str, str] = dict(inputs or {})
746
+ body_inputs[env_input] = ctx.environment
747
+ body_inputs[ref_input] = ref_or_sha
748
+ # Capture the dispatch instant *before* POSTing so we can
749
+ # correlate the resulting workflow run on the listing endpoint.
750
+ # GitHub records ``created_at`` with second precision, so subtract
751
+ # a small skew to avoid losing the run to clock drift.
752
+ dispatch_started = datetime.datetime.now(
753
+ datetime.UTC
754
+ ) - datetime.timedelta(seconds=2)
755
+ async with self._client(ctx, credentials) as client:
756
+ dispatch_resp = await client.post(
757
+ f'/actions/workflows/{workflow}/dispatches',
758
+ json={'ref': ref_or_sha, 'inputs': body_inputs},
759
+ )
760
+ dispatch_resp.raise_for_status()
761
+ # GitHub returns 204 with no body — find the run we just
762
+ # created by listing recent runs and matching event +
763
+ # creation time (and ref when present) so a concurrent
764
+ # dispatch can't bind us to its run.
765
+ runs_resp = await client.get(
766
+ f'/actions/workflows/{workflow}/runs',
767
+ params={'event': 'workflow_dispatch', 'per_page': '10'},
768
+ )
769
+ run_id = ''
770
+ run_url: str | None = None
771
+ if runs_resp.status_code == 200:
772
+ payload = typing.cast(dict[str, typing.Any], runs_resp.json())
773
+ runs: list[dict[str, typing.Any]] = (
774
+ payload.get('workflow_runs') or []
775
+ )
776
+ match = self._match_dispatched_run(
777
+ runs, dispatch_started, ref_or_sha
778
+ )
779
+ if match is not None:
780
+ run_id = str(match.get('id') or '')
781
+ run_url = match.get('html_url')
782
+ return DeploymentRun(
783
+ run_id=run_id,
784
+ run_url=run_url,
785
+ status='queued',
786
+ )
787
+
788
+ @staticmethod
789
+ def _match_dispatched_run(
790
+ runs: list[dict[str, typing.Any]],
791
+ dispatch_started: datetime.datetime,
792
+ ref_or_sha: str,
793
+ ) -> dict[str, typing.Any] | None:
794
+ """Pick the run created by the dispatch we just issued.
795
+
796
+ Only runs whose ``head_branch`` or ``head_sha`` explicitly
797
+ match ``ref_or_sha`` and whose ``created_at`` is at or after
798
+ ``dispatch_started`` are eligible. Returns ``None`` when the
799
+ result is ambiguous (zero or multiple matches) — the caller
800
+ surfaces an empty ``run_id`` rather than binding to someone
801
+ else's run.
802
+ """
803
+ candidates: list[dict[str, typing.Any]] = []
804
+ for run in runs:
805
+ created = _parse_iso(run.get('created_at'))
806
+ if created is None or created < dispatch_started:
807
+ continue
808
+ head_branch = run.get('head_branch')
809
+ head_sha = run.get('head_sha')
810
+ if head_branch == ref_or_sha or head_sha == ref_or_sha:
811
+ candidates.append(run)
812
+ if len(candidates) == 1:
813
+ return candidates[0]
814
+ return None
815
+
816
+ async def get_check_status(
817
+ self,
818
+ ctx: PluginContext,
819
+ credentials: dict[str, str],
820
+ committish: str,
821
+ ) -> CheckStatus:
822
+ """Aggregate ``/commits/{ref}/check-runs`` to a single status.
823
+
824
+ Tolerates the same failure modes as commit-level hydration:
825
+ network errors, non-200 responses, and unparseable JSON all
826
+ degrade to ``'unknown'`` rather than raising — the release
827
+ train should never fail to render because a side hydration
828
+ call hiccuped.
829
+ """
830
+ host = self._resolve_host(ctx.assignment_options)
831
+ owner, repo = self._owner_repo(ctx)
832
+ if _checks_disabled(credentials, host, owner, repo):
833
+ return 'unknown'
834
+ encoded = urllib.parse.quote(committish, safe='')
835
+ async with self._client(ctx, credentials) as client:
836
+ try:
837
+ resp = await client.get(f'/commits/{encoded}/check-runs')
838
+ except httpx.HTTPError:
839
+ return 'unknown'
840
+ if resp.status_code == 403:
841
+ _record_checks_disabled(credentials, host, owner, repo)
842
+ return 'unknown'
843
+ if resp.status_code != 200:
844
+ return 'unknown'
845
+ try:
846
+ payload = typing.cast(dict[str, typing.Any], resp.json())
847
+ except ValueError:
848
+ return 'unknown'
849
+ return _check_runs_to_status(payload)
850
+
851
+ async def get_deployment_status(
852
+ self,
853
+ ctx: PluginContext,
854
+ credentials: dict[str, str],
855
+ run_id: str,
856
+ ) -> DeploymentRun:
857
+ async with self._client(ctx, credentials) as client:
858
+ resp = await client.get(f'/actions/runs/{run_id}')
859
+ resp.raise_for_status()
860
+ payload = typing.cast(dict[str, typing.Any], resp.json())
861
+ status_raw = str(payload.get('status') or '')
862
+ conclusion = str(payload.get('conclusion') or '')
863
+ status: typing.Literal[
864
+ 'queued',
865
+ 'in_progress',
866
+ 'success',
867
+ 'failure',
868
+ 'cancelled',
869
+ 'unknown',
870
+ ]
871
+ if status_raw == 'queued':
872
+ status = 'queued'
873
+ elif status_raw == 'in_progress':
874
+ status = 'in_progress'
875
+ elif conclusion == 'success':
876
+ status = 'success'
877
+ elif conclusion in {'failure', 'timed_out', 'action_required'}:
878
+ status = 'failure'
879
+ elif conclusion == 'cancelled':
880
+ status = 'cancelled'
881
+ else:
882
+ status = 'unknown'
883
+ return DeploymentRun(
884
+ run_id=str(payload.get('id') or run_id),
885
+ run_url=payload.get('html_url'),
886
+ status=status,
887
+ started_at=_parse_iso(payload.get('run_started_at')),
888
+ completed_at=_parse_iso(payload.get('updated_at'))
889
+ if conclusion
890
+ else None,
891
+ )
892
+
893
+
894
+ _COMMON_OPTIONS: list[PluginOption] = [
895
+ PluginOption(
896
+ name='workflow',
897
+ label='Workflow file',
898
+ description='File name in .github/workflows to dispatch.',
899
+ type='string',
900
+ default=_DEFAULT_WORKFLOW,
901
+ ),
902
+ PluginOption(
903
+ name='environment_input',
904
+ label='Environment input name',
905
+ type='string',
906
+ default=_DEFAULT_ENVIRONMENT_INPUT,
907
+ ),
908
+ PluginOption(
909
+ name='ref_input',
910
+ label='Ref input name',
911
+ type='string',
912
+ default=_DEFAULT_REF_INPUT,
913
+ ),
914
+ ]
915
+
916
+ _COMMON_CREDENTIALS: list[CredentialField] = [
917
+ CredentialField(
918
+ name='access_token',
919
+ label='Service-account PAT (optional fallback)',
920
+ description=(
921
+ 'Personal access token used when no per-user identity is '
922
+ 'bound. Requires contents:write and actions:write scopes.'
923
+ ),
924
+ required=False,
925
+ ),
926
+ ]
927
+
928
+
929
+ class GitHubDeploymentPlugin(_DeploymentBase):
930
+ manifest = PluginManifest(
931
+ slug='github-deployment',
932
+ name='GitHub Deployment',
933
+ description=(
934
+ 'Drive github.com workflow_dispatch deployments and record '
935
+ 'GitHub Releases on behalf of an Imbi project.'
936
+ ),
937
+ plugin_type='deployment',
938
+ options=_COMMON_OPTIONS,
939
+ credentials=_COMMON_CREDENTIALS,
940
+ )
941
+
942
+ @classmethod
943
+ def _resolve_host(cls, options: dict[str, typing.Any]) -> str:
944
+ return 'github.com'
945
+
946
+
947
+ class GitHubEnterpriseCloudDeploymentPlugin(_DeploymentBase):
948
+ manifest = PluginManifest(
949
+ slug='github-deployment-ec',
950
+ name='GitHub Enterprise Cloud Deployment',
951
+ description=(
952
+ 'Drive workflow_dispatch deployments against a GHEC tenant '
953
+ '(``*.ghe.com``).'
954
+ ),
955
+ plugin_type='deployment',
956
+ options=[
957
+ PluginOption(
958
+ name='host',
959
+ label='GHEC tenant host',
960
+ description='e.g. tenant.ghe.com',
961
+ type='string',
962
+ required=True,
963
+ ),
964
+ *_COMMON_OPTIONS,
965
+ ],
966
+ credentials=_COMMON_CREDENTIALS,
967
+ )
968
+
969
+ @classmethod
970
+ def _resolve_host(cls, options: dict[str, typing.Any]) -> str:
971
+ return require_ghec_tenant_host(
972
+ normalize_host(options.get('host'), 'GHEC deployment plugin'),
973
+ 'GHEC deployment plugin',
974
+ )
975
+
976
+
977
+ class GitHubEnterpriseServerDeploymentPlugin(_DeploymentBase):
978
+ manifest = PluginManifest(
979
+ slug='github-deployment-es',
980
+ name='GitHub Enterprise Server Deployment',
981
+ description=(
982
+ 'Drive workflow_dispatch deployments against a GHES install.'
983
+ ),
984
+ plugin_type='deployment',
985
+ options=[
986
+ PluginOption(
987
+ name='host',
988
+ label='GHES host',
989
+ type='string',
990
+ required=True,
991
+ ),
992
+ *_COMMON_OPTIONS,
993
+ ],
994
+ credentials=_COMMON_CREDENTIALS,
995
+ )
996
+
997
+ @classmethod
998
+ def _resolve_host(cls, options: dict[str, typing.Any]) -> str:
999
+ return normalize_host(options.get('host'), 'GHES deployment plugin')