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.
Files changed (22) hide show
  1. imbi_plugin_github-1.4.0/CLAUDE.md +128 -0
  2. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/PKG-INFO +10 -8
  3. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/README.md +8 -6
  4. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/pyproject.toml +10 -8
  5. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/deployment.py +221 -38
  6. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/identity.py +2 -3
  7. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/lifecycle.py +53 -2
  8. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/tests/test_deployment.py +277 -50
  9. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/tests/test_lifecycle.py +122 -0
  10. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/uv.lock +51 -26
  11. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/.github/workflows/publish.yml +0 -0
  12. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/.github/workflows/test.yml +0 -0
  13. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/.gitignore +0 -0
  14. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/.pre-commit-config.yaml +0 -0
  15. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/LICENSE +0 -0
  16. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/justfile +0 -0
  17. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/__init__.py +0 -0
  18. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/_hosts.py +0 -0
  19. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/src/imbi_plugin_github/_repos.py +0 -0
  20. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/tests/__init__.py +0 -0
  21. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.4.0}/tests/test_hosts.py +0 -0
  22. {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.1.0
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>=0.11.0
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
- Phase 1 ships the OAuth App flow only; GitHub App installation tokens are
37
- deferred. The access token returned by the OAuth grant is passed straight
38
- to GitHub APIs as a `Bearer` token, so `materialize()` is a no-op.
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. See `deployment.py` for the per-environment
44
- `DEPLOYS_VIA` edge contract.
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
- Phase 1 ships the OAuth App flow only; GitHub App installation tokens are
18
- deferred. The access token returned by the OAuth grant is passed straight
19
- to GitHub APIs as a `Bearer` token, so `materialize()` is a no-op.
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. See `deployment.py` for the per-environment
25
- `DEPLOYS_VIA` edge contract.
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.1.0"
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>=0.11.0",
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.pytest.ini_options]
64
- asyncio_mode = "auto"
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 (Phase 2 — implemented here for completeness) -----
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 wins): per-env edge ``payload`` from
673
- ``ctx.environment_config['payload']`` over ``inputs`` from the
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(inputs or {})
682
- env_payload = ctx.environment_config.get('payload')
683
- if isinstance(env_payload, dict):
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
- wires up the per-environment ``DEPLOYS_VIA`` edge. Returns only
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
- _COMMON_EDGE_LABELS: list[PluginEdgeLabel] = [
846
- PluginEdgeLabel(
847
- name='DEPLOYS_VIA',
848
- # The edge can hang off the ProjectType (the usual case — every
849
- # python-api project deploys to staging via release, etc.) or a
850
- # specific Project (rare override slot).
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
- Phase 1 implements the OAuth App flow only. GitHub App installation
11
- tokens (org-scoped automation) are deferred service principals
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