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.
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/PKG-INFO +2 -2
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/pyproject.toml +2 -2
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/deployment.py +102 -4
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/lifecycle.py +69 -2
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/tests/test_deployment.py +165 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/tests/test_lifecycle.py +176 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/uv.lock +5 -5
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/.github/workflows/publish.yml +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/.github/workflows/test.yml +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/.gitignore +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/.pre-commit-config.yaml +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/CLAUDE.md +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/LICENSE +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/README.md +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/justfile +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/__init__.py +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/_hosts.py +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/_repos.py +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/src/imbi_plugin_github/identity.py +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-1.4.0 → imbi_plugin_github-1.5.0}/tests/test_hosts.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.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
|
-
|
|
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(
|
|
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.
|
|
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/
|
|
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/
|
|
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.
|
|
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.
|
|
734
|
+
{ name = "imbi-common", specifier = ">=2.7.0" },
|
|
735
735
|
{ name = "pydantic", specifier = ">=2" },
|
|
736
736
|
]
|
|
737
737
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|