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.
Files changed (24) hide show
  1. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/PKG-INFO +2 -2
  2. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/pyproject.toml +3 -3
  3. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/_repos.py +16 -3
  4. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/deployment.py +102 -4
  5. imbi_plugin_github-2.8.0/src/imbi_plugin_github/lifecycle.py +842 -0
  6. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/tests/test_deployment.py +165 -0
  7. imbi_plugin_github-2.8.0/tests/test_lifecycle.py +1159 -0
  8. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/uv.lock +6 -6
  9. imbi_plugin_github-1.4.0/src/imbi_plugin_github/lifecycle.py +0 -426
  10. imbi_plugin_github-1.4.0/tests/test_lifecycle.py +0 -559
  11. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/.github/workflows/publish.yml +0 -0
  12. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/.github/workflows/test.yml +0 -0
  13. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/.gitignore +0 -0
  14. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/.pre-commit-config.yaml +0 -0
  15. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/CLAUDE.md +0 -0
  16. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/LICENSE +0 -0
  17. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/README.md +0 -0
  18. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/justfile +0 -0
  19. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/__init__.py +0 -0
  20. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/_hosts.py +0 -0
  21. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/identity.py +0 -0
  22. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/tests/__init__.py +0 -0
  23. {imbi_plugin_github-1.4.0 → imbi_plugin_github-2.8.0}/tests/test_hosts.py +0 -0
  24. {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: 1.4.0
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>=2.5.5
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 = "1.4.0"
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>=2.5.5",
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, host: str, plugin_label: str
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 and ctx.project_slug:
108
- return ctx.project_type_slugs[0], ctx.project_slug
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
- def _client(
347
+ @contextlib.asynccontextmanager
348
+ async def _client(
322
349
  self, ctx: PluginContext, credentials: dict[str, str]
323
- ) -> httpx.AsyncClient:
324
- return httpx.AsyncClient(
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
- event_hooks={'response': [_raise_on_401]},
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 ---------------------------------------------------------------