imbi-plugin-github 1.3.0__tar.gz → 1.5.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 (22) hide show
  1. imbi_plugin_github-1.5.0/CLAUDE.md +128 -0
  2. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/PKG-INFO +6 -6
  3. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/README.md +4 -4
  4. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/pyproject.toml +4 -2
  5. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/deployment.py +103 -5
  6. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/identity.py +2 -3
  7. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/lifecycle.py +122 -4
  8. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/tests/test_deployment.py +165 -0
  9. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/tests/test_lifecycle.py +298 -0
  10. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/uv.lock +20 -5
  11. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/.github/workflows/publish.yml +0 -0
  12. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/.github/workflows/test.yml +0 -0
  13. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/.gitignore +0 -0
  14. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/.pre-commit-config.yaml +0 -0
  15. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/LICENSE +0 -0
  16. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/justfile +0 -0
  17. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/__init__.py +0 -0
  18. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/_hosts.py +0 -0
  19. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/_repos.py +0 -0
  20. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/tests/__init__.py +0 -0
  21. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/tests/test_hosts.py +0 -0
  22. {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.5.0}/tests/test_identity.py +0 -0
@@ -0,0 +1,128 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What This Is
6
+
7
+ A set of GitHub plugins for the Imbi platform, distributed as a single
8
+ Python package (`imbi_plugin_github`). It ships **three plugin types**
9
+ (identity, deployment, lifecycle), each in **three host flavors**
10
+ (github.com, GitHub Enterprise Cloud, GitHub Enterprise Server) — nine
11
+ plugins total. The Imbi host discovers them through the
12
+ `imbi.plugins` entry points declared in `pyproject.toml`; that table is
13
+ the registration surface — adding a plugin class means adding an entry
14
+ point there.
15
+
16
+ All plugin base classes (`IdentityPlugin`, `DeploymentPlugin`,
17
+ `LifecyclePlugin`, `PluginContext`, `PluginManifest`, the result/dataclass
18
+ types) come from `imbi_common.plugins.base`. That module is the contract
19
+ this package implements against; read it (in the sibling `imbi-common`
20
+ repo) before changing a plugin's method signatures.
21
+
22
+ ## Commands
23
+
24
+ ```bash
25
+ just setup # uv sync --all-groups --all-extras + install pre-commit hooks
26
+ just test # coverage run -m pytest tests + coverage report/xml (fails under 85%)
27
+ just test tests/test_lifecycle.py # one file
28
+ just test tests/test_lifecycle.py::ManifestTestCase # one class/test (passed straight to pytest)
29
+ just lint # pre-commit run --all-files (ruff, ruff-format, tombi, basedpyright)
30
+ just format [FILES] # ruff-format + tombi-format
31
+ ```
32
+
33
+ `just test` with arguments skips the coverage wrapper and runs `pytest`
34
+ directly. `.env`, if present, is passed via `--env-file` (not required
35
+ for the test suite). Type checking is `basedpyright` in **strict** mode
36
+ over `src`. Python is pinned to **3.14**; ruff uses single quotes and a
37
+ 79-char line length.
38
+
39
+ ## Architecture
40
+
41
+ ### The base/subclass/host-flavor pattern
42
+
43
+ Every plugin type follows the same shape: one `_*Base` class holds all
44
+ behavior, and three thin concrete subclasses differ *only* in their
45
+ `_resolve_host(options)` classmethod:
46
+
47
+ - github.com flavor → returns `'github.com'`.
48
+ - GHEC flavor → reads the required `host` option, validates it's a
49
+ `*.ghe.com` tenant via `require_ghec_tenant_host`.
50
+ - GHES flavor → reads the required `host` option, normalized only.
51
+
52
+ `_resolve_host` feeds URL construction, and the three backends route
53
+ differently — this mapping is the single most important thing to keep
54
+ consistent and is **duplicated** across the three modules
55
+ (`identity._endpoints`, `deployment._api_base`, `lifecycle._api_base`):
56
+
57
+ | Host | REST API base | OAuth base (identity only) |
58
+ | ------------------- | ------------------------------ | --------------------------------- |
59
+ | `github.com` | `https://api.github.com` | `https://github.com/login/oauth` |
60
+ | `<tenant>.ghe.com` | `https://api.<tenant>.ghe.com` | `https://<tenant>.ghe.com/login/oauth` |
61
+ | GHES `<host>` | `https://<host>/api/v3` | `https://<host>/login/oauth` |
62
+
63
+ When you change routing for one plugin type, check whether the other two
64
+ need the same change.
65
+
66
+ ### Shared helpers (single sources of truth)
67
+
68
+ - `_hosts.py` — `normalize_host` (strip/validate a bare hostname, reject
69
+ paths/ports/queries) and `require_ghec_tenant_host`. All host-option
70
+ validation lives here.
71
+ - `_repos.py` — `resolve_owner_repo(ctx, host, label)` derives the target
72
+ `(owner, repo)` for deployment and lifecycle calls: it scans
73
+ `ctx.project_links` (preferring the `github-repository` key, skipping
74
+ reserved GitHub path prefixes like `/orgs/`), then falls back to
75
+ `<project_type_slug>/<project_slug>`, and raises `ValueError` with an
76
+ operator-facing message when neither works.
77
+
78
+ ### Plugin invocation contract
79
+
80
+ Plugins are **stateless and single-shot**: the host constructs the
81
+ plugin once and calls methods passing `PluginContext` plus a
82
+ `credentials` dict per call. Identity plugins mint the OAuth access
83
+ token; deployment and lifecycle plugins consume it via
84
+ `credentials['access_token']` (with a `'token'` fallback) and send it as
85
+ a `Bearer` header. A `401` response is converted to
86
+ `PluginAuthenticationFailed` by an httpx response hook so the host's
87
+ retry layer can refresh the actor's identity once before failing the
88
+ user-visible request.
89
+
90
+ ### Behaviors that span multiple files / aren't obvious from one method
91
+
92
+ - **Deployment uses the GitHub Deployments API**, not
93
+ `workflow_dispatch`: Imbi's `Environment` maps 1:1 to GitHub's
94
+ `environment` so protection rules apply server-side. `trigger_deployment`
95
+ sends `auto_merge=False` and `required_contexts=[]` deliberately.
96
+ Promote behavior (semver tag → Deployment vs. raw SHA → tag + Release)
97
+ is decided **host-side** in imbi-api, not here. Per-environment workflow
98
+ inputs ride on the `USES_PLUGIN` graph edge as `env_payloads` and arrive
99
+ as `ctx.environment_config`.
100
+ - **`/check-runs` 403 cache** in `deployment.py`: a process-wide,
101
+ TTL'd cache (keyed by a hash of token+host+repo) suppresses repeated
102
+ 403s when the token lacks scope or Actions is disabled, so opening a
103
+ deploy dialog doesn't fire one wasted 403 per commit.
104
+ - **Lifecycle archive-with-transfer dance** (`lifecycle.py`): when
105
+ `archive_target_org` is set, archive transfers the repo there first.
106
+ GitHub refuses to transfer an *archived* repo, so an already-archived
107
+ source is briefly unarchived → transferred → re-archived. GitHub's
108
+ transfer is **async** (returns 202, repo briefly 404s at the
109
+ destination), so the post-transfer archive PATCH retries on 404 with a
110
+ bounded backoff (`_TRANSFER_ARCHIVE_BACKOFFS`). Unarchive only flips
111
+ `archived` back at the repo's current location — it never transfers
112
+ back, because the original owner isn't tracked.
113
+
114
+ ## Testing
115
+
116
+ Tests mock GitHub's HTTP with **respx** (`asyncio_mode = auto`, so async
117
+ tests need no decorator). Each test builds a `PluginContext` with a
118
+ `github-repository` project link and passes `{'access_token': ...}`
119
+ credentials, mirroring how the host calls in. When adding a GitHub API
120
+ call, add the matching respx route. Coverage must stay ≥ 85%.
121
+
122
+ ## Dependency Management
123
+
124
+ `pyproject.toml` pins the package to PyPI (`pypi.org`) with a 7-day
125
+ `exclude-newer` holdback on third-party packages; the imbi-common and
126
+ `imbi-plugin-*` packages are exempt via `exclude-newer-package` so local
127
+ ecosystem changes aren't held back. After changing dependencies, run
128
+ `uv sync` and verify `uv.lock`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: imbi-plugin-github
3
- Version: 1.3.0
3
+ Version: 1.5.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.7.0
17
17
  Requires-Dist: pydantic>=2
