imbi-plugin-github 1.4.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.4.0 → imbi_plugin_github-1.5.0}/PKG-INFO +2 -2
  2. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/pyproject.toml +2 -2
  3. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/deployment.py +102 -4
  4. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/lifecycle.py +69 -2
  5. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/tests/test_deployment.py +165 -0
  6. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/tests/test_lifecycle.py +176 -0
  7. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/uv.lock +5 -5
  8. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/.github/workflows/publish.yml +0 -0
  9. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/.github/workflows/test.yml +0 -0
  10. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/.gitignore +0 -0
  11. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/.pre-commit-config.yaml +0 -0
  12. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/CLAUDE.md +0 -0
  13. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/LICENSE +0 -0
  14. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/README.md +0 -0
  15. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/justfile +0 -0
  16. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/__init__.py +0 -0
  17. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/_hosts.py +0 -0
  18. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/_repos.py +0 -0
  19. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/identity.py +0 -0
  20. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/tests/__init__.py +0 -0
  21. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/tests/test_hosts.py +0 -0
  22. {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.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: 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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "imbi-plugin-github"
3
- version = "1.4.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
 
@@ -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 ---------------------------------------------------------------
@@ -57,6 +57,7 @@ from imbi_common.plugins.base import (
57
57
  PluginContext,
58
58
  PluginManifest,
59
59
  PluginOption,
60
+ RepositoryRelocation,
60
61
  )
61
62
  from imbi_common.plugins.errors import PluginAuthenticationFailed
62
63
 
@@ -129,10 +130,16 @@ class _LifecycleBase(LifecyclePlugin):
129
130
  def _client(
130
131
  self, ctx: PluginContext, credentials: dict[str, str]
131
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``.
132
138
  return httpx.AsyncClient(
133
139
  timeout=_HTTP_TIMEOUT_SECONDS,
134
140
  headers=_auth_headers(self._token(credentials)),
135
141
  base_url=self._api_base(ctx.assignment_options),
142
+ follow_redirects=True,
136
143
  event_hooks={'response': [_raise_on_401]},
137
144
  )
138
145
 
@@ -154,8 +161,18 @@ class _LifecycleBase(LifecyclePlugin):
154
161
 
155
162
  async with self._client(ctx, credentials) as client:
156
163
  current = await self._get_repo(client, owner, repo)
157
- already_archived = bool(current.get('archived'))
158
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'))
159
176
 
160
177
  needs_transfer = bool(
161
178
  target_org and current_owner.lower() != target_org.lower()
@@ -169,7 +186,9 @@ class _LifecycleBase(LifecyclePlugin):
169
186
  'archived'
170
187
  ),
171
188
  artifacts={
172
- 'repo_url': self._repo_html_url(host, owner, repo),
189
+ 'repo_url': self._repo_html_url(
190
+ host, current_owner, repo
191
+ ),
173
192
  },
174
193
  )
175
194
 
@@ -214,6 +233,11 @@ class _LifecycleBase(LifecyclePlugin):
214
233
  async with self._client(ctx, credentials) as client:
215
234
  current = await self._get_repo(client, owner, repo)
216
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
217
241
  if not current.get('archived'):
218
242
  return LifecycleResult(
219
243
  status='skipped',
@@ -246,6 +270,49 @@ class _LifecycleBase(LifecyclePlugin):
246
270
  return login
247
271
  return fallback
248
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
+
249
316
  @staticmethod
250
317
  def _repo_html_url(host: str, owner: str, repo: str) -> str:
251
318
  return f'https://{host}/{owner}/{repo}'
@@ -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)
@@ -557,3 +557,179 @@ class GhecApiBaseTestCase(unittest.IsolatedAsyncioTestCase):
557
557
  result.artifacts['repo_url'],
558
558
  'https://tenant.ghe.com/octo/demo',
559
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)
@@ -667,7 +667,7 @@ wheels = [
667
667
 
668
668
  [[package]]
669
669
  name = "imbi-common"
670
- version = "2.5.5"
670
+ version = "2.7.0"
671
671
  source = { registry = "https://pypi.org/simple" }
672
672
  dependencies = [
673
673
  { name = "cryptography" },
@@ -683,9 +683,9 @@ dependencies = [
683
683
  { name = "python-slugify" },
684
684
  { name = "typer" },
685
685
  ]
686
- 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" }
687
687
  wheels = [
688
- { 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" },
689
689
  ]
690
690
 
691
691
  [package.optional-dependencies]
@@ -703,7 +703,7 @@ server = [
703
703
 
704
704
  [[package]]
705
705
  name = "imbi-plugin-github"
706
- version = "1.4.0"
706
+ version = "1.5.0"
707
707
  source = { editable = "." }
708
708
  dependencies = [
709
709
  { name = "httpx" },
@@ -731,7 +731,7 @@ dist = [
731
731
  [package.metadata]
732
732
  requires-dist = [
733
733
  { name = "httpx", specifier = ">=0.27" },
734
- { name = "imbi-common", specifier = ">=2.5.5" },
734
+ { name = "imbi-common", specifier = ">=2.7.0" },
735
735
  { name = "pydantic", specifier = ">=2" },
736
736
  ]
737
737