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.
- imbi_plugin_github/__init__.py +13 -0
- imbi_plugin_github/_hosts.py +50 -0
- imbi_plugin_github/deployment.py +999 -0
- imbi_plugin_github/identity.py +436 -0
- imbi_plugin_github-0.1.0.dist-info/METADATA +52 -0
- imbi_plugin_github-0.1.0.dist-info/RECORD +9 -0
- imbi_plugin_github-0.1.0.dist-info/WHEEL +4 -0
- imbi_plugin_github-0.1.0.dist-info/entry_points.txt +7 -0
- imbi_plugin_github-0.1.0.dist-info/licenses/LICENSE +28 -0
|
@@ -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')
|