18
18
  Description-Content-Type: text/markdown
19
19
 
@@ -33,9 +33,9 @@ projects to the right backend.
33
33
 
34
34
  ### Identity
35
35
 
36
- Phase 1 ships the OAuth App flow only; GitHub App installation tokens are
37
- deferred. The access token returned by the OAuth grant is passed straight
38
- to GitHub APIs as a `Bearer` token, so `materialize()` is a no-op.
36
+ Implements the OAuth App flow. The access token returned by the OAuth
37
+ grant is passed straight to GitHub APIs as a `Bearer` token, so
38
+ `materialize()` is a no-op.
39
39
 
40
40
  ### Deployment
41
41
 
@@ -68,7 +68,7 @@ requires admin permission on the target organization.
68
68
  | Option | Required | Description |
69
69
  | ---------------- | --------- | -------------------------------------------------------------------------- |
70
70
  | `host` | GHEC/GHES | Tenant or appliance host (e.g. `tenant.ghe.com`, `github.example.com`). |
71
- | `default_scopes` | no | Space-separated default OAuth scopes (default: `read:user user:email`). |
71
+ | `default_scopes` | no | Space-separated default OAuth scopes (default: `read:user user:email repo workflow`). |
72
72
 
73
73
  ## Credentials (identity)
74
74
 
@@ -14,9 +14,9 @@ projects to the right backend.
14
14
 
15
15
  ### Identity
16
16
 
17
- Phase 1 ships the OAuth App flow only; GitHub App installation tokens are
18
- deferred. The access token returned by the OAuth grant is passed straight
19
- to GitHub APIs as a `Bearer` token, so `materialize()` is a no-op.
17
+ Implements the OAuth App flow. The access token returned by the OAuth
18
+ grant is passed straight to GitHub APIs as a `Bearer` token, so
19
+ `materialize()` is a no-op.
20
20
 
21
21
  ### Deployment
22
22
 
@@ -49,7 +49,7 @@ requires admin permission on the target organization.
49
49
  | Option | Required | Description |
50
50
  | ---------------- | --------- | -------------------------------------------------------------------------- |
51
51
  | `host` | GHEC/GHES | Tenant or appliance host (e.g. `tenant.ghe.com`, `github.example.com`). |
52
- | `default_scopes` | no | Space-separated default OAuth scopes (default: `read:user user:email`). |
52
+ | `default_scopes` | no | Space-separated default OAuth scopes (default: `read:user user:email repo workflow`). |
53
53
 
54
54
  ## Credentials (identity)
55
55
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "imbi-plugin-github"
3
- version = "1.3.0"
3
+ version = "1.5.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.7.0",
20
20
  "pydantic>=2",
21
21
  ]
22
22
 
@@ -121,6 +121,8 @@ max-complexity = 15
121
121
 
122
122
  [tool.uv]
123
123
  default-groups = ["dev"]
124
+ exclude-newer = "7 days"
125
+ exclude-newer-package = { "clickhouse-connect" = false, "imbi-common" = false, "imbi-plugin-aws" = false, "imbi-plugin-github" = false, "imbi-plugin-logzio" = false, "imbi-plugin-oidc" = false, "imbi-plugin-pagerduty" = false, "imbi-plugin-sentry" = false, "imbi-plugin-sonarqube" = false }
124
126
 
125
127
  [[tool.uv.index]]
126
128
  url = "https://pypi.org/simple"
