imbi-plugin-github 1.3.0__tar.gz → 1.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- imbi_plugin_github-1.4.0/CLAUDE.md +128 -0
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/PKG-INFO +5 -5
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/README.md +4 -4
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/pyproject.toml +3 -1
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/deployment.py +1 -1
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/identity.py +2 -3
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/lifecycle.py +53 -2
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/tests/test_lifecycle.py +122 -0
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/uv.lock +16 -1
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/.github/workflows/publish.yml +0 -0
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/.github/workflows/test.yml +0 -0
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/.gitignore +0 -0
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/.pre-commit-config.yaml +0 -0
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/LICENSE +0 -0
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/justfile +0 -0
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/__init__.py +0 -0
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/_hosts.py +0 -0
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/_repos.py +0 -0
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/tests/test_deployment.py +0 -0
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-1.3.0 → imbi_plugin_github-1.4.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.4.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
|
|
@@ -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.4.0"
|
|
4
4
|
description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -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"
|
|
@@ -580,7 +580,7 @@ class _DeploymentBase(DeploymentPlugin):
|
|
|
580
580
|
deletions=deletions,
|
|
581
581
|
)
|
|
582
582
|
|
|
583
|
-
# -- Tags / Releases
|
|
583
|
+
# -- Tags / Releases ----------------------------------------------------
|
|
584
584
|
|
|
585
585
|
async def create_tag(
|
|
586
586
|
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
|
|
|
@@ -56,6 +67,13 @@ LOGGER = logging.getLogger(__name__)
|
|
|
56
67
|
|
|
57
68
|
_HTTP_TIMEOUT_SECONDS = 10.0
|
|
58
69
|
|
|
70
|
+
# Backoffs (seconds) between attempts to archive a freshly-transferred
|
|
71
|
+
# repo while GitHub's async transfer settles. len + 1 == total
|
|
72
|
+
# attempts; the sum is kept well under the dispatcher's per-plugin
|
|
73
|
+
# timeout (default 10s) so a stuck transfer fails fast rather than
|
|
74
|
+
# hanging the operator's archive request.
|
|
75
|
+
_TRANSFER_ARCHIVE_BACKOFFS: tuple[float, ...] = (0.5, 1.0, 2.0)
|
|
76
|
+
|
|
59
77
|
|
|
60
78
|
async def _raise_on_401(response: httpx.Response) -> None:
|
|
61
79
|
"""Convert a 401 from GitHub into :class:`PluginAuthenticationFailed`.
|
|
@@ -172,8 +190,11 @@ class _LifecycleBase(LifecyclePlugin):
|
|
|
172
190
|
repo = str(transferred.get('name') or repo)
|
|
173
191
|
owner = target_org or current_owner
|
|
174
192
|
current_owner = owner
|
|
175
|
-
|
|
176
|
-
|
|
193
|
+
# The repo may not be reachable at the destination
|
|
194
|
+
# owner yet; tolerate the transfer-settle 404 window.
|
|
195
|
+
await self._archive_after_transfer(client, current_owner, repo)
|
|
196
|
+
else:
|
|
197
|
+
await self._set_archived(client, current_owner, repo, True)
|
|
177
198
|
|
|
178
199
|
return LifecycleResult(
|
|
179
200
|
status='ok',
|
|
@@ -249,6 +270,36 @@ class _LifecycleBase(LifecyclePlugin):
|
|
|
249
270
|
)
|
|
250
271
|
resp.raise_for_status()
|
|
251
272
|
|
|
273
|
+
async def _archive_after_transfer(
|
|
274
|
+
self,
|
|
275
|
+
client: httpx.AsyncClient,
|
|
276
|
+
owner: str,
|
|
277
|
+
repo: str,
|
|
278
|
+
) -> None:
|
|
279
|
+
"""Archive a freshly-transferred repo, retrying the 404 window.
|
|
280
|
+
|
|
281
|
+
GitHub's transfer is async: the repo is briefly unreachable at
|
|
282
|
+
the destination owner, so the archive PATCH 404s until the
|
|
283
|
+
transfer settles. Retry only on 404 — any other status (auth,
|
|
284
|
+
permissions, validation) is a real failure and re-raises
|
|
285
|
+
immediately.
|
|
286
|
+
"""
|
|
287
|
+
for backoff in (*_TRANSFER_ARCHIVE_BACKOFFS, None):
|
|
288
|
+
try:
|
|
289
|
+
await self._set_archived(client, owner, repo, True)
|
|
290
|
+
return
|
|
291
|
+
except httpx.HTTPStatusError as exc:
|
|
292
|
+
if exc.response.status_code != 404 or backoff is None:
|
|
293
|
+
raise
|
|
294
|
+
LOGGER.info(
|
|
295
|
+
'Repo %s/%s not yet reachable after transfer; '
|
|
296
|
+
'retrying archive in %ss',
|
|
297
|
+
owner,
|
|
298
|
+
repo,
|
|
299
|
+
backoff,
|
|
300
|
+
)
|
|
301
|
+
await asyncio.sleep(backoff)
|
|
302
|
+
|
|
252
303
|
async def _transfer(
|
|
253
304
|
self,
|
|
254
305
|
client: httpx.AsyncClient,
|
|
@@ -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
|
|
@@ -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"
|
|
@@ -688,7 +703,7 @@ server = [
|
|
|
688
703
|
|
|
689
704
|
[[package]]
|
|
690
705
|
name = "imbi-plugin-github"
|
|
691
|
-
version = "1.
|
|
706
|
+
version = "1.4.0"
|
|
692
707
|
source = { editable = "." }
|
|
693
708
|
dependencies = [
|
|
694
709
|
{ name = "httpx" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|