imbi-plugin-github 1.1.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.1.0 → imbi_plugin_github-1.4.0}/PKG-INFO +10 -8
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/README.md +8 -6
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/pyproject.toml +10 -8
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/deployment.py +221 -38
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/identity.py +2 -3
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/lifecycle.py +53 -2
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/tests/test_deployment.py +277 -50
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/tests/test_lifecycle.py +122 -0
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/uv.lock +51 -26
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/.github/workflows/publish.yml +0 -0
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/.github/workflows/test.yml +0 -0
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/.gitignore +0 -0
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/.pre-commit-config.yaml +0 -0
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/LICENSE +0 -0
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/justfile +0 -0
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/__init__.py +0 -0
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/_hosts.py +0 -0
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/_repos.py +0 -0
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-1.1.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
|
|
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.14
|
|
14
14
|
Requires-Python: >=3.14
|
|
15
15
|
Requires-Dist: httpx>=0.27
|
|
16
|
-
Requires-Dist: imbi-common>=
|
|
16
|
+
Requires-Dist: imbi-common>=2.5.5
|
|
17
17
|
Requires-Dist: pydantic>=2
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
|
|
@@ -33,15 +33,17 @@ 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
|
|
|
42
42
|
Drives the GitHub Deployments API (`POST /repos/{owner}/{repo}/deployments`)
|
|
43
|
-
plus tag and release creation.
|
|
44
|
-
|
|
43
|
+
plus tag and release creation. Promote behaviour is inferred from the
|
|
44
|
+
ref shape by the host (semver → trigger Deployment, raw SHA → cut tag
|
|
45
|
+
+ Release). Per-env workflow inputs ride on the `USES_PLUGIN` edge as
|
|
46
|
+
`env_payloads` and arrive on `PluginContext.environment_config`.
|
|
45
47
|
|
|
46
48
|
### Lifecycle
|
|
47
49
|
|
|
@@ -66,7 +68,7 @@ requires admin permission on the target organization.
|
|
|
66
68
|
| Option | Required | Description |
|
|
67
69
|
| ---------------- | --------- | -------------------------------------------------------------------------- |
|
|
68
70
|
| `host` | GHEC/GHES | Tenant or appliance host (e.g. `tenant.ghe.com`, `github.example.com`). |
|
|
69
|
-
| `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`). |
|
|
70
72
|
|
|
71
73
|
## Credentials (identity)
|
|
72
74
|
|
|
@@ -14,15 +14,17 @@ 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
|
|
|
23
23
|
Drives the GitHub Deployments API (`POST /repos/{owner}/{repo}/deployments`)
|
|
24
|
-
plus tag and release creation.
|
|
25
|
-
|
|
24
|
+
plus tag and release creation. Promote behaviour is inferred from the
|
|
25
|
+
ref shape by the host (semver → trigger Deployment, raw SHA → cut tag
|
|
26
|
+
+ Release). Per-env workflow inputs ride on the `USES_PLUGIN` edge as
|
|
27
|
+
`env_payloads` and arrive on `PluginContext.environment_config`.
|
|
26
28
|
|
|
27
29
|
### Lifecycle
|
|
28
30
|
|
|
@@ -47,7 +49,7 @@ requires admin permission on the target organization.
|
|
|
47
49
|
| Option | Required | Description |
|
|
48
50
|
| ---------------- | --------- | -------------------------------------------------------------------------- |
|
|
49
51
|
| `host` | GHEC/GHES | Tenant or appliance host (e.g. `tenant.ghe.com`, `github.example.com`). |
|
|
50
|
-
| `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`). |
|
|
51
53
|
|
|
52
54
|
## Credentials (identity)
|
|
53
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"
|
|
@@ -16,7 +16,7 @@ classifiers = [
|
|
|
16
16
|
]
|
|
17
17
|
dependencies = [
|
|
18
18
|
"httpx>=0.27",
|
|
19
|
-
"imbi-common>=
|
|
19
|
+
"imbi-common>=2.5.5",
|
|
20
20
|
"pydantic>=2",
|
|
21
21
|
]
|
|
22
22
|
|
|
@@ -48,9 +48,6 @@ dist = ["build", "twine", "wheel"]
|
|
|
48
48
|
requires = ["hatchling"]
|
|
49
49
|
build-backend = "hatchling.build"
|
|
50
50
|
|
|
51
|
-
[tool.hatch.build.targets.wheel]
|
|
52
|
-
packages = ["src/imbi_plugin_github"]
|
|
53
|
-
|
|
54
51
|
[tool.coverage.report]
|
|
55
52
|
exclude_also = ["typing.TYPE_CHECKING"]
|
|
56
53
|
fail_under = 85
|
|
@@ -60,9 +57,8 @@ show_missing = true
|
|
|
60
57
|
branch = true
|
|
61
58
|
source = ["src/imbi_plugin_github"]
|
|
62
59
|
|
|
63
|
-
[tool.
|
|
64
|
-
|
|
65
|
-
asyncio_default_fixture_loop_scope = "function"
|
|
60
|
+
[tool.hatch.build.targets.wheel]
|
|
61
|
+
packages = ["src/imbi_plugin_github"]
|
|
66
62
|
|
|
67
63
|
[tool.pyright]
|
|
68
64
|
executionEnvironments = [
|
|
@@ -74,6 +70,10 @@ reportMissingSuperCall = "hint"
|
|
|
74
70
|
reportMissingTypeStubs = false
|
|
75
71
|
typeCheckingMode = "strict"
|
|
76
72
|
|
|
73
|
+
[tool.pytest.ini_options]
|
|
74
|
+
asyncio_mode = "auto"
|
|
75
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
76
|
+
|
|
77
77
|
[tool.ruff]
|
|
78
78
|
line-length = 79
|
|
79
79
|
target-version = "py314"
|
|
@@ -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"
|
|
@@ -38,8 +38,10 @@ from imbi_common.plugins.base import (
|
|
|
38
38
|
Commit,
|
|
39
39
|
CompareResult,
|
|
40
40
|
CredentialField,
|
|
41
|
+
DeploymentEventStatus,
|
|
41
42
|
DeploymentPlugin,
|
|
42
43
|
DeploymentRun,
|
|
44
|
+
OpsLogTemplate,
|
|
43
45
|
PluginContext,
|
|
44
46
|
PluginEdgeLabel,
|
|
45
47
|
PluginManifest,
|
|
@@ -47,6 +49,7 @@ from imbi_common.plugins.base import (
|
|
|
47
49
|
Ref,
|
|
48
50
|
RefInfo,
|
|
49
51
|
ReleaseInfo,
|
|
52
|
+
RemoteDeployment,
|
|
50
53
|
WorkflowFile,
|
|
51
54
|
)
|
|
52
55
|
from imbi_common.plugins.errors import PluginAuthenticationFailed
|
|
@@ -577,7 +580,7 @@ class _DeploymentBase(DeploymentPlugin):
|
|
|
577
580
|
deletions=deletions,
|
|
578
581
|
)
|
|
579
582
|
|
|
580
|
-
# -- Tags / Releases
|
|
583
|
+
# -- Tags / Releases ----------------------------------------------------
|
|
581
584
|
|
|
582
585
|
async def create_tag(
|
|
583
586
|
self,
|
|
@@ -669,8 +672,9 @@ class _DeploymentBase(DeploymentPlugin):
|
|
|
669
672
|
promote refs are often freshly-cut tags whose CI hasn't run yet,
|
|
670
673
|
and the deploy workflow itself is what we're waiting on.
|
|
671
674
|
|
|
672
|
-
Payload precedence (highest
|
|
673
|
-
``
|
|
675
|
+
Payload precedence (lowest → highest): plugin assignment
|
|
676
|
+
``env_payloads[env_slug]`` (carried by the host on
|
|
677
|
+
``ctx.environment_config``) below the ``inputs`` map from the
|
|
674
678
|
caller. The ``ref`` and ``environment`` are not part of the
|
|
675
679
|
payload — they're top-level fields on the deployment object.
|
|
676
680
|
"""
|
|
@@ -678,12 +682,9 @@ class _DeploymentBase(DeploymentPlugin):
|
|
|
678
682
|
raise ValueError(
|
|
679
683
|
'trigger_deployment requires PluginContext.environment'
|
|
680
684
|
)
|
|
681
|
-
merged_payload: dict[str, typing.Any] = dict(
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
merged_payload.update(
|
|
685
|
-
typing.cast(dict[str, typing.Any], env_payload)
|
|
686
|
-
)
|
|
685
|
+
merged_payload: dict[str, typing.Any] = dict(ctx.environment_config)
|
|
686
|
+
if inputs:
|
|
687
|
+
merged_payload.update(inputs)
|
|
687
688
|
async with self._client(ctx, credentials) as client:
|
|
688
689
|
resp = await client.post(
|
|
689
690
|
'/deployments',
|
|
@@ -717,7 +718,7 @@ class _DeploymentBase(DeploymentPlugin):
|
|
|
717
718
|
"""List ``.github/workflows/*.yml`` registered for the repo.
|
|
718
719
|
|
|
719
720
|
Used by the UI to populate a workflow dropdown when an operator
|
|
720
|
-
|
|
721
|
+
configures plugin assignment ``env_payloads``. Returns only
|
|
721
722
|
``active`` workflows by default; callers that need disabled
|
|
722
723
|
entries can filter the result themselves. GitHub caps the
|
|
723
724
|
``/actions/workflows`` page at 100 — that's more than enough for
|
|
@@ -839,37 +840,186 @@ class _DeploymentBase(DeploymentPlugin):
|
|
|
839
840
|
else None,
|
|
840
841
|
)
|
|
841
842
|
|
|
843
|
+
async def list_recent_deployments(
|
|
844
|
+
self,
|
|
845
|
+
ctx: PluginContext,
|
|
846
|
+
credentials: dict[str, str],
|
|
847
|
+
environments: list[str],
|
|
848
|
+
limit: int = 1,
|
|
849
|
+
) -> list[RemoteDeployment]:
|
|
850
|
+
"""Return the latest ``limit`` deployments per environment.
|
|
851
|
+
|
|
852
|
+
Fans out one ``GET /deployments?environment={env}`` call per
|
|
853
|
+
requested environment in parallel, then for each returned
|
|
854
|
+
deployment fetches the latest status via ``GET
|
|
855
|
+
/deployments/{id}/statuses?per_page=1``. Environments the
|
|
856
|
+
remote does not recognise are silently skipped so a partial
|
|
857
|
+
resync still returns the deployments that do exist (an env
|
|
858
|
+
the repo simply hasn't deployed to yet is the common case).
|
|
859
|
+
|
|
860
|
+
The host calls this from the resync flow only when webhook
|
|
861
|
+
delivery has lapsed, so we keep the fan-out modest (``limit=1``
|
|
862
|
+
is the host's default) and let the host walk further history
|
|
863
|
+
with explicit pagination if it ever needs to.
|
|
864
|
+
"""
|
|
865
|
+
page_size = max(1, min(limit, 100))
|
|
866
|
+
async with self._client(ctx, credentials) as client:
|
|
867
|
+
per_env = await asyncio.gather(
|
|
868
|
+
*(
|
|
869
|
+
self._list_deployments_for_env(client, env, page_size)
|
|
870
|
+
for env in environments
|
|
871
|
+
)
|
|
872
|
+
)
|
|
873
|
+
return [observed for group in per_env for observed in group]
|
|
874
|
+
|
|
875
|
+
async def _list_deployments_for_env(
|
|
876
|
+
self,
|
|
877
|
+
client: httpx.AsyncClient,
|
|
878
|
+
environment: str,
|
|
879
|
+
page_size: int,
|
|
880
|
+
) -> list[RemoteDeployment]:
|
|
881
|
+
try:
|
|
882
|
+
resp = await client.get(
|
|
883
|
+
'/deployments',
|
|
884
|
+
params={
|
|
885
|
+
'environment': environment,
|
|
886
|
+
'per_page': str(page_size),
|
|
887
|
+
},
|
|
888
|
+
)
|
|
889
|
+
if resp.status_code == 404:
|
|
890
|
+
# Repo or environment unknown on the remote — treat as
|
|
891
|
+
# "nothing to backfill" rather than failing the resync.
|
|
892
|
+
return []
|
|
893
|
+
resp.raise_for_status()
|
|
894
|
+
except httpx.HTTPError:
|
|
895
|
+
LOGGER.warning(
|
|
896
|
+
'Failed to list deployments for env=%s',
|
|
897
|
+
environment,
|
|
898
|
+
exc_info=True,
|
|
899
|
+
)
|
|
900
|
+
return []
|
|
901
|
+
try:
|
|
902
|
+
deployments = typing.cast(list[dict[str, typing.Any]], resp.json())
|
|
903
|
+
except ValueError:
|
|
904
|
+
LOGGER.warning(
|
|
905
|
+
'Failed to parse deployments payload for env=%s',
|
|
906
|
+
environment,
|
|
907
|
+
)
|
|
908
|
+
return []
|
|
909
|
+
observed: list[RemoteDeployment] = []
|
|
910
|
+
for deployment in deployments:
|
|
911
|
+
run = await self._observe_deployment(
|
|
912
|
+
client, environment, deployment
|
|
913
|
+
)
|
|
914
|
+
if run is not None:
|
|
915
|
+
observed.append(run)
|
|
916
|
+
return observed
|
|
917
|
+
|
|
918
|
+
async def _observe_deployment(
|
|
919
|
+
self,
|
|
920
|
+
client: httpx.AsyncClient,
|
|
921
|
+
environment: str,
|
|
922
|
+
deployment: dict[str, typing.Any],
|
|
923
|
+
) -> RemoteDeployment | None:
|
|
924
|
+
deployment_id = deployment.get('id')
|
|
925
|
+
sha = deployment.get('sha')
|
|
926
|
+
if not deployment_id or not sha:
|
|
927
|
+
# GitHub always returns both, but defend the resync path
|
|
928
|
+
# against a malformed response — we'd rather skip one row
|
|
929
|
+
# than corrupt the graph by inventing identifiers.
|
|
930
|
+
return None
|
|
931
|
+
created_at = _parse_iso(deployment.get('created_at')) or (
|
|
932
|
+
datetime.datetime.now(datetime.UTC)
|
|
933
|
+
)
|
|
934
|
+
status, status_url = await self._latest_status(
|
|
935
|
+
client, str(deployment_id)
|
|
936
|
+
)
|
|
937
|
+
ref_value = deployment.get('ref')
|
|
938
|
+
description = deployment.get('description')
|
|
939
|
+
deployment_url = deployment.get('url') or deployment.get('html_url')
|
|
940
|
+
creator_login: str | None = None
|
|
941
|
+
creator_raw = deployment.get('creator')
|
|
942
|
+
if isinstance(creator_raw, dict):
|
|
943
|
+
creator_dict = typing.cast(dict[str, typing.Any], creator_raw)
|
|
944
|
+
login = creator_dict.get('login')
|
|
945
|
+
if isinstance(login, str) and login:
|
|
946
|
+
creator_login = login
|
|
947
|
+
return RemoteDeployment(
|
|
948
|
+
environment=environment,
|
|
949
|
+
sha=str(sha),
|
|
950
|
+
ref=str(ref_value) if ref_value else None,
|
|
951
|
+
status=status,
|
|
952
|
+
created_at=created_at,
|
|
953
|
+
external_run_id=str(deployment_id),
|
|
954
|
+
run_url=status_url,
|
|
955
|
+
deployment_url=str(deployment_url) if deployment_url else None,
|
|
956
|
+
description=str(description) if description else None,
|
|
957
|
+
creator=creator_login,
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
async def _latest_status(
|
|
961
|
+
self, client: httpx.AsyncClient, deployment_id: str
|
|
962
|
+
) -> tuple[DeploymentEventStatus, str | None]:
|
|
963
|
+
"""Return the canonical event status + workflow log URL.
|
|
964
|
+
|
|
965
|
+
Falls back to ``'pending'`` whenever the deploy workflow has
|
|
966
|
+
not yet posted a status: a freshly-created deployment with no
|
|
967
|
+
statuses is structurally identical to one whose workflow has
|
|
968
|
+
not started, and ``pending`` is the host's vocabulary for
|
|
969
|
+
both. Network / parse errors degrade the same way so resync
|
|
970
|
+
is never blocked by a single noisy row.
|
|
971
|
+
"""
|
|
972
|
+
try:
|
|
973
|
+
resp = await client.get(
|
|
974
|
+
f'/deployments/{deployment_id}/statuses',
|
|
975
|
+
params={'per_page': '1'},
|
|
976
|
+
)
|
|
977
|
+
except httpx.HTTPError:
|
|
978
|
+
return 'pending', None
|
|
979
|
+
if resp.status_code != 200:
|
|
980
|
+
return 'pending', None
|
|
981
|
+
try:
|
|
982
|
+
statuses = typing.cast(list[dict[str, typing.Any]], resp.json())
|
|
983
|
+
except ValueError:
|
|
984
|
+
return 'pending', None
|
|
985
|
+
if not statuses:
|
|
986
|
+
return 'pending', None
|
|
987
|
+
latest = statuses[0]
|
|
988
|
+
state = str(latest.get('state') or '').lower()
|
|
989
|
+
log_url = latest.get('log_url') or latest.get('target_url')
|
|
990
|
+
return _to_event_status(state), str(log_url) if log_url else None
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _to_event_status(github_state: str) -> DeploymentEventStatus:
|
|
994
|
+
"""Map a GitHub deployment-status ``state`` to the host vocabulary.
|
|
995
|
+
|
|
996
|
+
Unknown states fold to ``pending`` rather than raising so a single
|
|
997
|
+
novel value on the remote does not break resync for the whole
|
|
998
|
+
project. ``inactive`` on GitHub means a newer deployment for the
|
|
999
|
+
same environment superseded this one, which the host models as
|
|
1000
|
+
``rolled_back`` on the ``DeploymentEvent``.
|
|
1001
|
+
"""
|
|
1002
|
+
if github_state in {'pending', 'queued', 'waiting'}:
|
|
1003
|
+
return 'pending'
|
|
1004
|
+
if github_state == 'in_progress':
|
|
1005
|
+
return 'in_progress'
|
|
1006
|
+
if github_state == 'success':
|
|
1007
|
+
return 'success'
|
|
1008
|
+
if github_state in {'failure', 'error'}:
|
|
1009
|
+
return 'failed'
|
|
1010
|
+
if github_state == 'inactive':
|
|
1011
|
+
return 'rolled_back'
|
|
1012
|
+
return 'pending'
|
|
1013
|
+
|
|
842
1014
|
|
|
843
1015
|
_COMMON_OPTIONS: list[PluginOption] = []
|
|
844
1016
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
from_labels=['ProjectType', 'Project'],
|
|
852
|
-
to_labels=['Environment'],
|
|
853
|
-
properties={
|
|
854
|
-
# 'release' -> create_tag + create_release; no deployment
|
|
855
|
-
# (the repo's ``on: release: [published]``
|
|
856
|
-
# trigger handles it).
|
|
857
|
-
# 'deployment' -> POST /deployments only (no tag/release).
|
|
858
|
-
'action': 'str',
|
|
859
|
-
# Free-form key/value pairs forwarded as the GitHub
|
|
860
|
-
# deployment ``payload``. The deploy workflow reads them
|
|
861
|
-
# via ``github.event.deployment.payload`` -- so this is
|
|
862
|
-
# where operators stash things like a target cluster name,
|
|
863
|
-
# a feature flag bundle, etc. Empty by default.
|
|
864
|
-
'payload': 'dict[str, str]',
|
|
865
|
-
# Per-environment identity override. Lets ``production``
|
|
866
|
-
# post the deployment as a different OAuth identity than
|
|
867
|
-
# ``staging`` -- handy when prod requires a service
|
|
868
|
-
# account with extra deploy scopes.
|
|
869
|
-
'identity_plugin_id': 'str',
|
|
870
|
-
},
|
|
871
|
-
),
|
|
872
|
-
]
|
|
1017
|
+
# Promote behaviour is now inferred from the ``body.tag`` shape on the
|
|
1018
|
+
# imbi-api side: semver tags trigger a Deployment, raw SHAs cut a tag
|
|
1019
|
+
# + GitHub Release. Per-env workflow input overrides live on the
|
|
1020
|
+
# ``USES_PLUGIN`` edge under ``env_payloads`` (keyed by env slug),
|
|
1021
|
+
# resolved by the host and passed in via ``ctx.environment_config``.
|
|
1022
|
+
_COMMON_EDGE_LABELS: list[PluginEdgeLabel] = []
|
|
873
1023
|
|
|
874
1024
|
_COMMON_CREDENTIALS: list[CredentialField] = [
|
|
875
1025
|
CredentialField(
|
|
@@ -884,6 +1034,33 @@ _COMMON_CREDENTIALS: list[CredentialField] = [
|
|
|
884
1034
|
]
|
|
885
1035
|
|
|
886
1036
|
|
|
1037
|
+
# Templates for the operations-log JSON payload the API writes from
|
|
1038
|
+
# ``_record_deployment_event`` in imbi-api: ``{action, plugin_slug,
|
|
1039
|
+
# run_url, release_url, from_environment}``. Row-level fields
|
|
1040
|
+
# ``version`` and ``environment`` (from the entry's
|
|
1041
|
+
# ``environment_slug``/``environment.name``) are also in scope.
|
|
1042
|
+
_COMMON_OPS_LOG_TEMPLATES: dict[str, OpsLogTemplate] = {
|
|
1043
|
+
'deploy': OpsLogTemplate(
|
|
1044
|
+
label='Deployed {{version}} to {{environment}}',
|
|
1045
|
+
summary='deployed',
|
|
1046
|
+
),
|
|
1047
|
+
'redeploy': OpsLogTemplate(
|
|
1048
|
+
label='Re-deployed {{version}} to {{environment}}',
|
|
1049
|
+
summary='re-deployed',
|
|
1050
|
+
),
|
|
1051
|
+
'promote': OpsLogTemplate(
|
|
1052
|
+
label=(
|
|
1053
|
+
'Promoted {{from_environment}} to {{environment}} as {{version}}.'
|
|
1054
|
+
),
|
|
1055
|
+
summary='promoted',
|
|
1056
|
+
),
|
|
1057
|
+
'resync': OpsLogTemplate(
|
|
1058
|
+
label='Recorded {{version}} deploy in {{environment}}',
|
|
1059
|
+
summary='recorded a deploy in',
|
|
1060
|
+
),
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
|
|
887
1064
|
class GitHubDeploymentPlugin(_DeploymentBase):
|
|
888
1065
|
manifest = PluginManifest(
|
|
889
1066
|
slug='github-deployment',
|
|
@@ -896,9 +1073,11 @@ class GitHubDeploymentPlugin(_DeploymentBase):
|
|
|
896
1073
|
'server-side.'
|
|
897
1074
|
),
|
|
898
1075
|
plugin_type='deployment',
|
|
1076
|
+
supports_deployment_sync=True,
|
|
899
1077
|
options=_COMMON_OPTIONS,
|
|
900
1078
|
credentials=_COMMON_CREDENTIALS,
|
|
901
1079
|
edge_labels=_COMMON_EDGE_LABELS,
|
|
1080
|
+
ops_log_templates=_COMMON_OPS_LOG_TEMPLATES,
|
|
902
1081
|
)
|
|
903
1082
|
|
|
904
1083
|
@classmethod
|
|
@@ -916,6 +1095,7 @@ class GitHubEnterpriseCloudDeploymentPlugin(_DeploymentBase):
|
|
|
916
1095
|
'``on: deployment_status``) in their deploy workflow.'
|
|
917
1096
|
),
|
|
918
1097
|
plugin_type='deployment',
|
|
1098
|
+
supports_deployment_sync=True,
|
|
919
1099
|
options=[
|
|
920
1100
|
PluginOption(
|
|
921
1101
|
name='host',
|
|
@@ -928,6 +1108,7 @@ class GitHubEnterpriseCloudDeploymentPlugin(_DeploymentBase):
|
|
|
928
1108
|
],
|
|
929
1109
|
credentials=_COMMON_CREDENTIALS,
|
|
930
1110
|
edge_labels=_COMMON_EDGE_LABELS,
|
|
1111
|
+
ops_log_templates=_COMMON_OPS_LOG_TEMPLATES,
|
|
931
1112
|
)
|
|
932
1113
|
|
|
933
1114
|
@classmethod
|
|
@@ -948,6 +1129,7 @@ class GitHubEnterpriseServerDeploymentPlugin(_DeploymentBase):
|
|
|
948
1129
|
'in their deploy workflow.'
|
|
949
1130
|
),
|
|
950
1131
|
plugin_type='deployment',
|
|
1132
|
+
supports_deployment_sync=True,
|
|
951
1133
|
options=[
|
|
952
1134
|
PluginOption(
|
|
953
1135
|
name='host',
|
|
@@ -959,6 +1141,7 @@ class GitHubEnterpriseServerDeploymentPlugin(_DeploymentBase):
|
|
|
959
1141
|
],
|
|
960
1142
|
credentials=_COMMON_CREDENTIALS,
|
|
961
1143
|
edge_labels=_COMMON_EDGE_LABELS,
|
|
1144
|
+
ops_log_templates=_COMMON_OPS_LOG_TEMPLATES,
|
|
962
1145
|
)
|
|
963
1146
|
|
|
964
1147
|
@classmethod
|
|
@@ -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
|