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