imbi-plugin-github 1.4.0__tar.gz → 2.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/PKG-INFO +2 -2
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/pyproject.toml +3 -3
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/_repos.py +16 -3
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/deployment.py +102 -4
- imbi_plugin_github-2.8.0/src/imbi_plugin_github/lifecycle.py +842 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/tests/test_deployment.py +165 -0
- imbi_plugin_github-2.8.0/tests/test_lifecycle.py +1159 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/uv.lock +6 -6
- imbi_plugin_github-1.4.0/src/imbi_plugin_github/lifecycle.py +0 -426
- imbi_plugin_github-1.4.0/tests/test_lifecycle.py +0 -559
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/.github/workflows/publish.yml +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/.github/workflows/test.yml +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/.gitignore +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/.pre-commit-config.yaml +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/CLAUDE.md +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/LICENSE +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/README.md +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/justfile +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/__init__.py +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/_hosts.py +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/identity.py +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/tests/test_identity.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: imbi-plugin-github
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.8.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
|
|
16
|
+
Requires-Dist: imbi-common==2.8.0
|
|
17
17
|
Requires-Dist: pydantic>=2
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "imbi-plugin-github"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2.8.0"
|
|
4
4
|
description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -16,7 +16,7 @@ classifiers = [
|
|
|
16
16
|
]
|
|
17
17
|
dependencies = [
|
|
18
18
|
"httpx>=0.27",
|
|
19
|
-
"imbi-common
|
|
19
|
+
"imbi-common==2.8.0",
|
|
20
20
|
"pydantic>=2",
|
|
21
21
|
]
|
|
22
22
|
|
|
@@ -35,7 +35,7 @@ github-lifecycle-es = "imbi_plugin_github.lifecycle:GitHubEnterpriseServerLifecy
|
|
|
35
35
|
dev = [
|
|
36
36
|
"basedpyright",
|
|
37
37
|
"coverage[toml]",
|
|
38
|
-
"imbi-common[server,databases]",
|
|
38
|
+
"imbi-common[server,databases]==2.8.0",
|
|
39
39
|
"pre-commit",
|
|
40
40
|
"pytest",
|
|
41
41
|
"pytest-asyncio",
|
|
@@ -91,7 +91,11 @@ def derive_owner_repo_from_links(
|
|
|
91
91
|
|
|
92
92
|
|
|
93
93
|
def resolve_owner_repo(
|
|
94
|
-
ctx: PluginContext,
|
|
94
|
+
ctx: PluginContext,
|
|
95
|
+
host: str,
|
|
96
|
+
plugin_label: str,
|
|
97
|
+
*,
|
|
98
|
+
prefer_previous_slug: bool = False,
|
|
95
99
|
) -> tuple[str, str]:
|
|
96
100
|
"""Resolve the repo for a plugin call from ``ctx``.
|
|
97
101
|
|
|
@@ -100,12 +104,21 @@ def resolve_owner_repo(
|
|
|
100
104
|
:class:`ValueError` when neither path produces a candidate so the
|
|
101
105
|
caller can surface a clear "set the GitHub Repository link"
|
|
102
106
|
message to the operator.
|
|
107
|
+
|
|
108
|
+
``prefer_previous_slug`` makes the slug fallback prefer
|
|
109
|
+
``ctx.previous_project_slug`` (when set) over ``ctx.project_slug``
|
|
110
|
+
so callers reacting to a slug rename (e.g. ``on_project_updated``)
|
|
111
|
+
can still locate the pre-rename repo on GitHub.
|
|
103
112
|
"""
|
|
104
113
|
derived = derive_owner_repo_from_links(ctx.project_links, host)
|
|
105
114
|
if derived is not None:
|
|
106
115
|
return derived
|
|
107
|
-
if ctx.project_type_slugs
|
|
108
|
-
|
|
116
|
+
if ctx.project_type_slugs:
|
|
117
|
+
slug = ctx.project_slug
|
|
118
|
+
if prefer_previous_slug and ctx.previous_project_slug:
|
|
119
|
+
slug = ctx.previous_project_slug
|
|
120
|
+
if slug:
|
|
121
|
+
return ctx.project_type_slugs[0], slug
|
|
109
122
|
raise ValueError(
|
|
110
123
|
f'{plugin_label} could not determine the target repository: '
|
|
111
124
|
"set the project's GitHub Repository link or tag the project "
|
|
@@ -25,6 +25,7 @@ from __future__ import annotations
|
|
|
25
25
|
|
|
26
26
|
import asyncio
|
|
27
27
|
import collections.abc
|
|
28
|
+
import contextlib
|
|
28
29
|
import datetime
|
|
29
30
|
import hashlib
|
|
30
31
|
import logging
|
|
@@ -41,6 +42,7 @@ from imbi_common.plugins.base import (
|
|
|
41
42
|
DeploymentEventStatus,
|
|
42
43
|
DeploymentPlugin,
|
|
43
44
|
DeploymentRun,
|
|
45
|
+
LinkWriteback,
|
|
44
46
|
OpsLogTemplate,
|
|
45
47
|
PluginContext,
|
|
46
48
|
PluginEdgeLabel,
|
|
@@ -159,6 +161,30 @@ def _parse_iso(value: str | None) -> datetime.datetime | None:
|
|
|
159
161
|
return None
|
|
160
162
|
|
|
161
163
|
|
|
164
|
+
def _repo_root_from_redirect(location: str) -> str | None:
|
|
165
|
+
"""Derive the canonical repo-root URL from a rename redirect target.
|
|
166
|
+
|
|
167
|
+
GitHub answers a request to a renamed repo with a ``301`` whose
|
|
168
|
+
``Location`` points at the by-id form, e.g.
|
|
169
|
+
``https://api.host/repositories/687046/commits``. Strip the
|
|
170
|
+
sub-resource path back to ``https://api.host/repositories/687046`` so
|
|
171
|
+
we can ``GET`` it for the repo's current ``full_name``/``html_url``.
|
|
172
|
+
Returns ``None`` when ``location`` isn't a ``/repositories/{id}`` URL.
|
|
173
|
+
"""
|
|
174
|
+
parsed = urllib.parse.urlsplit(location)
|
|
175
|
+
parts = [segment for segment in parsed.path.split('/') if segment]
|
|
176
|
+
try:
|
|
177
|
+
idx = parts.index('repositories')
|
|
178
|
+
except ValueError:
|
|
179
|
+
return None
|
|
180
|
+
if idx + 1 >= len(parts):
|
|
181
|
+
return None
|
|
182
|
+
repo_id = parts[idx + 1]
|
|
183
|
+
return urllib.parse.urlunsplit(
|
|
184
|
+
(parsed.scheme, parsed.netloc, f'/repositories/{repo_id}', '', '')
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
162
188
|
def _checks_cache_key(
|
|
163
189
|
credentials: dict[str, str], host: str, owner: str, repo: str
|
|
164
190
|
) -> str | None:
|
|
@@ -318,14 +344,86 @@ class _DeploymentBase(DeploymentPlugin):
|
|
|
318
344
|
)
|
|
319
345
|
return token
|
|
320
346
|
|
|
321
|
-
|
|
347
|
+
@contextlib.asynccontextmanager
|
|
348
|
+
async def _client(
|
|
322
349
|
self, ctx: PluginContext, credentials: dict[str, str]
|
|
323
|
-
) -> httpx.AsyncClient:
|
|
324
|
-
|
|
350
|
+
) -> collections.abc.AsyncGenerator[httpx.AsyncClient]:
|
|
351
|
+
"""Yield an httpx client that survives — and self-heals — renames.
|
|
352
|
+
|
|
353
|
+
``follow_redirects=True`` means a renamed repo's ``301`` is
|
|
354
|
+
followed and the request transparently retried against the
|
|
355
|
+
canonical ``/repositories/{id}`` location instead of crashing in
|
|
356
|
+
``raise_for_status``. A response hook records that redirect; once
|
|
357
|
+
the caller's request succeeds we resolve the repo's new
|
|
358
|
+
``full_name``/``html_url`` and stash a
|
|
359
|
+
:class:`~imbi_common.plugins.base.LinkWriteback` on ``ctx`` so the
|
|
360
|
+
host can persist the project's updated stored link. This is the
|
|
361
|
+
single chokepoint for every deployment call.
|
|
362
|
+
"""
|
|
363
|
+
captured: list[str] = []
|
|
364
|
+
|
|
365
|
+
async def _capture_redirect(response: httpx.Response) -> None:
|
|
366
|
+
if response.is_redirect:
|
|
367
|
+
location = response.headers.get('location') or ''
|
|
368
|
+
if '/repositories/' in location:
|
|
369
|
+
captured.append(location)
|
|
370
|
+
|
|
371
|
+
client = httpx.AsyncClient(
|
|
325
372
|
timeout=_HTTP_TIMEOUT_SECONDS,
|
|
326
373
|
headers=_auth_headers(self._token(credentials)),
|
|
327
374
|
base_url=self._repo_url(ctx),
|
|
328
|
-
|
|
375
|
+
follow_redirects=True,
|
|
376
|
+
event_hooks={'response': [_capture_redirect, _raise_on_401]},
|
|
377
|
+
)
|
|
378
|
+
async with client:
|
|
379
|
+
yield client
|
|
380
|
+
# Only after the caller's request succeeded: a captured
|
|
381
|
+
# redirect means the repo was renamed out from under the
|
|
382
|
+
# stored link. Resolve the new name once and report it.
|
|
383
|
+
if captured and ctx.link_writeback is None:
|
|
384
|
+
await self._record_relocation(client, ctx, captured[-1])
|
|
385
|
+
|
|
386
|
+
async def _record_relocation(
|
|
387
|
+
self,
|
|
388
|
+
client: httpx.AsyncClient,
|
|
389
|
+
ctx: PluginContext,
|
|
390
|
+
redirect_location: str,
|
|
391
|
+
) -> None:
|
|
392
|
+
"""Resolve a renamed repo's canonical name and stash it on ``ctx``.
|
|
393
|
+
|
|
394
|
+
Best-effort: any failure to resolve the repo root leaves
|
|
395
|
+
``ctx.link_writeback`` unset so the user-facing call (which
|
|
396
|
+
already succeeded via the followed redirect) is never disturbed.
|
|
397
|
+
"""
|
|
398
|
+
repo_root = _repo_root_from_redirect(redirect_location)
|
|
399
|
+
if repo_root is None:
|
|
400
|
+
return
|
|
401
|
+
try:
|
|
402
|
+
resp = await client.get(repo_root)
|
|
403
|
+
except (httpx.HTTPError, PluginAuthenticationFailed):
|
|
404
|
+
# The user-facing request already succeeded; a probe failure
|
|
405
|
+
# (network, or a 401 surfaced by the response hook) must not
|
|
406
|
+
# turn that success into an error during teardown.
|
|
407
|
+
return
|
|
408
|
+
if resp.status_code != 200:
|
|
409
|
+
return
|
|
410
|
+
try:
|
|
411
|
+
payload = typing.cast(dict[str, typing.Any], resp.json())
|
|
412
|
+
except ValueError:
|
|
413
|
+
return
|
|
414
|
+
full_name = str(payload.get('full_name') or '')
|
|
415
|
+
html_url = str(payload.get('html_url') or '')
|
|
416
|
+
if not full_name or not html_url:
|
|
417
|
+
return
|
|
418
|
+
old_owner, old_repo = self._owner_repo(ctx)
|
|
419
|
+
old_owner_repo = f'{old_owner}/{old_repo}'
|
|
420
|
+
if full_name.lower() == old_owner_repo.lower():
|
|
421
|
+
return
|
|
422
|
+
ctx.link_writeback = LinkWriteback(
|
|
423
|
+
link_key='github-repository',
|
|
424
|
+
new_url=html_url,
|
|
425
|
+
old_owner_repo=old_owner_repo,
|
|
426
|
+
new_owner_repo=full_name,
|
|
329
427
|
)
|
|
330
428
|
|
|
331
429
|
# -- Refs ---------------------------------------------------------------
|