imbi-plugin-github 1.5.0__tar.gz → 2.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/PKG-INFO +2 -2
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/pyproject.toml +3 -3
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/_repos.py +16 -3
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/deployment.py +6 -6
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/lifecycle.py +366 -17
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/tests/test_deployment.py +4 -4
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/tests/test_lifecycle.py +429 -5
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/uv.lock +6 -6
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/.github/workflows/publish.yml +0 -0
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/.github/workflows/test.yml +0 -0
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/.gitignore +0 -0
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/.pre-commit-config.yaml +0 -0
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/CLAUDE.md +0 -0
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/LICENSE +0 -0
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/README.md +0 -0
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/justfile +0 -0
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/__init__.py +0 -0
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/_hosts.py +0 -0
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/identity.py +0 -0
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/tests/test_identity.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: imbi-plugin-github
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.8.0
|
|
4
4
|
Summary: GitHub identity plugin for Imbi (github.com / GHEC / GHES)
|
|
5
5
|
Author-email: "Gavin M. Roy" <gavinr@aweber.com>
|
|
6
6
|
License: BSD-3-Clause
|
|
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.14
|
|
14
14
|
Requires-Python: >=3.14
|
|
15
15
|
Requires-Dist: httpx>=0.27
|
|
16
|
-
Requires-Dist: imbi-common
|
|
16
|
+
Requires-Dist: imbi-common==2.8.0
|
|
17
17
|
Requires-Dist: pydantic>=2
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "imbi-plugin-github"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2.8.0"
|
|
4
4
|
description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -16,7 +16,7 @@ classifiers = [
|
|
|
16
16
|
]
|
|
17
17
|
dependencies = [
|
|
18
18
|
"httpx>=0.27",
|
|
19
|
-
"imbi-common
|
|
19
|
+
"imbi-common==2.8.0",
|
|
20
20
|
"pydantic>=2",
|
|
21
21
|
]
|
|
22
22
|
|
|
@@ -35,7 +35,7 @@ github-lifecycle-es = "imbi_plugin_github.lifecycle:GitHubEnterpriseServerLifecy
|
|
|
35
35
|
dev = [
|
|
36
36
|
"basedpyright",
|
|
37
37
|
"coverage[toml]",
|
|
38
|
-
"imbi-common[server,databases]",
|
|
38
|
+
"imbi-common[server,databases]==2.8.0",
|
|
39
39
|
"pre-commit",
|
|
40
40
|
"pytest",
|
|
41
41
|
"pytest-asyncio",
|
|
@@ -91,7 +91,11 @@ def derive_owner_repo_from_links(
|
|
|
91
91
|
|
|
92
92
|
|
|
93
93
|
def resolve_owner_repo(
|
|
94
|
-
ctx: PluginContext,
|
|
94
|
+
ctx: PluginContext,
|
|
95
|
+
host: str,
|
|
96
|
+
plugin_label: str,
|
|
97
|
+
*,
|
|
98
|
+
prefer_previous_slug: bool = False,
|
|
95
99
|
) -> tuple[str, str]:
|
|
96
100
|
"""Resolve the repo for a plugin call from ``ctx``.
|
|
97
101
|
|
|
@@ -100,12 +104,21 @@ def resolve_owner_repo(
|
|
|
100
104
|
:class:`ValueError` when neither path produces a candidate so the
|
|
101
105
|
caller can surface a clear "set the GitHub Repository link"
|
|
102
106
|
message to the operator.
|
|
107
|
+
|
|
108
|
+
``prefer_previous_slug`` makes the slug fallback prefer
|
|
109
|
+
``ctx.previous_project_slug`` (when set) over ``ctx.project_slug``
|
|
110
|
+
so callers reacting to a slug rename (e.g. ``on_project_updated``)
|
|
111
|
+
can still locate the pre-rename repo on GitHub.
|
|
103
112
|
"""
|
|
104
113
|
derived = derive_owner_repo_from_links(ctx.project_links, host)
|
|
105
114
|
if derived is not None:
|
|
106
115
|
return derived
|
|
107
|
-
if ctx.project_type_slugs
|
|
108
|
-
|
|
116
|
+
if ctx.project_type_slugs:
|
|
117
|
+
slug = ctx.project_slug
|
|
118
|
+
if prefer_previous_slug and ctx.previous_project_slug:
|
|
119
|
+
slug = ctx.previous_project_slug
|
|
120
|
+
if slug:
|
|
121
|
+
return ctx.project_type_slugs[0], slug
|
|
109
122
|
raise ValueError(
|
|
110
123
|
f'{plugin_label} could not determine the target repository: '
|
|
111
124
|
"set the project's GitHub Repository link or tag the project "
|
|
@@ -42,6 +42,7 @@ from imbi_common.plugins.base import (
|
|
|
42
42
|
DeploymentEventStatus,
|
|
43
43
|
DeploymentPlugin,
|
|
44
44
|
DeploymentRun,
|
|
45
|
+
LinkWriteback,
|
|
45
46
|
OpsLogTemplate,
|
|
46
47
|
PluginContext,
|
|
47
48
|
PluginEdgeLabel,
|
|
@@ -51,7 +52,6 @@ from imbi_common.plugins.base import (
|
|
|
51
52
|
RefInfo,
|
|
52
53
|
ReleaseInfo,
|
|
53
54
|
RemoteDeployment,
|
|
54
|
-
RepositoryRelocation,
|
|
55
55
|
WorkflowFile,
|
|
56
56
|
)
|
|
57
57
|
from imbi_common.plugins.errors import PluginAuthenticationFailed
|
|
@@ -356,8 +356,8 @@ class _DeploymentBase(DeploymentPlugin):
|
|
|
356
356
|
``raise_for_status``. A response hook records that redirect; once
|
|
357
357
|
the caller's request succeeds we resolve the repo's new
|
|
358
358
|
``full_name``/``html_url`` and stash a
|
|
359
|
-
:class:`~imbi_common.plugins.base.
|
|
360
|
-
|
|
359
|
+
:class:`~imbi_common.plugins.base.LinkWriteback` on ``ctx`` so the
|
|
360
|
+
host can persist the project's updated stored link. This is the
|
|
361
361
|
single chokepoint for every deployment call.
|
|
362
362
|
"""
|
|
363
363
|
captured: list[str] = []
|
|
@@ -380,7 +380,7 @@ class _DeploymentBase(DeploymentPlugin):
|
|
|
380
380
|
# Only after the caller's request succeeded: a captured
|
|
381
381
|
# redirect means the repo was renamed out from under the
|
|
382
382
|
# stored link. Resolve the new name once and report it.
|
|
383
|
-
if captured and ctx.
|
|
383
|
+
if captured and ctx.link_writeback is None:
|
|
384
384
|
await self._record_relocation(client, ctx, captured[-1])
|
|
385
385
|
|
|
386
386
|
async def _record_relocation(
|
|
@@ -392,7 +392,7 @@ class _DeploymentBase(DeploymentPlugin):
|
|
|
392
392
|
"""Resolve a renamed repo's canonical name and stash it on ``ctx``.
|
|
393
393
|
|
|
394
394
|
Best-effort: any failure to resolve the repo root leaves
|
|
395
|
-
``ctx.
|
|
395
|
+
``ctx.link_writeback`` unset so the user-facing call (which
|
|
396
396
|
already succeeded via the followed redirect) is never disturbed.
|
|
397
397
|
"""
|
|
398
398
|
repo_root = _repo_root_from_redirect(redirect_location)
|
|
@@ -419,7 +419,7 @@ class _DeploymentBase(DeploymentPlugin):
|
|
|
419
419
|
old_owner_repo = f'{old_owner}/{old_repo}'
|
|
420
420
|
if full_name.lower() == old_owner_repo.lower():
|
|
421
421
|
return
|
|
422
|
-
ctx.
|
|
422
|
+
ctx.link_writeback = LinkWriteback(
|
|
423
423
|
link_key='github-repository',
|
|
424
424
|
new_url=html_url,
|
|
425
425
|
old_owner_repo=old_owner_repo,
|
|
@@ -54,15 +54,20 @@ from imbi_common.plugins.base import (
|
|
|
54
54
|
CredentialField,
|
|
55
55
|
LifecyclePlugin,
|
|
56
56
|
LifecycleResult,
|
|
57
|
+
LinkWriteback,
|
|
57
58
|
PluginContext,
|
|
58
59
|
PluginManifest,
|
|
59
60
|
PluginOption,
|
|
60
|
-
|
|
61
|
+
RelocationTarget,
|
|
61
62
|
)
|
|
62
63
|
from imbi_common.plugins.errors import PluginAuthenticationFailed
|
|
64
|
+
from imbi_common.plugins.templates import expand_template
|
|
63
65
|
|
|
64
66
|
from imbi_plugin_github._hosts import normalize_host, require_ghec_tenant_host
|
|
65
|
-
from imbi_plugin_github._repos import
|
|
67
|
+
from imbi_plugin_github._repos import (
|
|
68
|
+
derive_owner_repo_from_links,
|
|
69
|
+
resolve_owner_repo,
|
|
70
|
+
)
|
|
66
71
|
|
|
67
72
|
LOGGER = logging.getLogger(__name__)
|
|
68
73
|
|
|
@@ -150,6 +155,249 @@ class _LifecycleBase(LifecyclePlugin):
|
|
|
150
155
|
return raw.strip()
|
|
151
156
|
return None
|
|
152
157
|
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _resolve_create_org(ctx: PluginContext) -> str | None:
|
|
160
|
+
"""Resolve the GitHub org for create / relocate from plugin options.
|
|
161
|
+
|
|
162
|
+
Checks ``org_mapping`` (project-type-slug → org) first so per-type
|
|
163
|
+
overrides win, then falls back to the ``create_org`` template.
|
|
164
|
+
Returns ``None`` when neither is configured so the caller can
|
|
165
|
+
emit a clean ``status='skipped'``.
|
|
166
|
+
"""
|
|
167
|
+
options = ctx.assignment_options
|
|
168
|
+
mapping_raw = options.get('org_mapping')
|
|
169
|
+
if isinstance(mapping_raw, dict):
|
|
170
|
+
mapping = typing.cast(dict[str, typing.Any], mapping_raw)
|
|
171
|
+
for pt_slug in ctx.project_type_slugs:
|
|
172
|
+
if pt_slug in mapping:
|
|
173
|
+
value = mapping[pt_slug]
|
|
174
|
+
if isinstance(value, str) and value.strip():
|
|
175
|
+
return value.strip()
|
|
176
|
+
template = options.get('create_org')
|
|
177
|
+
if isinstance(template, str) and template.strip():
|
|
178
|
+
first_type_slug = (
|
|
179
|
+
ctx.project_type_slugs[0] if ctx.project_type_slugs else None
|
|
180
|
+
)
|
|
181
|
+
expanded = expand_template(
|
|
182
|
+
template,
|
|
183
|
+
{
|
|
184
|
+
'project_slug': ctx.project_slug,
|
|
185
|
+
'org_slug': ctx.org_slug,
|
|
186
|
+
'team_slug': ctx.team_slug,
|
|
187
|
+
'project_type_slug': first_type_slug,
|
|
188
|
+
'project_id': ctx.project_id,
|
|
189
|
+
},
|
|
190
|
+
).strip()
|
|
191
|
+
return expanded or None
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
async def on_project_created(
|
|
195
|
+
self,
|
|
196
|
+
ctx: PluginContext,
|
|
197
|
+
credentials: dict[str, str],
|
|
198
|
+
) -> LifecycleResult:
|
|
199
|
+
target_org = self._resolve_create_org(ctx)
|
|
200
|
+
if not target_org:
|
|
201
|
+
return LifecycleResult(
|
|
202
|
+
status='skipped',
|
|
203
|
+
message=(
|
|
204
|
+
'No target org configured for project creation; set '
|
|
205
|
+
"the plugin's ``create_org`` or ``org_mapping`` option"
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
host = self._resolve_host(ctx.assignment_options)
|
|
209
|
+
async with self._client(ctx, credentials) as client:
|
|
210
|
+
existing = await self._get_repo_or_none(
|
|
211
|
+
client, target_org, ctx.project_slug
|
|
212
|
+
)
|
|
213
|
+
if existing is not None:
|
|
214
|
+
# Already provisioned (e.g. retry after a partial failure):
|
|
215
|
+
# adopt the existing repo's URL via a link writeback so the
|
|
216
|
+
# operator can wire it up without a second attempt.
|
|
217
|
+
html_url = str(
|
|
218
|
+
existing.get('html_url')
|
|
219
|
+
or self._repo_html_url(host, target_org, ctx.project_slug)
|
|
220
|
+
)
|
|
221
|
+
ctx.link_writeback = LinkWriteback(
|
|
222
|
+
link_key='github-repository',
|
|
223
|
+
new_url=html_url,
|
|
224
|
+
)
|
|
225
|
+
return LifecycleResult(
|
|
226
|
+
status='skipped',
|
|
227
|
+
message=(
|
|
228
|
+
f'Repository {target_org}/{ctx.project_slug} '
|
|
229
|
+
'already exists'
|
|
230
|
+
),
|
|
231
|
+
artifacts={'repo_url': html_url},
|
|
232
|
+
)
|
|
233
|
+
created = await self._create_repo(client, target_org, ctx)
|
|
234
|
+
html_url = str(
|
|
235
|
+
created.get('html_url')
|
|
236
|
+
or self._repo_html_url(host, target_org, ctx.project_slug)
|
|
237
|
+
)
|
|
238
|
+
ctx.link_writeback = LinkWriteback(
|
|
239
|
+
link_key='github-repository',
|
|
240
|
+
new_url=html_url,
|
|
241
|
+
)
|
|
242
|
+
return LifecycleResult(
|
|
243
|
+
status='ok',
|
|
244
|
+
message=f'Created {target_org}/{ctx.project_slug}',
|
|
245
|
+
artifacts={'repo_url': html_url},
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
async def on_project_updated(
|
|
249
|
+
self,
|
|
250
|
+
ctx: PluginContext,
|
|
251
|
+
credentials: dict[str, str],
|
|
252
|
+
) -> LifecycleResult:
|
|
253
|
+
host = self._resolve_host(ctx.assignment_options)
|
|
254
|
+
# ``prefer_previous_slug`` so a slug rename still locates the
|
|
255
|
+
# pre-rename repo on GitHub when the project has no stored link.
|
|
256
|
+
owner, repo = resolve_owner_repo(
|
|
257
|
+
ctx,
|
|
258
|
+
host,
|
|
259
|
+
'GitHub lifecycle plugin',
|
|
260
|
+
prefer_previous_slug=True,
|
|
261
|
+
)
|
|
262
|
+
async with self._client(ctx, credentials) as client:
|
|
263
|
+
current = await self._get_repo(client, owner, repo)
|
|
264
|
+
current_owner = self._current_owner(current, owner)
|
|
265
|
+
current_repo = self._current_repo(current, repo)
|
|
266
|
+
# Surface an external rename even when there's nothing else
|
|
267
|
+
# to do, so the host can self-heal the link.
|
|
268
|
+
self._maybe_report_relocation(
|
|
269
|
+
ctx, host, current, owner, repo, current_owner, current_repo
|
|
270
|
+
)
|
|
271
|
+
patched = await self._patch_repo_attrs(
|
|
272
|
+
client,
|
|
273
|
+
current_owner,
|
|
274
|
+
current_repo,
|
|
275
|
+
name=ctx.project_slug,
|
|
276
|
+
description=ctx.project_description or '',
|
|
277
|
+
homepage=ctx.project_ui_url or '',
|
|
278
|
+
)
|
|
279
|
+
new_repo = str(patched.get('name') or current_repo)
|
|
280
|
+
new_url = str(
|
|
281
|
+
patched.get('html_url')
|
|
282
|
+
or self._repo_html_url(host, current_owner, new_repo)
|
|
283
|
+
)
|
|
284
|
+
# If the patch itself renamed the repo (we asked GitHub to
|
|
285
|
+
# set ``name`` to a new slug), record the writeback even when
|
|
286
|
+
# the external-rename check above didn't.
|
|
287
|
+
if new_repo != current_repo:
|
|
288
|
+
ctx.link_writeback = LinkWriteback(
|
|
289
|
+
link_key='github-repository',
|
|
290
|
+
new_url=new_url,
|
|
291
|
+
old_owner_repo=f'{current_owner}/{current_repo}',
|
|
292
|
+
new_owner_repo=f'{current_owner}/{new_repo}',
|
|
293
|
+
)
|
|
294
|
+
return LifecycleResult(
|
|
295
|
+
status='ok',
|
|
296
|
+
message=f'Updated {current_owner}/{new_repo}',
|
|
297
|
+
artifacts={'repo_url': new_url},
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
async def on_project_deleted(
|
|
301
|
+
self,
|
|
302
|
+
ctx: PluginContext,
|
|
303
|
+
credentials: dict[str, str],
|
|
304
|
+
) -> LifecycleResult:
|
|
305
|
+
host = self._resolve_host(ctx.assignment_options)
|
|
306
|
+
try:
|
|
307
|
+
owner, repo = resolve_owner_repo(
|
|
308
|
+
ctx, host, 'GitHub lifecycle plugin'
|
|
309
|
+
)
|
|
310
|
+
except ValueError as exc:
|
|
311
|
+
return LifecycleResult(status='skipped', message=str(exc))
|
|
312
|
+
async with self._client(ctx, credentials) as client:
|
|
313
|
+
resp = await client.delete(f'/repos/{owner}/{repo}')
|
|
314
|
+
if resp.status_code == 404:
|
|
315
|
+
return LifecycleResult(
|
|
316
|
+
status='skipped',
|
|
317
|
+
message=f'Repository {owner}/{repo} already gone',
|
|
318
|
+
)
|
|
319
|
+
resp.raise_for_status()
|
|
320
|
+
return LifecycleResult(
|
|
321
|
+
status='ok',
|
|
322
|
+
message=f'Deleted {owner}/{repo}',
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
async def on_project_relocated(
|
|
326
|
+
self,
|
|
327
|
+
ctx: PluginContext,
|
|
328
|
+
credentials: dict[str, str],
|
|
329
|
+
) -> LifecycleResult:
|
|
330
|
+
host = self._resolve_host(ctx.assignment_options)
|
|
331
|
+
target = await self.resolve_relocation_target(ctx, credentials)
|
|
332
|
+
if target is None:
|
|
333
|
+
return LifecycleResult(
|
|
334
|
+
status='skipped',
|
|
335
|
+
message='No relocation target resolved',
|
|
336
|
+
)
|
|
337
|
+
try:
|
|
338
|
+
new_owner, _new_repo_hint = target.identifier.split('/', 1)
|
|
339
|
+
except ValueError:
|
|
340
|
+
return LifecycleResult(
|
|
341
|
+
status='failed',
|
|
342
|
+
message=(
|
|
343
|
+
f'Malformed relocation identifier {target.identifier!r};'
|
|
344
|
+
' expected ``<owner>/<repo>``'
|
|
345
|
+
),
|
|
346
|
+
)
|
|
347
|
+
try:
|
|
348
|
+
owner, repo = resolve_owner_repo(
|
|
349
|
+
ctx, host, 'GitHub lifecycle plugin'
|
|
350
|
+
)
|
|
351
|
+
except ValueError as exc:
|
|
352
|
+
return LifecycleResult(status='skipped', message=str(exc))
|
|
353
|
+
if owner.lower() == new_owner.lower():
|
|
354
|
+
return LifecycleResult(
|
|
355
|
+
status='skipped',
|
|
356
|
+
message=(
|
|
357
|
+
f'Repository {owner}/{repo} is already at the '
|
|
358
|
+
f'target org {new_owner}'
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
async with self._client(ctx, credentials) as client:
|
|
362
|
+
transferred = await self._transfer(client, owner, repo, new_owner)
|
|
363
|
+
final_repo = str(transferred.get('name') or repo)
|
|
364
|
+
html_url = str(
|
|
365
|
+
transferred.get('html_url')
|
|
366
|
+
or self._repo_html_url(host, new_owner, final_repo)
|
|
367
|
+
)
|
|
368
|
+
ctx.link_writeback = LinkWriteback(
|
|
369
|
+
link_key='github-repository',
|
|
370
|
+
new_url=html_url,
|
|
371
|
+
old_owner_repo=f'{owner}/{repo}',
|
|
372
|
+
new_owner_repo=f'{new_owner}/{final_repo}',
|
|
373
|
+
)
|
|
374
|
+
return LifecycleResult(
|
|
375
|
+
status='ok',
|
|
376
|
+
message=f'Transferred to {new_owner}/{final_repo}',
|
|
377
|
+
artifacts={'repo_url': html_url},
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
async def resolve_relocation_target(
|
|
381
|
+
self,
|
|
382
|
+
ctx: PluginContext,
|
|
383
|
+
credentials: dict[str, str],
|
|
384
|
+
) -> RelocationTarget | None:
|
|
385
|
+
del credentials # resolution is local; never hits the remote.
|
|
386
|
+
target_org = self._resolve_create_org(ctx)
|
|
387
|
+
if not target_org:
|
|
388
|
+
return None
|
|
389
|
+
host = self._resolve_host(ctx.assignment_options)
|
|
390
|
+
# Prefer the canonical repo name from a stored link; fall back to
|
|
391
|
+
# project_slug so a preview before any link exists still resolves.
|
|
392
|
+
derived = derive_owner_repo_from_links(ctx.project_links, host)
|
|
393
|
+
repo_name = derived[1] if derived is not None else ctx.project_slug
|
|
394
|
+
identifier = f'{target_org}/{repo_name}'
|
|
395
|
+
return RelocationTarget(
|
|
396
|
+
link_key='github-repository',
|
|
397
|
+
identifier=identifier,
|
|
398
|
+
display=identifier,
|
|
399
|
+
)
|
|
400
|
+
|
|
153
401
|
async def on_project_archived(
|
|
154
402
|
self,
|
|
155
403
|
ctx: PluginContext,
|
|
@@ -288,13 +536,13 @@ class _LifecycleBase(LifecyclePlugin):
|
|
|
288
536
|
current_owner: str,
|
|
289
537
|
current_repo: str,
|
|
290
538
|
) -> None:
|
|
291
|
-
"""Record a
|
|
539
|
+
"""Record a link writeback when the repo moved out from under the link.
|
|
292
540
|
|
|
293
541
|
Compares the link-derived ``<owner>/<repo>`` against the repo's
|
|
294
542
|
canonical name from ``payload``. When they differ the repo was
|
|
295
543
|
renamed (or its owner renamed) outside Imbi, so stash a
|
|
296
|
-
:class:`
|
|
297
|
-
|
|
544
|
+
:class:`LinkWriteback` on ``ctx`` for the host to persist the
|
|
545
|
+
new link. No-op when they match.
|
|
298
546
|
"""
|
|
299
547
|
old_owner_repo = f'{link_owner}/{link_repo}'
|
|
300
548
|
new_owner_repo = f'{current_owner}/{current_repo}'
|
|
@@ -306,7 +554,7 @@ class _LifecycleBase(LifecyclePlugin):
|
|
|
306
554
|
if isinstance(html_url, str) and html_url
|
|
307
555
|
else self._repo_html_url(host, current_owner, current_repo)
|
|
308
556
|
)
|
|
309
|
-
ctx.
|
|
557
|
+
ctx.link_writeback = LinkWriteback(
|
|
310
558
|
link_key='github-repository',
|
|
311
559
|
new_url=new_url,
|
|
312
560
|
old_owner_repo=old_owner_repo,
|
|
@@ -324,6 +572,64 @@ class _LifecycleBase(LifecyclePlugin):
|
|
|
324
572
|
resp.raise_for_status()
|
|
325
573
|
return typing.cast(dict[str, typing.Any], resp.json())
|
|
326
574
|
|
|
575
|
+
async def _get_repo_or_none(
|
|
576
|
+
self, client: httpx.AsyncClient, owner: str, repo: str
|
|
577
|
+
) -> dict[str, typing.Any] | None:
|
|
578
|
+
"""Read a repo, returning ``None`` on 404 instead of raising.
|
|
579
|
+
|
|
580
|
+
Used by :meth:`on_project_created` for the idempotency check —
|
|
581
|
+
any other status is treated as a real failure and re-raised.
|
|
582
|
+
"""
|
|
583
|
+
resp = await client.get(f'/repos/{owner}/{repo}')
|
|
584
|
+
if resp.status_code == 404:
|
|
585
|
+
return None
|
|
586
|
+
resp.raise_for_status()
|
|
587
|
+
return typing.cast(dict[str, typing.Any], resp.json())
|
|
588
|
+
|
|
589
|
+
async def _create_repo(
|
|
590
|
+
self,
|
|
591
|
+
client: httpx.AsyncClient,
|
|
592
|
+
org: str,
|
|
593
|
+
ctx: PluginContext,
|
|
594
|
+
) -> dict[str, typing.Any]:
|
|
595
|
+
resp = await client.post(
|
|
596
|
+
f'/orgs/{org}/repos',
|
|
597
|
+
json={
|
|
598
|
+
'name': ctx.project_slug,
|
|
599
|
+
'description': ctx.project_description or '',
|
|
600
|
+
'homepage': ctx.project_ui_url or '',
|
|
601
|
+
},
|
|
602
|
+
)
|
|
603
|
+
resp.raise_for_status()
|
|
604
|
+
return typing.cast(dict[str, typing.Any], resp.json())
|
|
605
|
+
|
|
606
|
+
async def _patch_repo_attrs(
|
|
607
|
+
self,
|
|
608
|
+
client: httpx.AsyncClient,
|
|
609
|
+
owner: str,
|
|
610
|
+
repo: str,
|
|
611
|
+
*,
|
|
612
|
+
name: str,
|
|
613
|
+
description: str,
|
|
614
|
+
homepage: str,
|
|
615
|
+
) -> dict[str, typing.Any]:
|
|
616
|
+
"""Sync name / description / homepage via a single PATCH.
|
|
617
|
+
|
|
618
|
+
One call covers all three sync fields so an update that touches
|
|
619
|
+
several is still a single GitHub round trip. ``raise_for_status``
|
|
620
|
+
on any non-2xx so the dispatcher captures the failure.
|
|
621
|
+
"""
|
|
622
|
+
resp = await client.patch(
|
|
623
|
+
f'/repos/{owner}/{repo}',
|
|
624
|
+
json={
|
|
625
|
+
'name': name,
|
|
626
|
+
'description': description,
|
|
627
|
+
'homepage': homepage,
|
|
628
|
+
},
|
|
629
|
+
)
|
|
630
|
+
resp.raise_for_status()
|
|
631
|
+
return typing.cast(dict[str, typing.Any], resp.json())
|
|
632
|
+
|
|
327
633
|
async def _set_archived(
|
|
328
634
|
self,
|
|
329
635
|
client: httpx.AsyncClient,
|
|
@@ -397,8 +703,49 @@ _COMMON_OPTIONS: list[PluginOption] = [
|
|
|
397
703
|
type='string',
|
|
398
704
|
required=False,
|
|
399
705
|
),
|
|
706
|
+
PluginOption(
|
|
707
|
+
name='create_org',
|
|
708
|
+
label='Default org for repo creation',
|
|
709
|
+
description=(
|
|
710
|
+
'Org used by ``on_project_created`` (and the relocate-target '
|
|
711
|
+
'preview) when no per-project-type override matches in '
|
|
712
|
+
'``org_mapping``. Supports the template variables '
|
|
713
|
+
'``${project_slug}``, ``${org_slug}``, ``${team_slug}``, '
|
|
714
|
+
'``${project_type_slug}``, ``${project_id}``. Leave blank '
|
|
715
|
+
'to skip create / relocate when no mapping matches.'
|
|
716
|
+
),
|
|
717
|
+
type='string',
|
|
718
|
+
required=False,
|
|
719
|
+
),
|
|
720
|
+
PluginOption(
|
|
721
|
+
name='org_mapping',
|
|
722
|
+
label='Project-type to org overrides',
|
|
723
|
+
description=(
|
|
724
|
+
'Per-project-type-slug overrides for the target GitHub org. '
|
|
725
|
+
'The first ``project_type_slug`` that has a mapping wins '
|
|
726
|
+
'over ``create_org``. Use this when different project types '
|
|
727
|
+
'live in different orgs (e.g. ``api`` → ``aweber-services``, '
|
|
728
|
+
'``library`` → ``aweber-libs``).'
|
|
729
|
+
),
|
|
730
|
+
type='mapping',
|
|
731
|
+
required=False,
|
|
732
|
+
),
|
|
400
733
|
]
|
|
401
734
|
|
|
735
|
+
# Lifecycle events every GitHub lifecycle variant supports. Shared
|
|
736
|
+
# across the three host flavors -- they inherit the same ``_LifecycleBase``
|
|
737
|
+
# implementation, so their capability lists are identical.
|
|
738
|
+
_COMMON_LIFECYCLE_EVENTS: list[
|
|
739
|
+
typing.Literal[
|
|
740
|
+
'created',
|
|
741
|
+
'updated',
|
|
742
|
+
'archived',
|
|
743
|
+
'unarchived',
|
|
744
|
+
'deleted',
|
|
745
|
+
'relocated',
|
|
746
|
+
]
|
|
747
|
+
] = ['created', 'updated', 'archived', 'unarchived', 'deleted', 'relocated']
|
|
748
|
+
|
|
402
749
|
_COMMON_CREDENTIALS: list[CredentialField] = [
|
|
403
750
|
CredentialField(
|
|
404
751
|
name='access_token',
|
|
@@ -418,15 +765,15 @@ class GitHubLifecyclePlugin(_LifecycleBase):
|
|
|
418
765
|
slug='github-lifecycle',
|
|
419
766
|
name='GitHub Lifecycle',
|
|
420
767
|
description=(
|
|
421
|
-
'React to project
|
|
422
|
-
'archiving
|
|
423
|
-
'
|
|
424
|
-
'
|
|
425
|
-
'org searches.'
|
|
768
|
+
'React to the project lifecycle on github.com by creating, '
|
|
769
|
+
'renaming, archiving, transferring, or deleting the matching '
|
|
770
|
+
'repository. Org selection on create / relocate is driven '
|
|
771
|
+
'by per-project-type overrides plus a template fallback.'
|
|
426
772
|
),
|
|
427
773
|
plugin_type='lifecycle',
|
|
428
774
|
options=_COMMON_OPTIONS,
|
|
429
775
|
credentials=_COMMON_CREDENTIALS,
|
|
776
|
+
lifecycle_events=_COMMON_LIFECYCLE_EVENTS,
|
|
430
777
|
)
|
|
431
778
|
|
|
432
779
|
@classmethod
|
|
@@ -440,9 +787,9 @@ class GitHubEnterpriseCloudLifecyclePlugin(_LifecycleBase):
|
|
|
440
787
|
slug='github-lifecycle-ec',
|
|
441
788
|
name='GitHub Enterprise Cloud Lifecycle',
|
|
442
789
|
description=(
|
|
443
|
-
'React to project
|
|
444
|
-
'(``*.ghe.com``) by
|
|
445
|
-
'
|
|
790
|
+
'React to the project lifecycle on a GHEC tenant '
|
|
791
|
+
'(``*.ghe.com``) by creating, renaming, archiving, '
|
|
792
|
+
'transferring, or deleting the matching repository.'
|
|
446
793
|
),
|
|
447
794
|
plugin_type='lifecycle',
|
|
448
795
|
options=[
|
|
@@ -456,6 +803,7 @@ class GitHubEnterpriseCloudLifecyclePlugin(_LifecycleBase):
|
|
|
456
803
|
*_COMMON_OPTIONS,
|
|
457
804
|
],
|
|
458
805
|
credentials=_COMMON_CREDENTIALS,
|
|
806
|
+
lifecycle_events=_COMMON_LIFECYCLE_EVENTS,
|
|
459
807
|
)
|
|
460
808
|
|
|
461
809
|
@classmethod
|
|
@@ -471,9 +819,9 @@ class GitHubEnterpriseServerLifecyclePlugin(_LifecycleBase):
|
|
|
471
819
|
slug='github-lifecycle-es',
|
|
472
820
|
name='GitHub Enterprise Server Lifecycle',
|
|
473
821
|
description=(
|
|
474
|
-
'React to project
|
|
475
|
-
'
|
|
476
|
-
'
|
|
822
|
+
'React to the project lifecycle on a GHES install by '
|
|
823
|
+
'creating, renaming, archiving, transferring, or deleting '
|
|
824
|
+
'the matching repository.'
|
|
477
825
|
),
|
|
478
826
|
plugin_type='lifecycle',
|
|
479
827
|
options=[
|
|
@@ -486,6 +834,7 @@ class GitHubEnterpriseServerLifecyclePlugin(_LifecycleBase):
|
|
|
486
834
|
*_COMMON_OPTIONS,
|
|
487
835
|
],
|
|
488
836
|
credentials=_COMMON_CREDENTIALS,
|
|
837
|
+
lifecycle_events=_COMMON_LIFECYCLE_EVENTS,
|
|
489
838
|
)
|
|
490
839
|
|
|
491
840
|
@classmethod
|
|
@@ -1403,7 +1403,7 @@ class RepoRenameRelocationTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
1403
1403
|
self.assertEqual(len(commits), 1)
|
|
1404
1404
|
self.assertEqual(commits[0].sha, 'abc')
|
|
1405
1405
|
# ...and the rename is reported for the host to self-heal.
|
|
1406
|
-
reloc = ctx.
|
|
1406
|
+
reloc = ctx.link_writeback
|
|
1407
1407
|
assert reloc is not None
|
|
1408
1408
|
self.assertEqual(reloc.link_key, 'github-repository')
|
|
1409
1409
|
self.assertEqual(reloc.new_url, 'https://github.com/octo/renamed')
|
|
@@ -1432,7 +1432,7 @@ class RepoRenameRelocationTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
1432
1432
|
ctx = _ctx()
|
|
1433
1433
|
plugin = GitHubDeploymentPlugin()
|
|
1434
1434
|
await plugin.list_commits(ctx, _CREDS, ref='main')
|
|
1435
|
-
self.assertIsNone(ctx.
|
|
1435
|
+
self.assertIsNone(ctx.link_writeback)
|
|
1436
1436
|
|
|
1437
1437
|
@respx.mock
|
|
1438
1438
|
async def test_no_relocation_when_repo_root_unresolvable(self) -> None:
|
|
@@ -1445,7 +1445,7 @@ class RepoRenameRelocationTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
1445
1445
|
plugin = GitHubDeploymentPlugin()
|
|
1446
1446
|
commits = await plugin.list_commits(ctx, _CREDS, ref='main')
|
|
1447
1447
|
self.assertEqual(len(commits), 1)
|
|
1448
|
-
self.assertIsNone(ctx.
|
|
1448
|
+
self.assertIsNone(ctx.link_writeback)
|
|
1449
1449
|
|
|
1450
1450
|
@respx.mock
|
|
1451
1451
|
async def test_no_relocation_when_name_unchanged(self) -> None:
|
|
@@ -1464,4 +1464,4 @@ class RepoRenameRelocationTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
1464
1464
|
ctx = _ctx()
|
|
1465
1465
|
plugin = GitHubDeploymentPlugin()
|
|
1466
1466
|
await plugin.list_commits(ctx, _CREDS, ref='main')
|
|
1467
|
-
self.assertIsNone(ctx.
|
|
1467
|
+
self.assertIsNone(ctx.link_writeback)
|
|
@@ -29,10 +29,16 @@ def _ctx(
|
|
|
29
29
|
options: dict[str, object] | None = None,
|
|
30
30
|
project_links: dict[str, str] | None = None,
|
|
31
31
|
project_type_slugs: list[str] | None = None,
|
|
32
|
+
*,
|
|
33
|
+
project_slug: str = 'demo',
|
|
34
|
+
previous_project_slug: str | None = None,
|
|
35
|
+
project_name: str | None = None,
|
|
36
|
+
project_description: str | None = None,
|
|
37
|
+
project_ui_url: str | None = None,
|
|
32
38
|
) -> PluginContext:
|
|
33
39
|
return PluginContext(
|
|
34
40
|
project_id='p',
|
|
35
|
-
project_slug=
|
|
41
|
+
project_slug=project_slug,
|
|
36
42
|
org_slug='octo',
|
|
37
43
|
assignment_options=options or {},
|
|
38
44
|
actor_user_id='u-1',
|
|
@@ -42,6 +48,10 @@ def _ctx(
|
|
|
42
48
|
else {'github-repository': 'https://github.com/octo/demo'}
|
|
43
49
|
),
|
|
44
50
|
project_type_slugs=project_type_slugs or [],
|
|
51
|
+
previous_project_slug=previous_project_slug,
|
|
52
|
+
project_name=project_name,
|
|
53
|
+
project_description=project_description,
|
|
54
|
+
project_ui_url=project_ui_url,
|
|
45
55
|
)
|
|
46
56
|
|
|
47
57
|
|
|
@@ -82,6 +92,39 @@ class ManifestTestCase(unittest.TestCase):
|
|
|
82
92
|
names = {opt.name for opt in cls.manifest.options}
|
|
83
93
|
self.assertIn('archive_target_org', names)
|
|
84
94
|
|
|
95
|
+
def test_create_org_and_org_mapping_options(self) -> None:
|
|
96
|
+
# Every flavor advertises the new ``create_org`` + ``org_mapping``
|
|
97
|
+
# options so the admin UI can render them without per-host casing.
|
|
98
|
+
for cls in (
|
|
99
|
+
GitHubLifecyclePlugin,
|
|
100
|
+
GitHubEnterpriseCloudLifecyclePlugin,
|
|
101
|
+
GitHubEnterpriseServerLifecyclePlugin,
|
|
102
|
+
):
|
|
103
|
+
opts = {opt.name: opt for opt in cls.manifest.options}
|
|
104
|
+
self.assertIn('create_org', opts)
|
|
105
|
+
self.assertEqual(opts['create_org'].type, 'string')
|
|
106
|
+
self.assertIn('org_mapping', opts)
|
|
107
|
+
self.assertEqual(opts['org_mapping'].type, 'mapping')
|
|
108
|
+
|
|
109
|
+
def test_lifecycle_events_includes_all_supported(self) -> None:
|
|
110
|
+
# ``lifecycle_events`` drives UI affordance gating (e.g. the
|
|
111
|
+
# "Also delete the repository" checkbox); every flavor must
|
|
112
|
+
# advertise the full set the plugin implements.
|
|
113
|
+
expected = {
|
|
114
|
+
'created',
|
|
115
|
+
'updated',
|
|
116
|
+
'archived',
|
|
117
|
+
'unarchived',
|
|
118
|
+
'deleted',
|
|
119
|
+
'relocated',
|
|
120
|
+
}
|
|
121
|
+
for cls in (
|
|
122
|
+
GitHubLifecyclePlugin,
|
|
123
|
+
GitHubEnterpriseCloudLifecyclePlugin,
|
|
124
|
+
GitHubEnterpriseServerLifecyclePlugin,
|
|
125
|
+
):
|
|
126
|
+
self.assertEqual(set(cls.manifest.lifecycle_events), expected)
|
|
127
|
+
|
|
85
128
|
|
|
86
129
|
class ArchiveTestCase(unittest.IsolatedAsyncioTestCase):
|
|
87
130
|
@respx.mock
|
|
@@ -604,7 +647,7 @@ class RenameRelocationTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
604
647
|
self.assertEqual(
|
|
605
648
|
result.artifacts['repo_url'], 'https://github.com/octo/renamed'
|
|
606
649
|
)
|
|
607
|
-
reloc = ctx.
|
|
650
|
+
reloc = ctx.link_writeback
|
|
608
651
|
assert reloc is not None
|
|
609
652
|
self.assertEqual(reloc.link_key, 'github-repository')
|
|
610
653
|
self.assertEqual(reloc.new_url, 'https://github.com/octo/renamed')
|
|
@@ -644,7 +687,7 @@ class RenameRelocationTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
644
687
|
|
|
645
688
|
self.assertEqual(result.status, 'ok')
|
|
646
689
|
self.assertEqual(patch_route.calls.call_count, 1)
|
|
647
|
-
reloc = ctx.
|
|
690
|
+
reloc = ctx.link_writeback
|
|
648
691
|
assert reloc is not None
|
|
649
692
|
self.assertEqual(reloc.new_owner_repo, 'octo/renamed')
|
|
650
693
|
|
|
@@ -703,7 +746,7 @@ class RenameRelocationTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
703
746
|
ctx = _ctx()
|
|
704
747
|
plugin = GitHubLifecyclePlugin()
|
|
705
748
|
await plugin.on_project_archived(ctx, _CREDS)
|
|
706
|
-
self.assertIsNone(ctx.
|
|
749
|
+
self.assertIsNone(ctx.link_writeback)
|
|
707
750
|
|
|
708
751
|
@respx.mock
|
|
709
752
|
async def test_intentional_transfer_is_not_reported_as_relocation(
|
|
@@ -732,4 +775,385 @@ class RenameRelocationTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
732
775
|
plugin = GitHubLifecyclePlugin()
|
|
733
776
|
result = await plugin.on_project_archived(ctx, _CREDS)
|
|
734
777
|
self.assertEqual(result.status, 'ok')
|
|
735
|
-
self.assertIsNone(ctx.
|
|
778
|
+
self.assertIsNone(ctx.link_writeback)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
class CreateTestCase(unittest.IsolatedAsyncioTestCase):
|
|
782
|
+
"""``on_project_created`` provisions the repo and writes the link."""
|
|
783
|
+
|
|
784
|
+
@respx.mock
|
|
785
|
+
async def test_create_happy_path_uses_org_mapping(self) -> None:
|
|
786
|
+
# Project-type mapping wins over ``create_org``; the repo is
|
|
787
|
+
# created at the mapped org and the link writeback carries the
|
|
788
|
+
# canonical ``html_url`` GitHub returned.
|
|
789
|
+
respx.get('https://api.github.com/repos/aweber-apis/demo').mock(
|
|
790
|
+
return_value=httpx.Response(404, json={'message': 'Not Found'})
|
|
791
|
+
)
|
|
792
|
+
create_route = respx.post(
|
|
793
|
+
'https://api.github.com/orgs/aweber-apis/repos'
|
|
794
|
+
).mock(
|
|
795
|
+
return_value=httpx.Response(
|
|
796
|
+
201,
|
|
797
|
+
json={
|
|
798
|
+
'name': 'demo',
|
|
799
|
+
'html_url': 'https://github.com/aweber-apis/demo',
|
|
800
|
+
'owner': {'login': 'aweber-apis'},
|
|
801
|
+
},
|
|
802
|
+
)
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
ctx = _ctx(
|
|
806
|
+
options={
|
|
807
|
+
'create_org': 'fallback',
|
|
808
|
+
'org_mapping': {'api-service': 'aweber-apis'},
|
|
809
|
+
},
|
|
810
|
+
project_links={},
|
|
811
|
+
project_type_slugs=['api-service'],
|
|
812
|
+
project_description='An example API',
|
|
813
|
+
project_ui_url='https://imbi.example.com/projects/p',
|
|
814
|
+
)
|
|
815
|
+
plugin = GitHubLifecyclePlugin()
|
|
816
|
+
result = await plugin.on_project_created(ctx, _CREDS)
|
|
817
|
+
|
|
818
|
+
self.assertEqual(result.status, 'ok')
|
|
819
|
+
self.assertEqual(create_route.calls.call_count, 1)
|
|
820
|
+
self.assertEqual(
|
|
821
|
+
create_route.calls.last.request.read(),
|
|
822
|
+
b'{"name":"demo","description":"An example API",'
|
|
823
|
+
b'"homepage":"https://imbi.example.com/projects/p"}',
|
|
824
|
+
)
|
|
825
|
+
self.assertIsNotNone(ctx.link_writeback)
|
|
826
|
+
assert ctx.link_writeback is not None
|
|
827
|
+
self.assertEqual(ctx.link_writeback.link_key, 'github-repository')
|
|
828
|
+
self.assertEqual(
|
|
829
|
+
ctx.link_writeback.new_url,
|
|
830
|
+
'https://github.com/aweber-apis/demo',
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
@respx.mock
|
|
834
|
+
async def test_create_falls_back_to_template(self) -> None:
|
|
835
|
+
# No mapping match → expand the ``create_org`` template.
|
|
836
|
+
respx.get('https://api.github.com/repos/aweber-api-service/demo').mock(
|
|
837
|
+
return_value=httpx.Response(404, json={'message': 'Not Found'})
|
|
838
|
+
)
|
|
839
|
+
create_route = respx.post(
|
|
840
|
+
'https://api.github.com/orgs/aweber-api-service/repos'
|
|
841
|
+
).mock(
|
|
842
|
+
return_value=httpx.Response(
|
|
843
|
+
201,
|
|
844
|
+
json={
|
|
845
|
+
'name': 'demo',
|
|
846
|
+
'html_url': ('https://github.com/aweber-api-service/demo'),
|
|
847
|
+
},
|
|
848
|
+
)
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
ctx = _ctx(
|
|
852
|
+
options={'create_org': 'aweber-${project_type_slug}'},
|
|
853
|
+
project_links={},
|
|
854
|
+
project_type_slugs=['api-service'],
|
|
855
|
+
)
|
|
856
|
+
plugin = GitHubLifecyclePlugin()
|
|
857
|
+
result = await plugin.on_project_created(ctx, _CREDS)
|
|
858
|
+
|
|
859
|
+
self.assertEqual(result.status, 'ok')
|
|
860
|
+
self.assertEqual(create_route.calls.call_count, 1)
|
|
861
|
+
|
|
862
|
+
async def test_create_skips_when_no_target_org_configured(self) -> None:
|
|
863
|
+
# Neither mapping nor template -> clean skip rather than HTTP.
|
|
864
|
+
ctx = _ctx(
|
|
865
|
+
options={},
|
|
866
|
+
project_links={},
|
|
867
|
+
project_type_slugs=['api-service'],
|
|
868
|
+
)
|
|
869
|
+
plugin = GitHubLifecyclePlugin()
|
|
870
|
+
result = await plugin.on_project_created(ctx, _CREDS)
|
|
871
|
+
self.assertEqual(result.status, 'skipped')
|
|
872
|
+
self.assertIn('No target org', result.message or '')
|
|
873
|
+
self.assertIsNone(ctx.link_writeback)
|
|
874
|
+
|
|
875
|
+
@respx.mock
|
|
876
|
+
async def test_create_idempotent_when_repo_exists(self) -> None:
|
|
877
|
+
# Re-running a create for a repo that already exists adopts the
|
|
878
|
+
# existing URL via a link writeback and reports ``skipped``.
|
|
879
|
+
respx.get('https://api.github.com/repos/aweber-apis/demo').mock(
|
|
880
|
+
return_value=httpx.Response(
|
|
881
|
+
200,
|
|
882
|
+
json={
|
|
883
|
+
'name': 'demo',
|
|
884
|
+
'html_url': 'https://github.com/aweber-apis/demo',
|
|
885
|
+
'owner': {'login': 'aweber-apis'},
|
|
886
|
+
},
|
|
887
|
+
)
|
|
888
|
+
)
|
|
889
|
+
create_route = respx.post(
|
|
890
|
+
'https://api.github.com/orgs/aweber-apis/repos'
|
|
891
|
+
).mock(return_value=httpx.Response(201, json={}))
|
|
892
|
+
|
|
893
|
+
ctx = _ctx(
|
|
894
|
+
options={'org_mapping': {'api-service': 'aweber-apis'}},
|
|
895
|
+
project_links={},
|
|
896
|
+
project_type_slugs=['api-service'],
|
|
897
|
+
)
|
|
898
|
+
plugin = GitHubLifecyclePlugin()
|
|
899
|
+
result = await plugin.on_project_created(ctx, _CREDS)
|
|
900
|
+
self.assertEqual(result.status, 'skipped')
|
|
901
|
+
self.assertIn('already exists', result.message or '')
|
|
902
|
+
self.assertEqual(create_route.calls.call_count, 0)
|
|
903
|
+
self.assertIsNotNone(ctx.link_writeback)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
class UpdateTestCase(unittest.IsolatedAsyncioTestCase):
|
|
907
|
+
"""``on_project_updated`` syncs slug/description/homepage to the repo."""
|
|
908
|
+
|
|
909
|
+
@respx.mock
|
|
910
|
+
async def test_update_syncs_all_three_fields_in_one_patch(self) -> None:
|
|
911
|
+
respx.get('https://api.github.com/repos/octo/demo').mock(
|
|
912
|
+
return_value=httpx.Response(
|
|
913
|
+
200,
|
|
914
|
+
json={
|
|
915
|
+
'name': 'demo',
|
|
916
|
+
'owner': {'login': 'octo'},
|
|
917
|
+
},
|
|
918
|
+
)
|
|
919
|
+
)
|
|
920
|
+
patch_route = respx.patch(
|
|
921
|
+
'https://api.github.com/repos/octo/demo'
|
|
922
|
+
).mock(
|
|
923
|
+
return_value=httpx.Response(
|
|
924
|
+
200,
|
|
925
|
+
json={
|
|
926
|
+
'name': 'demo',
|
|
927
|
+
'html_url': 'https://github.com/octo/demo',
|
|
928
|
+
'owner': {'login': 'octo'},
|
|
929
|
+
},
|
|
930
|
+
)
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
ctx = _ctx(
|
|
934
|
+
project_description='New description',
|
|
935
|
+
project_ui_url='https://imbi.example.com/projects/p',
|
|
936
|
+
)
|
|
937
|
+
plugin = GitHubLifecyclePlugin()
|
|
938
|
+
result = await plugin.on_project_updated(ctx, _CREDS)
|
|
939
|
+
|
|
940
|
+
self.assertEqual(result.status, 'ok')
|
|
941
|
+
self.assertEqual(patch_route.calls.call_count, 1)
|
|
942
|
+
body = patch_route.calls.last.request.read()
|
|
943
|
+
self.assertIn(b'"name":"demo"', body)
|
|
944
|
+
self.assertIn(b'"description":"New description"', body)
|
|
945
|
+
self.assertIn(
|
|
946
|
+
b'"homepage":"https://imbi.example.com/projects/p"', body
|
|
947
|
+
)
|
|
948
|
+
self.assertIsNone(ctx.link_writeback)
|
|
949
|
+
|
|
950
|
+
@respx.mock
|
|
951
|
+
async def test_update_records_writeback_when_slug_changes(self) -> None:
|
|
952
|
+
# A slug rename PATCHes ``name`` to the new value; the response
|
|
953
|
+
# carries the new ``html_url`` and the plugin stashes the
|
|
954
|
+
# writeback for the host to persist.
|
|
955
|
+
respx.get('https://api.github.com/repos/octo/demo-old').mock(
|
|
956
|
+
return_value=httpx.Response(
|
|
957
|
+
200,
|
|
958
|
+
json={
|
|
959
|
+
'name': 'demo-old',
|
|
960
|
+
'owner': {'login': 'octo'},
|
|
961
|
+
},
|
|
962
|
+
)
|
|
963
|
+
)
|
|
964
|
+
respx.patch('https://api.github.com/repos/octo/demo-old').mock(
|
|
965
|
+
return_value=httpx.Response(
|
|
966
|
+
200,
|
|
967
|
+
json={
|
|
968
|
+
'name': 'demo-new',
|
|
969
|
+
'html_url': 'https://github.com/octo/demo-new',
|
|
970
|
+
'owner': {'login': 'octo'},
|
|
971
|
+
},
|
|
972
|
+
)
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
ctx = _ctx(
|
|
976
|
+
project_slug='demo-new',
|
|
977
|
+
previous_project_slug='demo-old',
|
|
978
|
+
project_links={
|
|
979
|
+
'github-repository': 'https://github.com/octo/demo-old',
|
|
980
|
+
},
|
|
981
|
+
)
|
|
982
|
+
plugin = GitHubLifecyclePlugin()
|
|
983
|
+
result = await plugin.on_project_updated(ctx, _CREDS)
|
|
984
|
+
|
|
985
|
+
self.assertEqual(result.status, 'ok')
|
|
986
|
+
self.assertIsNotNone(ctx.link_writeback)
|
|
987
|
+
assert ctx.link_writeback is not None
|
|
988
|
+
self.assertEqual(
|
|
989
|
+
ctx.link_writeback.new_url,
|
|
990
|
+
'https://github.com/octo/demo-new',
|
|
991
|
+
)
|
|
992
|
+
self.assertEqual(ctx.link_writeback.old_owner_repo, 'octo/demo-old')
|
|
993
|
+
self.assertEqual(ctx.link_writeback.new_owner_repo, 'octo/demo-new')
|
|
994
|
+
|
|
995
|
+
@respx.mock
|
|
996
|
+
async def test_update_falls_back_to_previous_slug_without_link(
|
|
997
|
+
self,
|
|
998
|
+
) -> None:
|
|
999
|
+
# No stored link → resolve from ``<project_type_slug>/<previous_slug>``
|
|
1000
|
+
# so a slug-rename still finds the pre-rename repo on GitHub.
|
|
1001
|
+
respx.get('https://api.github.com/repos/api-service/demo-old').mock(
|
|
1002
|
+
return_value=httpx.Response(
|
|
1003
|
+
200,
|
|
1004
|
+
json={
|
|
1005
|
+
'name': 'demo-old',
|
|
1006
|
+
'owner': {'login': 'api-service'},
|
|
1007
|
+
},
|
|
1008
|
+
)
|
|
1009
|
+
)
|
|
1010
|
+
respx.patch('https://api.github.com/repos/api-service/demo-old').mock(
|
|
1011
|
+
return_value=httpx.Response(
|
|
1012
|
+
200,
|
|
1013
|
+
json={
|
|
1014
|
+
'name': 'demo-new',
|
|
1015
|
+
'html_url': ('https://github.com/api-service/demo-new'),
|
|
1016
|
+
},
|
|
1017
|
+
)
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
ctx = _ctx(
|
|
1021
|
+
project_slug='demo-new',
|
|
1022
|
+
previous_project_slug='demo-old',
|
|
1023
|
+
project_links={},
|
|
1024
|
+
project_type_slugs=['api-service'],
|
|
1025
|
+
)
|
|
1026
|
+
plugin = GitHubLifecyclePlugin()
|
|
1027
|
+
result = await plugin.on_project_updated(ctx, _CREDS)
|
|
1028
|
+
self.assertEqual(result.status, 'ok')
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
class DeleteTestCase(unittest.IsolatedAsyncioTestCase):
|
|
1032
|
+
"""``on_project_deleted`` removes the backing repository."""
|
|
1033
|
+
|
|
1034
|
+
@respx.mock
|
|
1035
|
+
async def test_delete_happy_path(self) -> None:
|
|
1036
|
+
delete_route = respx.delete(
|
|
1037
|
+
'https://api.github.com/repos/octo/demo'
|
|
1038
|
+
).mock(return_value=httpx.Response(204))
|
|
1039
|
+
|
|
1040
|
+
plugin = GitHubLifecyclePlugin()
|
|
1041
|
+
result = await plugin.on_project_deleted(_ctx(), _CREDS)
|
|
1042
|
+
|
|
1043
|
+
self.assertEqual(result.status, 'ok')
|
|
1044
|
+
self.assertEqual(delete_route.calls.call_count, 1)
|
|
1045
|
+
|
|
1046
|
+
@respx.mock
|
|
1047
|
+
async def test_delete_already_gone_is_skipped(self) -> None:
|
|
1048
|
+
respx.delete('https://api.github.com/repos/octo/demo').mock(
|
|
1049
|
+
return_value=httpx.Response(404, json={'message': 'Not Found'})
|
|
1050
|
+
)
|
|
1051
|
+
plugin = GitHubLifecyclePlugin()
|
|
1052
|
+
result = await plugin.on_project_deleted(_ctx(), _CREDS)
|
|
1053
|
+
self.assertEqual(result.status, 'skipped')
|
|
1054
|
+
self.assertIn('already gone', result.message or '')
|
|
1055
|
+
|
|
1056
|
+
async def test_delete_without_resolvable_repo_is_skipped(self) -> None:
|
|
1057
|
+
# No link, no project type → clean skip (rather than a hard
|
|
1058
|
+
# failure) since there's nothing to act on.
|
|
1059
|
+
ctx = _ctx(project_links={}, project_type_slugs=[])
|
|
1060
|
+
plugin = GitHubLifecyclePlugin()
|
|
1061
|
+
result = await plugin.on_project_deleted(ctx, _CREDS)
|
|
1062
|
+
self.assertEqual(result.status, 'skipped')
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
class RelocateTestCase(unittest.IsolatedAsyncioTestCase):
|
|
1066
|
+
"""``on_project_relocated`` transfers the repo to the resolved org."""
|
|
1067
|
+
|
|
1068
|
+
@respx.mock
|
|
1069
|
+
async def test_relocate_transfers_repo_and_records_writeback(
|
|
1070
|
+
self,
|
|
1071
|
+
) -> None:
|
|
1072
|
+
transfer_route = respx.post(
|
|
1073
|
+
'https://api.github.com/repos/octo/demo/transfer'
|
|
1074
|
+
).mock(
|
|
1075
|
+
return_value=httpx.Response(
|
|
1076
|
+
202,
|
|
1077
|
+
json={
|
|
1078
|
+
'name': 'demo',
|
|
1079
|
+
'html_url': 'https://github.com/aweber-apis/demo',
|
|
1080
|
+
'owner': {'login': 'aweber-apis'},
|
|
1081
|
+
},
|
|
1082
|
+
)
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
ctx = _ctx(
|
|
1086
|
+
options={'org_mapping': {'api-service': 'aweber-apis'}},
|
|
1087
|
+
project_type_slugs=['api-service'],
|
|
1088
|
+
)
|
|
1089
|
+
plugin = GitHubLifecyclePlugin()
|
|
1090
|
+
result = await plugin.on_project_relocated(ctx, _CREDS)
|
|
1091
|
+
|
|
1092
|
+
self.assertEqual(result.status, 'ok')
|
|
1093
|
+
self.assertEqual(transfer_route.calls.call_count, 1)
|
|
1094
|
+
self.assertEqual(
|
|
1095
|
+
transfer_route.calls.last.request.read(),
|
|
1096
|
+
b'{"new_owner":"aweber-apis"}',
|
|
1097
|
+
)
|
|
1098
|
+
self.assertIsNotNone(ctx.link_writeback)
|
|
1099
|
+
assert ctx.link_writeback is not None
|
|
1100
|
+
self.assertEqual(
|
|
1101
|
+
ctx.link_writeback.new_url,
|
|
1102
|
+
'https://github.com/aweber-apis/demo',
|
|
1103
|
+
)
|
|
1104
|
+
self.assertEqual(ctx.link_writeback.old_owner_repo, 'octo/demo')
|
|
1105
|
+
self.assertEqual(ctx.link_writeback.new_owner_repo, 'aweber-apis/demo')
|
|
1106
|
+
|
|
1107
|
+
async def test_relocate_skips_when_no_target_resolved(self) -> None:
|
|
1108
|
+
ctx = _ctx(options={}, project_type_slugs=['api-service'])
|
|
1109
|
+
plugin = GitHubLifecyclePlugin()
|
|
1110
|
+
result = await plugin.on_project_relocated(ctx, _CREDS)
|
|
1111
|
+
self.assertEqual(result.status, 'skipped')
|
|
1112
|
+
self.assertIn('No relocation target', result.message or '')
|
|
1113
|
+
|
|
1114
|
+
async def test_relocate_skips_when_already_at_target(self) -> None:
|
|
1115
|
+
# Current owner == target org → no transfer, clean skip.
|
|
1116
|
+
ctx = _ctx(
|
|
1117
|
+
options={'org_mapping': {'api-service': 'octo'}},
|
|
1118
|
+
project_type_slugs=['api-service'],
|
|
1119
|
+
)
|
|
1120
|
+
plugin = GitHubLifecyclePlugin()
|
|
1121
|
+
result = await plugin.on_project_relocated(ctx, _CREDS)
|
|
1122
|
+
self.assertEqual(result.status, 'skipped')
|
|
1123
|
+
self.assertIn('already at the target', result.message or '')
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
class ResolveRelocationTargetTestCase(unittest.IsolatedAsyncioTestCase):
|
|
1127
|
+
"""``resolve_relocation_target`` derives the target locally."""
|
|
1128
|
+
|
|
1129
|
+
async def test_returns_target_from_mapping(self) -> None:
|
|
1130
|
+
ctx = _ctx(
|
|
1131
|
+
options={'org_mapping': {'api-service': 'aweber-apis'}},
|
|
1132
|
+
project_type_slugs=['api-service'],
|
|
1133
|
+
)
|
|
1134
|
+
plugin = GitHubLifecyclePlugin()
|
|
1135
|
+
target = await plugin.resolve_relocation_target(ctx, _CREDS)
|
|
1136
|
+
self.assertIsNotNone(target)
|
|
1137
|
+
assert target is not None
|
|
1138
|
+
self.assertEqual(target.link_key, 'github-repository')
|
|
1139
|
+
self.assertEqual(target.identifier, 'aweber-apis/demo')
|
|
1140
|
+
|
|
1141
|
+
async def test_returns_target_from_template_when_mapping_misses(
|
|
1142
|
+
self,
|
|
1143
|
+
) -> None:
|
|
1144
|
+
ctx = _ctx(
|
|
1145
|
+
options={'create_org': 'aweber-${project_type_slug}'},
|
|
1146
|
+
project_type_slugs=['api-service'],
|
|
1147
|
+
project_links={},
|
|
1148
|
+
)
|
|
1149
|
+
plugin = GitHubLifecyclePlugin()
|
|
1150
|
+
target = await plugin.resolve_relocation_target(ctx, _CREDS)
|
|
1151
|
+
self.assertIsNotNone(target)
|
|
1152
|
+
assert target is not None
|
|
1153
|
+
self.assertEqual(target.identifier, 'aweber-api-service/demo')
|
|
1154
|
+
|
|
1155
|
+
async def test_returns_none_when_no_org_configured(self) -> None:
|
|
1156
|
+
ctx = _ctx(options={}, project_type_slugs=['api-service'])
|
|
1157
|
+
plugin = GitHubLifecyclePlugin()
|
|
1158
|
+
target = await plugin.resolve_relocation_target(ctx, _CREDS)
|
|
1159
|
+
self.assertIsNone(target)
|
|
@@ -667,7 +667,7 @@ wheels = [
|
|
|
667
667
|
|
|
668
668
|
[[package]]
|
|
669
669
|
name = "imbi-common"
|
|
670
|
-
version = "2.
|
|
670
|
+
version = "2.8.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/97/bc/d530fb11a6cc77b6fb9205824f90ffd61a9e177e5521547e8f56d625cb67/imbi_common-2.8.0.tar.gz", hash = "sha256:064407290a832f73e6ae2830f6802268f41a4ce65a68fb5f51c18cc45e966c70", size = 329556, upload-time = "2026-05-29T23:58:54.186Z" }
|
|
687
687
|
wheels = [
|
|
688
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
688
|
+
{ url = "https://files.pythonhosted.org/packages/7f/3f/eb691a100829dff13f58c56a38bcd8c48ec83dd4a8270aca97da63b5a604/imbi_common-2.8.0-py3-none-any.whl", hash = "sha256:30cdb1aa1666f39a36eee9cc2285891650eb2340fcf184c26ed34c115695608d", size = 105139, upload-time = "2026-05-29T23:58:52.578Z" },
|
|
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 = "
|
|
706
|
+
version = "2.8.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 = "
|
|
734
|
+
{ name = "imbi-common", specifier = "==2.8.0" },
|
|
735
735
|
{ name = "pydantic", specifier = ">=2" },
|
|
736
736
|
]
|
|
737
737
|
|
|
@@ -739,7 +739,7 @@ requires-dist = [
|
|
|
739
739
|
dev = [
|
|
740
740
|
{ name = "basedpyright" },
|
|
741
741
|
{ name = "coverage", extras = ["toml"] },
|
|
742
|
-
{ name = "imbi-common", extras = ["server", "databases"] },
|
|
742
|
+
{ name = "imbi-common", extras = ["server", "databases"], specifier = "==2.8.0" },
|
|
743
743
|
{ name = "pre-commit" },
|
|
744
744
|
{ name = "pytest" },
|
|
745
745
|
{ name = "pytest-asyncio" },
|
|
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
|