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.
Files changed (22) hide show
  1. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/PKG-INFO +2 -2
  2. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/pyproject.toml +3 -3
  3. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/_repos.py +16 -3
  4. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/deployment.py +6 -6
  5. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/lifecycle.py +366 -17
  6. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/tests/test_deployment.py +4 -4
  7. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/tests/test_lifecycle.py +429 -5
  8. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/uv.lock +6 -6
  9. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/.github/workflows/publish.yml +0 -0
  10. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/.github/workflows/test.yml +0 -0
  11. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/.gitignore +0 -0
  12. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/.pre-commit-config.yaml +0 -0
  13. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/CLAUDE.md +0 -0
  14. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/LICENSE +0 -0
  15. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/README.md +0 -0
  16. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/justfile +0 -0
  17. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/__init__.py +0 -0
  18. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/_hosts.py +0 -0
  19. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/src/imbi_plugin_github/identity.py +0 -0
  20. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/tests/__init__.py +0 -0
  21. {imbi_plugin_github-1.5.0 → imbi_plugin_github-2.8.0}/tests/test_hosts.py +0 -0
  22. {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: 1.5.0
3
+ Version: 2.8.0
4
4
  Summary: GitHub identity plugin for Imbi (github.com / GHEC / GHES)
5
5
  Author-email: "Gavin M. Roy" <gavinr@aweber.com>
6
6
  License: BSD-3-Clause
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Programming Language :: Python :: 3.14
14
14
  Requires-Python: >=3.14
15
15
  Requires-Dist: httpx>=0.27
16
- Requires-Dist: imbi-common>=2.7.0
16
+ Requires-Dist: imbi-common==2.8.0
17
17
  Requires-Dist: pydantic>=2
18
18
  Description-Content-Type: text/markdown
19
19
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "imbi-plugin-github"
3
- version = "1.5.0"
3
+ version = "2.8.0"
4
4
  description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -16,7 +16,7 @@ classifiers = [
16
16
  ]
17
17
  dependencies = [
18
18
  "httpx>=0.27",
19
- "imbi-common>=2.7.0",
19
+ "imbi-common==2.8.0",
20
20
  "pydantic>=2",
21
21
  ]
22
22
 
@@ -35,7 +35,7 @@ github-lifecycle-es = "imbi_plugin_github.lifecycle:GitHubEnterpriseServerLifecy
35
35
  dev = [
36
36
  "basedpyright",
37
37
  "coverage[toml]",
38
- "imbi-common[server,databases]",
38
+ "imbi-common[server,databases]==2.8.0",
39
39
  "pre-commit",
40
40
  "pytest",
41
41
  "pytest-asyncio",
@@ -91,7 +91,11 @@ def derive_owner_repo_from_links(
91
91
 
92
92
 
93
93
  def resolve_owner_repo(
94
- ctx: PluginContext, host: str, plugin_label: str
94
+ ctx: PluginContext,
95
+ host: str,
96
+ plugin_label: str,
97
+ *,
98
+ prefer_previous_slug: bool = False,
95
99
  ) -> tuple[str, str]:
96
100
  """Resolve the repo for a plugin call from ``ctx``.
97
101
 
@@ -100,12 +104,21 @@ def resolve_owner_repo(
100
104
  :class:`ValueError` when neither path produces a candidate so the
101
105
  caller can surface a clear "set the GitHub Repository link"
102
106
  message to the operator.
107
+
108
+ ``prefer_previous_slug`` makes the slug fallback prefer
109
+ ``ctx.previous_project_slug`` (when set) over ``ctx.project_slug``
110
+ so callers reacting to a slug rename (e.g. ``on_project_updated``)
111
+ can still locate the pre-rename repo on GitHub.
103
112
  """
104
113
  derived = derive_owner_repo_from_links(ctx.project_links, host)
105
114
  if derived is not None:
106
115
  return derived
107
- if ctx.project_type_slugs and ctx.project_slug:
108
- return ctx.project_type_slugs[0], ctx.project_slug
116
+ if ctx.project_type_slugs:
117
+ slug = ctx.project_slug
118
+ if prefer_previous_slug and ctx.previous_project_slug:
119
+ slug = ctx.previous_project_slug
120
+ if slug:
121
+ return ctx.project_type_slugs[0], slug
109
122
  raise ValueError(
110
123
  f'{plugin_label} could not determine the target repository: '
111
124
  "set the project's GitHub Repository link or tag the project "
@@ -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.RepositoryRelocation` on ``ctx``
360
- so the host can self-heal the project's stored link. This is the
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.repository_relocation is None:
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.repository_relocation`` unset so the user-facing call (which
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.repository_relocation = RepositoryRelocation(
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
- RepositoryRelocation,
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 resolve_owner_repo
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 relocation when the repo moved out from under the link.
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:`RepositoryRelocation` on ``ctx`` for the host to self-heal
297
- the stored link. No-op when they match.
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.repository_relocation = RepositoryRelocation(
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 archive / unarchive on github.com by '
422
- 'archiving (or unarchiving) the matching repository. '
423
- 'Optionally transfers the repo to a sunset organization '
424
- 'before archiving so retired projects do not crowd primary '
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 archive / unarchive on a GHEC tenant '
444
- '(``*.ghe.com``) by archiving (or unarchiving) the matching '
445
- 'repository; optional transfer to a sunset org first.'
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 archive / unarchive on a GHES install by '
475
- 'archiving (or unarchiving) the matching repository; '
476
- 'optional transfer to a sunset org first.'
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.repository_relocation
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.repository_relocation)
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.repository_relocation)
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.repository_relocation)
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='demo',
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.repository_relocation
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.repository_relocation
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.repository_relocation)
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.repository_relocation)
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.7.0"
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/4b/b0/5f28ac0670d239c885822e5a7c9a94b2bb9c1ea205dccba82ac2ae36f329/imbi_common-2.7.0.tar.gz", hash = "sha256:723d2a7ecffef3f0cf86828173586a9721931b95068718c7b0c7cff768c7f764", size = 303583, upload-time = "2026-05-28T12:56:17.525Z" }
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/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" },
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 = "1.5.0"
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 = ">=2.7.0" },
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" },