@@ -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
@@ -50,6 +51,7 @@ from imbi_common.plugins.base import (
50
51
  RefInfo,
51
52
  ReleaseInfo,
52
53
  RemoteDeployment,
54
+ RepositoryRelocation,
53
55
  WorkflowFile,
54
56
  )
55
57
  from imbi_common.plugins.errors import PluginAuthenticationFailed
@@ -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.RepositoryRelocation` on ``ctx``
360
+ so the host can self-heal the project's 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.repository_relocation 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.repository_relocation`` 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.repository_relocation = RepositoryRelocation(
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 ---------------------------------------------------------------
@@ -580,7 +678,7 @@ class _DeploymentBase(DeploymentPlugin):
580
678
  deletions=deletions,
581
679
  )
582
680
 
583
- # -- Tags / Releases (Phase 2 — implemented here for completeness) -----
681
+ # -- Tags / Releases ----------------------------------------------------
584
682
 
585
683
  async def create_tag(
586
684
  self,
@@ -7,9 +7,8 @@ Three concrete subclasses share one base and differ only by host:
7
7
  * :class:`GitHubEnterpriseServerPlugin` — operator-managed GHES; the
8
8
  hostname comes from a manifest option.
9
9
 
10
- Phase 1 implements the OAuth App flow only. GitHub App installation
11
- tokens (org-scoped automation) are deferred service principals
12
- continue to use the legacy ``ServiceApplication`` model.
10
+ The plugins implement the OAuth App flow: the access token from the
11
+ OAuth grant is passed straight to GitHub APIs as a ``Bearer`` token.
13
12
  """
14
13
 
15
14
  from __future__ import annotations
@@ -20,6 +20,16 @@ On project archive the plugin:
20
20
  3. Archives the repo via ``PATCH /repos/{owner}/{repo}`` with
21
21
  ``{"archived": true}``.
22
22
 
23
+ GitHub's repo transfer is asynchronous: ``POST .../transfer`` returns
24
+ ``202 Accepted`` and the repo is briefly unreachable at the
25
+ destination owner. A PATCH fired immediately after the transfer
26
+ therefore 404s, leaving the repo transferred-but-not-archived (see
27
+ the ``archives`` org incidents on the GHEC tenant). The post-transfer
28
+ archive is retried on 404 with a bounded backoff so the common case
29
+ (transfer settles within a few seconds) succeeds, while a genuinely
30
+ stuck transfer still fails fast enough to stay inside the dispatcher's
31
+ per-plugin timeout and surface to the operator.
32
+
23
33
  On unarchive the plugin only flips ``archived`` back to ``false`` at
24
34
  the repo's current location — it does **not** attempt to transfer
25
35
  the repo back to its original org because the original owner is not
@@ -35,6 +45,7 @@ target organization.
35
45
 
36
46
  from __future__ import annotations
37
47
 
48
+ import asyncio
38
49
  import logging
39
50
  import typing
40
51
 
@@ -46,6 +57,7 @@ from imbi_common.plugins.base import (
46
57
  PluginContext,
47
58
  PluginManifest,
48
59
  PluginOption,
60
+ RepositoryRelocation,
49
61
  )
50
62
  from imbi_common.plugins.errors import PluginAuthenticationFailed
51
63
 
@@ -56,6 +68,13 @@ LOGGER = logging.getLogger(__name__)
56
68
 
57
69
  _HTTP_TIMEOUT_SECONDS = 10.0
58
70
 
71
+ # Backoffs (seconds) between attempts to archive a freshly-transferred
72
+ # repo while GitHub's async transfer settles. len + 1 == total
73
+ # attempts; the sum is kept well under the dispatcher's per-plugin
74
+ # timeout (default 10s) so a stuck transfer fails fast rather than
75
+ # hanging the operator's archive request.
76
+ _TRANSFER_ARCHIVE_BACKOFFS: tuple[float, ...] = (0.5, 1.0, 2.0)
77
+
59
78
 
60
79
  async def _raise_on_401(response: httpx.Response) -> None:
61
80
  """Convert a 401 from GitHub into :class:`PluginAuthenticationFailed`.
@@ -111,10 +130,16 @@ class _LifecycleBase(LifecyclePlugin):
111
130
  def _client(
112
131
  self, ctx: PluginContext, credentials: dict[str, str]
113
132
  ) -> httpx.AsyncClient:
133
+ # follow_redirects so a repo renamed outside Imbi (GitHub answers
134
+ # the stale ``/repos/{owner}/{repo}`` path with a 301 to the by-id
135
+ # form) is followed instead of crashing in ``raise_for_status``.
136
+ # The canonical owner/repo are then adopted from the repo payload;
137
+ # see ``on_project_archived``.
114
138
  return httpx.AsyncClient(
115
139
  timeout=_HTTP_TIMEOUT_SECONDS,
116
140
  headers=_auth_headers(self._token(credentials)),
117
141
  base_url=self._api_base(ctx.assignment_options),
142
+ follow_redirects=True,
118
143
  event_hooks={'response': [_raise_on_401]},
119
144
  )
120
145
 
@@ -136,8 +161,18 @@ class _LifecycleBase(LifecyclePlugin):
136
161
 
137
162
  async with self._client(ctx, credentials) as client:
138
163
  current = await self._get_repo(client, owner, repo)
139
- already_archived = bool(current.get('archived'))
140
164
  current_owner = self._current_owner(current, owner)
165
+ current_repo = self._current_repo(current, repo)
166
+ # If the repo moved out from under the stored link *before*
167
+ # we touched it (an external rename), report it so the host
168
+ # self-heals the link. This is computed before any transfer
169
+ # we initiate below, so an intentional archive-org transfer
170
+ # is never mistaken for an external relocation.
171
+ self._maybe_report_relocation(
172
+ ctx, host, current, owner, repo, current_owner, current_repo
173
+ )
174
+ repo = current_repo
175
+ already_archived = bool(current.get('archived'))
141
176
 
142
177
  needs_transfer = bool(
143
178
  target_org and current_owner.lower() != target_org.lower()
@@ -151,7 +186,9 @@ class _LifecycleBase(LifecyclePlugin):
151
186
  'archived'
152
187
  ),
153
188
  artifacts={
154
- 'repo_url': self._repo_html_url(host, owner, repo),
189
+ 'repo_url': self._repo_html_url(
190
+ host, current_owner, repo
191
+ ),
155
192
  },
156
193
  )
157
194
 
@@ -172,8 +209,11 @@ class _LifecycleBase(LifecyclePlugin):
172
209
  repo = str(transferred.get('name') or repo)
173
210
  owner = target_org or current_owner
174
211
  current_owner = owner
175
-
176
- await self._set_archived(client, current_owner, repo, True)
212
+ # The repo may not be reachable at the destination
213
+ # owner yet; tolerate the transfer-settle 404 window.
214
+ await self._archive_after_transfer(client, current_owner, repo)
215
+ else:
216
+ await self._set_archived(client, current_owner, repo, True)
177
217
 
178
218
  return LifecycleResult(
179
219
  status='ok',
@@ -193,6 +233,11 @@ class _LifecycleBase(LifecyclePlugin):
193
233
  async with self._client(ctx, credentials) as client:
194
234
  current = await self._get_repo(client, owner, repo)
195
235
  current_owner = self._current_owner(current, owner)
236
+ current_repo = self._current_repo(current, repo)
237
+ self._maybe_report_relocation(
238
+ ctx, host, current, owner, repo, current_owner, current_repo
239
+ )
240
+ repo = current_repo
196
241
  if not current.get('archived'):
197
242
  return LifecycleResult(
198
243
  status='skipped',
@@ -225,6 +270,49 @@ class _LifecycleBase(LifecyclePlugin):
225
270
  return login
226
271
  return fallback
227
272
 
273
+ @staticmethod
274
+ def _current_repo(payload: dict[str, typing.Any], fallback: str) -> str:
275
+ """Return the repo name from a repo payload, fallback when absent."""
276
+ name = payload.get('name')
277
+ if isinstance(name, str) and name:
278
+ return name
279
+ return fallback
280
+
281
+ def _maybe_report_relocation(
282
+ self,
283
+ ctx: PluginContext,
284
+ host: str,
285
+ payload: dict[str, typing.Any],
286
+ link_owner: str,
287
+ link_repo: str,
288
+ current_owner: str,
289
+ current_repo: str,
290
+ ) -> None:
291
+ """Record a relocation when the repo moved out from under the link.
292
+
293
+ Compares the link-derived ``<owner>/<repo>`` against the repo's
294
+ canonical name from ``payload``. When they differ the repo was
295
+ renamed (or its owner renamed) outside Imbi, so stash a
296
+ :class:`RepositoryRelocation` on ``ctx`` for the host to self-heal
297
+ the stored link. No-op when they match.
298
+ """
299
+ old_owner_repo = f'{link_owner}/{link_repo}'
300
+ new_owner_repo = f'{current_owner}/{current_repo}'
301
+ if new_owner_repo.lower() == old_owner_repo.lower():
302
+ return
303
+ html_url = payload.get('html_url')
304
+ new_url = (
305
+ html_url
306
+ if isinstance(html_url, str) and html_url
307
+ else self._repo_html_url(host, current_owner, current_repo)
308
+ )
309
+ ctx.repository_relocation = RepositoryRelocation(
310
+ link_key='github-repository',
311
+ new_url=new_url,
312
+ old_owner_repo=old_owner_repo,
313
+ new_owner_repo=new_owner_repo,
314
+ )
315
+
228
316
  @staticmethod
229
317
  def _repo_html_url(host: str, owner: str, repo: str) -> str:
230
318
  return f'https://{host}/{owner}/{repo}'
@@ -249,6 +337,36 @@ class _LifecycleBase(LifecyclePlugin):
249
337
  )
250
338
  resp.raise_for_status()
251
339
 
340
+ async def _archive_after_transfer(
341
+ self,
342
+ client: httpx.AsyncClient,
343
+ owner: str,
344
+ repo: str,
345
+ ) -> None:
346
+ """Archive a freshly-transferred repo, retrying the 404 window.
347
+
348
+ GitHub's transfer is async: the repo is briefly unreachable at
349
+ the destination owner, so the archive PATCH 404s until the
350
+ transfer settles. Retry only on 404 — any other status (auth,
351
+ permissions, validation) is a real failure and re-raises
352
+ immediately.
353
+ """
354
+ for backoff in (*_TRANSFER_ARCHIVE_BACKOFFS, None):
355
+ try:
356
+ await self._set_archived(client, owner, repo, True)
357
+ return
358
+ except httpx.HTTPStatusError as exc:
359
+ if exc.response.status_code != 404 or backoff is None:
360
+ raise
361
+ LOGGER.info(
362
+ 'Repo %s/%s not yet reachable after transfer; '
363
+ 'retrying archive in %ss',
364
+ owner,
365
+ repo,
366
+ backoff,
367
+ )
368
+ await asyncio.sleep(backoff)
369
+
252
370
  async def _transfer(
253
371
  self,
254
372
  client: httpx.AsyncClient,
@@ -17,6 +17,7 @@ from imbi_plugin_github.deployment import (
17
17
  GitHubDeploymentPlugin,
18
18
  GitHubEnterpriseCloudDeploymentPlugin,
19
19
  GitHubEnterpriseServerDeploymentPlugin,
20
+ _repo_root_from_redirect,
20
21
  )
21
22
 
22
23
 
@@ -1300,3 +1301,167 @@ class ListWorkflowsTestCase(unittest.IsolatedAsyncioTestCase):
1300
1301
  )
1301
1302
  plugin = GitHubDeploymentPlugin()
1302
1303
  self.assertEqual(await plugin.list_workflows(_ctx(), _CREDS), [])
1304
+
1305
+
1306
+ class RepoRootFromRedirectTestCase(unittest.TestCase):
1307
+ def test_strips_subresource_to_repo_root(self) -> None:
1308
+ self.assertEqual(
1309
+ _repo_root_from_redirect(
1310
+ 'https://api.github.com/repositories/687046/commits'
1311
+ ),
1312
+ 'https://api.github.com/repositories/687046',
1313
+ )
1314
+
1315
+ def test_bare_repo_id(self) -> None:
1316
+ self.assertEqual(
1317
+ _repo_root_from_redirect(
1318
+ 'https://api.github.com/repositories/687046'
1319
+ ),
1320
+ 'https://api.github.com/repositories/687046',
1321
+ )
1322
+
1323
+ def test_returns_none_without_repositories_segment(self) -> None:
1324
+ self.assertIsNone(
1325
+ _repo_root_from_redirect('https://api.github.com/repos/o/r')
1326
+ )
1327
+
1328
+ def test_returns_none_when_id_missing(self) -> None:
1329
+ self.assertIsNone(
1330
+ _repo_root_from_redirect('https://api.github.com/repositories')
1331
+ )
1332
+
1333
+
1334
+ class RepoRenameRelocationTestCase(unittest.IsolatedAsyncioTestCase):
1335
+ """A repo renamed outside Imbi: GitHub 301s the stale path to the
1336
+ by-id form. The client follows it (request succeeds) and reports the
1337
+ new name on ``ctx`` so the host can self-heal the stored link.
1338
+ """
1339
+
1340
+ @staticmethod
1341
+ def _mock_rename() -> None:
1342
+ # Stale repo-path call 301s to the canonical /repositories/{id}.
1343
+ respx.get('https://api.github.com/repos/octo/demo/commits').mock(
1344
+ return_value=httpx.Response(
1345
+ 301,
1346
+ headers={
1347
+ 'location': (
1348
+ 'https://api.github.com/repositories/123/commits'
1349
+ )
1350
+ },
1351
+ )
1352
+ )
1353
+ respx.get('https://api.github.com/repositories/123/commits').mock(
1354
+ return_value=httpx.Response(
1355
+ 200,
1356
+ json=[
1357
+ {
1358
+ 'sha': 'abc',
1359
+ 'commit': {
1360
+ 'message': 'msg',
1361
+ 'author': {'name': 'X', 'date': None},
1362
+ },
1363
+ }
1364
+ ],
1365
+ )
1366
+ )
1367
+ # Head-commit CI hydration follows the same redirect.
1368
+ respx.get(
1369
+ 'https://api.github.com/repos/octo/demo/commits/abc/check-runs'
1370
+ ).mock(
1371
+ return_value=httpx.Response(
1372
+ 301,
1373
+ headers={
1374
+ 'location': (
1375
+ 'https://api.github.com/repositories/123'
1376
+ '/commits/abc/check-runs'
1377
+ )
1378
+ },
1379
+ )
1380
+ )
1381
+ respx.get(
1382
+ 'https://api.github.com/repositories/123/commits/abc/check-runs'
1383
+ ).mock(return_value=httpx.Response(200, json={'check_runs': []}))
1384
+
1385
+ @respx.mock
1386
+ async def test_list_commits_follows_rename_and_reports_relocation(
1387
+ self,
1388
+ ) -> None:
1389
+ self._mock_rename()
1390
+ respx.get('https://api.github.com/repositories/123').mock(
1391
+ return_value=httpx.Response(
1392
+ 200,
1393
+ json={
1394
+ 'full_name': 'octo/renamed',
1395
+ 'html_url': 'https://github.com/octo/renamed',
1396
+ },
1397
+ )
1398
+ )
1399
+ ctx = _ctx()
1400
+ plugin = GitHubDeploymentPlugin()
1401
+ commits = await plugin.list_commits(ctx, _CREDS, ref='main')
1402
+ # The user-facing request still succeeds via the followed redirect.
1403
+ self.assertEqual(len(commits), 1)
1404
+ self.assertEqual(commits[0].sha, 'abc')
1405
+ # ...and the rename is reported for the host to self-heal.
1406
+ reloc = ctx.repository_relocation
1407
+ assert reloc is not None
1408
+ self.assertEqual(reloc.link_key, 'github-repository')
1409
+ self.assertEqual(reloc.new_url, 'https://github.com/octo/renamed')
1410
+ self.assertEqual(reloc.old_owner_repo, 'octo/demo')
1411
+ self.assertEqual(reloc.new_owner_repo, 'octo/renamed')
1412
+
1413
+ @respx.mock
1414
+ async def test_no_relocation_when_repo_not_renamed(self) -> None:
1415
+ respx.get('https://api.github.com/repos/octo/demo/commits').mock(
1416
+ return_value=httpx.Response(
1417
+ 200,
1418
+ json=[
1419
+ {
1420
+ 'sha': 'abc',
1421
+ 'commit': {
1422
+ 'message': 'msg',
1423
+ 'author': {'name': 'X', 'date': None},
1424
+ },
1425
+ }
1426
+ ],
1427
+ )
1428
+ )
1429
+ respx.get(
1430
+ 'https://api.github.com/repos/octo/demo/commits/abc/check-runs'
1431
+ ).mock(return_value=httpx.Response(200, json={'check_runs': []}))
1432
+ ctx = _ctx()
1433
+ plugin = GitHubDeploymentPlugin()
1434
+ await plugin.list_commits(ctx, _CREDS, ref='main')
1435
+ self.assertIsNone(ctx.repository_relocation)
1436
+
1437
+ @respx.mock
1438
+ async def test_no_relocation_when_repo_root_unresolvable(self) -> None:
1439
+ self._mock_rename()
1440
+ # Repo-root resolution fails -> best-effort, no relocation recorded.
1441
+ respx.get('https://api.github.com/repositories/123').mock(
1442
+ return_value=httpx.Response(404)
1443
+ )
1444
+ ctx = _ctx()
1445
+ plugin = GitHubDeploymentPlugin()
1446
+ commits = await plugin.list_commits(ctx, _CREDS, ref='main')
1447
+ self.assertEqual(len(commits), 1)
1448
+ self.assertIsNone(ctx.repository_relocation)
1449
+
1450
+ @respx.mock
1451
+ async def test_no_relocation_when_name_unchanged(self) -> None:
1452
+ self._mock_rename()
1453
+ # Redirect happened but full_name matches the stored owner/repo
1454
+ # (e.g. a transient by-id redirect) -> nothing to heal.
1455
+ respx.get('https://api.github.com/repositories/123').mock(
1456
+ return_value=httpx.Response(
1457
+ 200,
1458
+ json={
1459
+ 'full_name': 'octo/demo',
1460
+ 'html_url': 'https://github.com/octo/demo',
1461
+ },
1462
+ )
1463
+ )
1464
+ ctx = _ctx()
1465
+ plugin = GitHubDeploymentPlugin()
1466
+ await plugin.list_commits(ctx, _CREDS, ref='main')
1467
+ self.assertIsNone(ctx.repository_relocation)
@@ -8,6 +8,7 @@ from absent links, and the 401 -> PluginAuthenticationFailed path.
8
8
  """
9
9
 
10
10
  import unittest
11
+ import unittest.mock
11
12
 
12
13
  import httpx
13
14
  import respx
@@ -179,6 +180,127 @@ class ArchiveTestCase(unittest.IsolatedAsyncioTestCase):
179
180
  'https://github.com/octo-archive/demo',
180
181
  )
181
182
 
183
+ @respx.mock
184
+ async def test_archive_transfer_retries_settle_404(self) -> None:
185
+ # GitHub's transfer is async (202); the repo is briefly
186
+ # unreachable at the destination, so the first archive PATCH
187
+ # 404s. The plugin must retry and succeed once it settles.
188
+ respx.get('https://api.github.com/repos/octo/demo').mock(
189
+ return_value=httpx.Response(
190
+ 200,
191
+ json={
192
+ 'archived': False,
193
+ 'owner': {'login': 'octo'},
194
+ 'name': 'demo',
195
+ },
196
+ )
197
+ )
198
+ respx.post('https://api.github.com/repos/octo/demo/transfer').mock(
199
+ return_value=httpx.Response(
200
+ 202,
201
+ json={'name': 'demo', 'owner': {'login': 'octo-archive'}},
202
+ )
203
+ )
204
+ archive_route = respx.patch(
205
+ 'https://api.github.com/repos/octo-archive/demo'
206
+ ).mock(
207
+ side_effect=[
208
+ httpx.Response(404, json={'message': 'Not Found'}),
209
+ httpx.Response(404, json={'message': 'Not Found'}),
210
+ httpx.Response(200, json={}),
211
+ ]
212
+ )
213
+
214
+ plugin = GitHubLifecyclePlugin()
215
+ with unittest.mock.patch(
216
+ 'imbi_plugin_github.lifecycle.asyncio.sleep'
217
+ ) as sleep:
218
+ result = await plugin.on_project_archived(
219
+ _ctx(options={'archive_target_org': 'octo-archive'}),
220
+ _CREDS,
221
+ )
222
+
223
+ self.assertEqual(result.status, 'ok')
224
+ self.assertEqual(archive_route.calls.call_count, 3)
225
+ # Two failed attempts → two backoff sleeps before success.
226
+ self.assertEqual(sleep.await_count, 2)
227
+
228
+ @respx.mock
229
+ async def test_archive_transfer_exhausts_retries_on_persistent_404(
230
+ self,
231
+ ) -> None:
232
+ # If the transfer never settles within the retry budget the
233
+ # final 404 must propagate so the dispatcher records a failure.
234
+ respx.get('https://api.github.com/repos/octo/demo').mock(
235
+ return_value=httpx.Response(
236
+ 200,
237
+ json={
238
+ 'archived': False,
239
+ 'owner': {'login': 'octo'},
240
+ 'name': 'demo',
241
+ },
242
+ )
243
+ )
244
+ respx.post('https://api.github.com/repos/octo/demo/transfer').mock(
245
+ return_value=httpx.Response(
246
+ 202,
247
+ json={'name': 'demo', 'owner': {'login': 'octo-archive'}},
248
+ )
249
+ )
250
+ archive_route = respx.patch(
251
+ 'https://api.github.com/repos/octo-archive/demo'
252
+ ).mock(return_value=httpx.Response(404, json={'message': 'Not Found'}))
253
+
254
+ plugin = GitHubLifecyclePlugin()
255
+ with unittest.mock.patch(
256
+ 'imbi_plugin_github.lifecycle.asyncio.sleep'
257
+ ) as sleep:
258
+ with self.assertRaises(httpx.HTTPStatusError):
259
+ await plugin.on_project_archived(
260
+ _ctx(options={'archive_target_org': 'octo-archive'}),
261
+ _CREDS,
262
+ )
263
+
264
+ # Three backoffs configured → four total attempts.
265
+ self.assertEqual(archive_route.calls.call_count, 4)
266
+ self.assertEqual(sleep.await_count, 3)
267
+
268
+ @respx.mock
269
+ async def test_archive_transfer_does_not_retry_non_404(self) -> None:
270
+ # A non-404 (e.g. permissions) is a real failure: raise at once.
271
+ respx.get('https://api.github.com/repos/octo/demo').mock(
272
+ return_value=httpx.Response(
273
+ 200,
274
+ json={
275
+ 'archived': False,
276
+ 'owner': {'login': 'octo'},
277
+ 'name': 'demo',
278
+ },
279
+ )
280
+ )
281
+ respx.post('https://api.github.com/repos/octo/demo/transfer').mock(
282
+ return_value=httpx.Response(
283
+ 202,
284
+ json={'name': 'demo', 'owner': {'login': 'octo-archive'}},
285
+ )
286
+ )
287
+ archive_route = respx.patch(
288
+ 'https://api.github.com/repos/octo-archive/demo'
289
+ ).mock(return_value=httpx.Response(403, json={'message': 'Forbidden'}))
290
+
291
+ plugin = GitHubLifecyclePlugin()
292
+ with unittest.mock.patch(
293
+ 'imbi_plugin_github.lifecycle.asyncio.sleep'
294
+ ) as sleep:
295
+ with self.assertRaises(httpx.HTTPStatusError):
296
+ await plugin.on_project_archived(
297
+ _ctx(options={'archive_target_org': 'octo-archive'}),
298
+ _CREDS,
299
+ )
300
+
301
+ self.assertEqual(archive_route.calls.call_count, 1)
302
+ self.assertEqual(sleep.await_count, 0)
303
+
182
304
  @respx.mock
183
305
  async def test_archive_transfer_when_already_archived(self) -> None:
184
306
  # GitHub forbids transferring archived repos, so the plugin must
@@ -435,3 +557,179 @@ class GhecApiBaseTestCase(unittest.IsolatedAsyncioTestCase):
435
557
  result.artifacts['repo_url'],
436
558
  'https://tenant.ghe.com/octo/demo',
437
559
  )
560
+
561
+
562
+ class RenameRelocationTestCase(unittest.IsolatedAsyncioTestCase):
563
+ """A repo renamed outside Imbi: the stale path 301s to the by-id form,
564
+ the client follows it, and the canonical owner/repo are adopted and
565
+ reported on ``ctx`` for the host to self-heal the link.
566
+ """
567
+
568
+ @respx.mock
569
+ async def test_archive_follows_rename_and_reports_relocation(
570
+ self,
571
+ ) -> None:
572
+ # Stale path 301s to the canonical /repositories/{id}; the repo's
573
+ # current name is ``renamed``.
574
+ respx.get('https://api.github.com/repos/octo/demo').mock(
575
+ return_value=httpx.Response(
576
+ 301,
577
+ headers={
578
+ 'location': 'https://api.github.com/repositories/123'
579
+ },
580
+ )
581
+ )
582
+ respx.get('https://api.github.com/repositories/123').mock(
583
+ return_value=httpx.Response(
584
+ 200,
585
+ json={
586
+ 'archived': False,
587
+ 'owner': {'login': 'octo'},
588
+ 'name': 'renamed',
589
+ 'html_url': 'https://github.com/octo/renamed',
590
+ },
591
+ )
592
+ )
593
+ # Archive targets the canonical name we adopted from the payload.
594
+ patch_route = respx.patch(
595
+ 'https://api.github.com/repos/octo/renamed'
596
+ ).mock(return_value=httpx.Response(200, json={}))
597
+
598
+ ctx = _ctx()
599
+ plugin = GitHubLifecyclePlugin()
600
+ result = await plugin.on_project_archived(ctx, _CREDS)
601
+
602
+ self.assertEqual(result.status, 'ok')
603
+ self.assertEqual(patch_route.calls.call_count, 1)
604
+ self.assertEqual(
605
+ result.artifacts['repo_url'], 'https://github.com/octo/renamed'
606
+ )
607
+ reloc = ctx.repository_relocation
608
+ assert reloc is not None
609
+ self.assertEqual(reloc.link_key, 'github-repository')
610
+ self.assertEqual(reloc.new_url, 'https://github.com/octo/renamed')
611
+ self.assertEqual(reloc.old_owner_repo, 'octo/demo')
612
+ self.assertEqual(reloc.new_owner_repo, 'octo/renamed')
613
+
614
+ @respx.mock
615
+ async def test_unarchive_follows_rename_and_reports_relocation(
616
+ self,
617
+ ) -> None:
618
+ respx.get('https://api.github.com/repos/octo/demo').mock(
619
+ return_value=httpx.Response(
620
+ 301,
621
+ headers={
622
+ 'location': 'https://api.github.com/repositories/123'
623
+ },
624
+ )
625
+ )
626
+ respx.get('https://api.github.com/repositories/123').mock(
627
+ return_value=httpx.Response(
628
+ 200,
629
+ json={
630
+ 'archived': True,
631
+ 'owner': {'login': 'octo'},
632
+ 'name': 'renamed',
633
+ 'html_url': 'https://github.com/octo/renamed',
634
+ },
635
+ )
636
+ )
637
+ patch_route = respx.patch(
638
+ 'https://api.github.com/repos/octo/renamed'
639
+ ).mock(return_value=httpx.Response(200, json={}))
640
+
641
+ ctx = _ctx()
642
+ plugin = GitHubLifecyclePlugin()
643
+ result = await plugin.on_project_unarchived(ctx, _CREDS)
644
+
645
+ self.assertEqual(result.status, 'ok')
646
+ self.assertEqual(patch_route.calls.call_count, 1)
647
+ reloc = ctx.repository_relocation
648
+ assert reloc is not None
649
+ self.assertEqual(reloc.new_owner_repo, 'octo/renamed')
650
+
651
+ @respx.mock
652
+ async def test_archive_skip_uses_canonical_owner_in_artifact(
653
+ self,
654
+ ) -> None:
655
+ # External rename moved the repo to a new owner *and* it is already
656
+ # archived, so we hit the skip path. The artifact URL must reflect
657
+ # the canonical owner/repo, not the stale link-derived owner.
658
+ respx.get('https://api.github.com/repos/octo/demo').mock(
659
+ return_value=httpx.Response(
660
+ 301,
661
+ headers={
662
+ 'location': 'https://api.github.com/repositories/123'
663
+ },
664
+ )
665
+ )
666
+ respx.get('https://api.github.com/repositories/123').mock(
667
+ return_value=httpx.Response(
668
+ 200,
669
+ json={
670
+ 'archived': True,
671
+ 'owner': {'login': 'octo-new'},
672
+ 'name': 'renamed',
673
+ 'html_url': 'https://github.com/octo-new/renamed',
674
+ },
675
+ )
676
+ )
677
+
678
+ ctx = _ctx()
679
+ plugin = GitHubLifecyclePlugin()
680
+ result = await plugin.on_project_archived(ctx, _CREDS)
681
+
682
+ self.assertEqual(result.status, 'skipped')
683
+ self.assertEqual(
684
+ result.artifacts['repo_url'],
685
+ 'https://github.com/octo-new/renamed',
686
+ )
687
+
688
+ @respx.mock
689
+ async def test_no_relocation_when_not_renamed(self) -> None:
690
+ respx.get('https://api.github.com/repos/octo/demo').mock(
691
+ return_value=httpx.Response(
692
+ 200,
693
+ json={
694
+ 'archived': False,
695
+ 'owner': {'login': 'octo'},
696
+ 'name': 'demo',
697
+ },
698
+ )
699
+ )
700
+ respx.patch('https://api.github.com/repos/octo/demo').mock(
701
+ return_value=httpx.Response(200, json={})
702
+ )
703
+ ctx = _ctx()
704
+ plugin = GitHubLifecyclePlugin()
705
+ await plugin.on_project_archived(ctx, _CREDS)
706
+ self.assertIsNone(ctx.repository_relocation)
707
+
708
+ @respx.mock
709
+ async def test_intentional_transfer_is_not_reported_as_relocation(
710
+ self,
711
+ ) -> None:
712
+ # Repo is found at its stored location (no external rename), then
713
+ # we transfer it to the archive org. That intentional move must
714
+ # NOT be reported as a relocation.
715
+ respx.get('https://api.github.com/repos/octo/demo').mock(
716
+ return_value=httpx.Response(
717
+ 200,
718
+ json={
719
+ 'archived': False,
720
+ 'owner': {'login': 'octo'},
721
+ 'name': 'demo',
722
+ },
723
+ )
724
+ )
725
+ respx.post('https://api.github.com/repos/octo/demo/transfer').mock(
726
+ return_value=httpx.Response(202, json={'name': 'demo'})
727
+ )
728
+ respx.patch('https://api.github.com/repos/archives/demo').mock(
729
+ return_value=httpx.Response(200, json={})
730
+ )
731
+ ctx = _ctx(options={'archive_target_org': 'archives'})
732
+ plugin = GitHubLifecyclePlugin()
733
+ result = await plugin.on_project_archived(ctx, _CREDS)
734
+ self.assertEqual(result.status, 'ok')
735
+ self.assertIsNone(ctx.repository_relocation)
@@ -2,6 +2,21 @@ version = 1
2
2
  revision = 3
3
3
  requires-python = ">=3.14"
4
4
 
5
+ [options]
6
+ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
7
+ exclude-newer-span = "P7D"
8
+
9
+ [options.exclude-newer-package]
10
+ imbi-common = false
11
+ imbi-plugin-pagerduty = false
12
+ imbi-plugin-github = false
13
+ imbi-plugin-logzio = false
14
+ imbi-plugin-aws = false
15
+ imbi-plugin-sonarqube = false
16
+ clickhouse-connect = false
17
+ imbi-plugin-sentry = false
18
+ imbi-plugin-oidc = false
19
+
5
20
  [[package]]
6
21
  name = "aiohappyeyeballs"
7
22
  version = "2.6.1"
@@ -652,7 +667,7 @@ wheels = [
652
667
 
653
668
  [[package]]
654
669
  name = "imbi-common"
655
- version = "2.5.5"
670
+ version = "2.7.0"
656
671
  source = { registry = "https://pypi.org/simple" }
657
672
  dependencies = [
658
673
  { name = "cryptography" },
@@ -668,9 +683,9 @@ dependencies = [
668
683
  { name = "python-slugify" },
669
684
  { name = "typer" },
670
685
  ]
671
- sdist = { url = "https://files.pythonhosted.org/packages/0d/b7/6dab7155b281f9c075d3434e3f234ef217fe49db840b14e59e3e5a75faa2/imbi_common-2.5.5.tar.gz", hash = "sha256:56751efe31fdc1fcb1d1be19e6b3f073d5b3e4fda3c89c0bcdec5c7943549477", size = 277481, upload-time = "2026-05-20T02:10:09.044Z" }
686
+ sdist = { url = "https://files.pythonhosted.org/packages/4b/b0/5f28ac0670d239c885822e5a7c9a94b2bb9c1ea205dccba82ac2ae36f329/imbi_common-2.7.0.tar.gz", hash = "sha256:723d2a7ecffef3f0cf86828173586a9721931b95068718c7b0c7cff768c7f764", size = 303583, upload-time = "2026-05-28T12:56:17.525Z" }
672
687
  wheels = [
673
- { url = "https://files.pythonhosted.org/packages/48/04/69e72ec3a1968a9dd8b91df38e6a7be16e1399a054a953777a23f34c60ca/imbi_common-2.5.5-py3-none-any.whl", hash = "sha256:19ffa23d9cdb0b8065af1d3a9166f819c15414fc4b5707d089adaf64b932ebf1", size = 91154, upload-time = "2026-05-20T02:10:07.448Z" },
688
+ { url = "https://files.pythonhosted.org/packages/ce/33/e801c0e9d7ecfef434ca1f178291584f4dce1e55f34590c9425c2086c4f6/imbi_common-2.7.0-py3-none-any.whl", hash = "sha256:c475153119376753d5f09b18de686eb872464625fdaf073aa893fec38b3956ab", size = 98903, upload-time = "2026-05-28T12:56:16.147Z" },
674
689
  ]
675
690
 
676
691
  [package.optional-dependencies]
@@ -688,7 +703,7 @@ server = [
688
703
 
689
704
  [[package]]
690
705
  name = "imbi-plugin-github"
691
- version = "1.2.0"
706
+ version = "1.5.0"
692
707
  source = { editable = "." }
693
708
  dependencies = [
694
709
  { name = "httpx" },
@@ -716,7 +731,7 @@ dist = [
716
731
  [package.metadata]
717
732
  requires-dist = [
718
733
  { name = "httpx", specifier = ">=0.27" },
719
- { name = "imbi-common", specifier = ">=2.5.5" },
734
+ { name = "imbi-common", specifier = ">=2.7.0" },
720
735
  { name = "pydantic", specifier = ">=2" },
721
736
  ]
722
737