imbi-plugin-github 2.16.1__tar.gz → 2.18.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 (36) hide show
  1. imbi_plugin_github-2.18.0/.gitignore +22 -0
  2. {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/PKG-INFO +3 -4
  3. imbi_plugin_github-2.18.0/moon.yml +22 -0
  4. imbi_plugin_github-2.18.0/pyproject.toml +46 -0
  5. imbi_plugin_github-2.18.0/src/imbi/plugins/github/README.md +107 -0
  6. {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/__init__.py +2 -2
  7. {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/_app_auth.py +2 -2
  8. {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/_repos.py +1 -1
  9. {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/commits.py +20 -21
  10. {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/deployment.py +110 -10
  11. {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/doctor.py +6 -6
  12. {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/identity.py +3 -3
  13. {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/lifecycle.py +6 -6
  14. {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/plugin.py +7 -8
  15. {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/pull_requests.py +14 -14
  16. imbi_plugin_github-2.18.0/tests/__init__.py +0 -0
  17. {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/tests/test_commits.py +8 -8
  18. {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/tests/test_deployment.py +236 -10
  19. {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/tests/test_doctor.py +6 -6
  20. {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/tests/test_hosts.py +1 -1
  21. {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/tests/test_identity.py +4 -4
  22. {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/tests/test_lifecycle.py +8 -8
  23. {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/tests/test_pull_requests.py +5 -5
  24. {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/tests/test_repos.py +2 -2
  25. imbi_plugin_github-2.16.1/.github/workflows/publish.yml +0 -45
  26. imbi_plugin_github-2.16.1/.github/workflows/test.yml +0 -54
  27. imbi_plugin_github-2.16.1/.gitignore +0 -10
  28. imbi_plugin_github-2.16.1/.pre-commit-config.yaml +0 -39
  29. imbi_plugin_github-2.16.1/CLAUDE.md +0 -138
  30. imbi_plugin_github-2.16.1/LICENSE +0 -28
  31. imbi_plugin_github-2.16.1/justfile +0 -59
  32. imbi_plugin_github-2.16.1/pyproject.toml +0 -119
  33. imbi_plugin_github-2.16.1/uv.lock +0 -2024
  34. {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/README.md +0 -0
  35. {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/_hosts.py +0 -0
  36. /imbi_plugin_github-2.16.1/tests/__init__.py → /imbi_plugin_github-2.18.0/src/imbi/plugins/github/py.typed +0 -0
@@ -0,0 +1,22 @@
1
+ __pycache__
2
+ *.egg-info
3
+ *.pyc
4
+ *.env
5
+ .env
6
+ .env.test
7
+ build/
8
+ dist/
9
+ docs/site/
10
+ helm/imbi/charts/
11
+ runtime/state/
12
+ .venv/
13
+ .coverage
14
+ .mypy_cache/
15
+ .pytest_cache/
16
+ .ruff_cache/
17
+ coverage.xml
18
+ site/
19
+
20
+ # moon
21
+ .moon/cache
22
+ .moon/docker
@@ -1,10 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: imbi-plugin-github
3
- Version: 2.16.1
3
+ Version: 2.18.0
4
4
  Summary: GitHub identity plugin for Imbi (github.com / GHEC / GHES)
5
5
  Author-email: "Gavin M. Roy" <gavinr@aweber.com>
6
- License: BSD-3-Clause
7
- License-File: LICENSE
6
+ License-Expression: BSD-3-Clause
8
7
  Classifier: Development Status :: 3 - Alpha
9
8
  Classifier: Intended Audience :: Developers
10
9
  Classifier: Natural Language :: English
@@ -13,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
13
12
  Classifier: Programming Language :: Python :: 3.14
14
13
  Requires-Python: >=3.14
15
14
  Requires-Dist: httpx>=0.27
16
- Requires-Dist: imbi-common[databases]==2.16.1
15
+ Requires-Dist: imbi-common[databases]==2.18.0
17
16
  Requires-Dist: pydantic>=2
18
17
  Requires-Dist: pyjwt[crypto]>=2.8
19
18
  Description-Content-Type: text/markdown
@@ -0,0 +1,22 @@
1
+ # yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json
2
+ layer: 'library'
3
+ stack: 'backend'
4
+ language: 'python'
5
+ tags:
6
+ - 'python'
7
+
8
+ project:
9
+ name: 'imbi-plugin-github'
10
+ description: 'GitHub identity plugin for Imbi'
11
+
12
+ dependsOn:
13
+ - 'common'
14
+
15
+ # Inherits lint / format / typecheck / test from .moon/tasks/python.yml. The
16
+ # shared test task points --cov-config at this member's pyproject, where
17
+ # [tool.coverage] scopes source + threshold to the member.
18
+ tasks:
19
+ test:
20
+ args:
21
+ - '--cov'
22
+ - '--cov-report=term-missing'
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "imbi-plugin-github"
3
+ version = "2.18.0"
4
+ description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ license = "BSD-3-Clause"
8
+ authors = [
9
+ { name = "Gavin M. Roy", email = "gavinr@aweber.com" },
10
+ ]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Developers",
14
+ "Natural Language :: English",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.14",
18
+ ]
19
+ dependencies = [
20
+ "httpx>=0.27",
21
+ "imbi-common[databases]==2.18.0",
22
+ "pydantic>=2",
23
+ "pyjwt[crypto]>=2.8",
24
+ ]
25
+
26
+ [build-system]
27
+ requires = ["hatchling"]
28
+ build-backend = "hatchling.build"
29
+
30
+ # Coverage for the per-member `moon run github:test` run. Scoped to this
31
+ # member so the report/threshold reflect only imbi.plugins.github; the aggregate
32
+ # `root:coverage` run uses the root pyproject config instead.
33
+ [tool.coverage.run]
34
+ branch = true
35
+ source_pkgs = ["imbi.plugins.github"]
36
+
37
+ [tool.coverage.report]
38
+ exclude_also = ["typing.TYPE_CHECKING"]
39
+ fail_under = 85
40
+ show_missing = true
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/imbi"]
44
+ include = [
45
+ "src/imbi/plugins/github/py.typed",
46
+ ]
@@ -0,0 +1,107 @@
1
+ # imbi-plugin-github
2
+
3
+ GitHub plugins for Imbi. Three flavors (github.com, GitHub Enterprise Cloud,
4
+ GitHub Enterprise Server) of each plugin type so the admin UI can wire
5
+ projects to the right backend.
6
+
7
+ ## Plugin types
8
+
9
+ | Type | Slugs |
10
+ | ---------- | -------------------------------------------------------------------- |
11
+ | Identity | `github`, `github-enterprise-cloud`, `github-enterprise-server` |
12
+ | Deployment | `github-deployment`, `github-deployment-ec`, `github-deployment-es` |
13
+ | Lifecycle | `github-lifecycle`, `github-lifecycle-ec`, `github-lifecycle-es` |
14
+ | Webhook | `github-commit-sync` |
15
+
16
+ ### Identity
17
+
18
+ Implements the OAuth App flow. The access token returned by the OAuth
19
+ grant is passed straight to GitHub APIs as a `Bearer` token, so
20
+ `materialize()` is a no-op.
21
+
22
+ ### Deployment
23
+
24
+ Drives the GitHub Deployments API (`POST /repos/{owner}/{repo}/deployments`)
25
+ plus tag and release creation. Promote behaviour is inferred from the
26
+ ref shape by the host (semver → trigger Deployment, raw SHA → cut tag
27
+ + Release). Per-env workflow inputs ride on the `USES_PLUGIN` edge as
28
+ `env_payloads` and arrive on `PluginContext.environment_config`.
29
+
30
+ ### Lifecycle
31
+
32
+ Reacts to project archive / unarchive by archiving the matching repo via
33
+ `PATCH /repos/{owner}/{repo}` with `{"archived": true|false}`. When the
34
+ `archive_target_org` option is set, archive **also** transfers the repo to
35
+ that org first via `POST /repos/{owner}/{repo}/transfer` — useful for
36
+ moving sunset projects into a dedicated "archive" org so they no longer
37
+ crowd primary-org searches.
38
+
39
+ GitHub refuses to transfer archived repos, so an already-archived source
40
+ is briefly unarchived, transferred, and re-archived at the destination.
41
+ On unarchive the plugin only flips `archived` back to `false` at the
42
+ repo's current location — it does **not** transfer back to the original
43
+ org.
44
+
45
+ Archiving requires admin scope on the repo; transferring additionally
46
+ requires admin permission on the target organization.
47
+
48
+ ### Webhook (commit / tag sync)
49
+
50
+ A single `github-commit-sync` webhook-action plugin exposes two actions
51
+ the gateway dispatches on `push` deliveries:
52
+
53
+ | Action | Handler | Records into ClickHouse |
54
+ | -------------- | -------------------------------- | ----------------------- |
55
+ | `sync_commits` | `github-commit-sync#sync_commits`| `commits` |
56
+ | `sync_tags` | `github-commit-sync#sync_tags` | `tags` |
57
+
58
+ `sync_commits` fetches the full set of commits in a push via the compare
59
+ API (paginated, so it isn't capped by the 20-commit inline payload limit);
60
+ `sync_tags` records the pushed tag and, with `reconcile_all`, the repo's
61
+ full tag list. Branch/tag gating is the rule's CEL `filter_expression`
62
+ (e.g. `ref == "refs/heads/main"`, `ref.startsWith("refs/tags/")`). The API
63
+ flavor (github.com / GHEC / GHES) is resolved at runtime — explicit
64
+ `api_base_url`, else a connected GitHub plugin on the same service, else
65
+ the service endpoint, else the payload's `repository.url`.
66
+
67
+ Unlike identity/deployment/lifecycle (which act as the OAuth user),
68
+ commit-sync runs without an actor and authenticates with a **service**
69
+ credential in one of two modes, resolved per call:
70
+
71
+ - **PAT** — a static `access_token`.
72
+ - **GitHub App** — `app_id` + `private_key`; the plugin signs an App JWT
73
+ and mints a short-lived **installation token** (cached process-wide
74
+ until shortly before it expires), so no static, expiring token is
75
+ stored. `installation_id` is optional — when unset it is discovered
76
+ from the pushed repository (`GET /repos/{owner}/{repo}/installation`).
77
+ The App needs **Contents: Read-only**.
78
+
79
+ ## Manifest options (identity)
80
+
81
+ | Option | Required | Description |
82
+ | ---------------- | --------- | -------------------------------------------------------------------------- |
83
+ | `host` | GHEC/GHES | Tenant or appliance host (e.g. `tenant.ghe.com`, `github.example.com`). |
84
+ | `default_scopes` | no | Space-separated default OAuth scopes (default: `read:user user:email repo workflow`). |
85
+
86
+ ## Credentials (identity)
87
+
88
+ | Field | Required |
89
+ | ---------------- | -------- |
90
+ | `client_id` | yes |
91
+ | `client_secret` | yes |
92
+
93
+ ## Credentials (commit-sync)
94
+
95
+ Provide **either** the PAT field **or** the GitHub App fields (all
96
+ individually optional; validated per call):
97
+
98
+ | Field | Mode | Description |
99
+ | ----------------- | ---- | ------------------------------------------------------ |
100
+ | `access_token` | PAT | Static personal/service token. |
101
+ | `app_id` | App | GitHub App identifier. |
102
+ | `private_key` | App | App private key — raw PEM or base64-encoded PEM. |
103
+ | `installation_id` | App | Optional; discovered from the repo when unset. |
104
+
105
+ ## License
106
+
107
+ BSD-3-Clause.
@@ -1,11 +1,11 @@
1
1
  """Imbi GitHub plugin (Architecture v3).
2
2
 
3
- One :class:`~imbi_plugin_github.plugin.GitHubPlugin` backs every GitHub
3
+ One :class:`~imbi.plugins.github.plugin.GitHubPlugin` backs every GitHub
4
4
  Integration. The registry discovers this package by its ``imbi_plugin_*``
5
5
  name and reads the module-level :data:`PLUGIN` attribute.
6
6
  """
7
7
 
8
- from imbi_plugin_github.plugin import GitHubPlugin
8
+ from imbi.plugins.github.plugin import GitHubPlugin
9
9
 
10
10
  #: The package's single plugin, discovered by the registry convention scan.
11
11
  PLUGIN = GitHubPlugin
@@ -9,7 +9,7 @@ per hour per ``(app, installation, host)`` rather than one per webhook
9
9
  delivery.
10
10
 
11
11
  All three GitHub flavors work unchanged: the caller resolves the API
12
- base via :func:`imbi_plugin_github._hosts.host_to_api_base` and passes
12
+ base via :func:`imbi.plugins.github._hosts.host_to_api_base` and passes
13
13
  it in, so the JWT exchange hits ``api.github.com``,
14
14
  ``api.<tenant>.ghe.com``, or ``<ghes>/api/v3`` as appropriate.
15
15
  """
@@ -26,7 +26,7 @@ import typing
26
26
  import httpx
27
27
  import jwt
28
28
 
29
- from imbi_plugin_github.deployment import (
29
+ from imbi.plugins.github.deployment import (
30
30
  _auth_headers, # pyright: ignore[reportPrivateUsage]
31
31
  _raise_on_401, # pyright: ignore[reportPrivateUsage]
32
32
  )
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import urllib.parse
14
14
 
15
- from imbi_common.plugins.base import PluginContext
15
+ from imbi.common.plugins.base import PluginContext
16
16
 
17
17
  # Reserved GitHub URL prefixes that share the host with repository URLs
18
18
  # but never point at a real ``<owner>/<repo>`` pair. Any link whose
@@ -20,7 +20,7 @@ it walks the full default-branch history and the complete tag list rather
20
20
  than a single push delta.
21
21
 
22
22
  Commit / tag rows are written to the shared ClickHouse ``commits`` /
23
- ``tags`` tables via :func:`imbi_common.clickhouse.insert`. Writes are
23
+ ``tags`` tables via :func:`imbi.common.clickhouse.insert`. Writes are
24
24
  best-effort: a storage failure is logged and swallowed so an analytics
25
25
  hiccup never 5xxs the webhook, exactly as the gateway's own event
26
26
  recording behaves.
@@ -40,21 +40,21 @@ import urllib.parse
40
40
  import httpx
41
41
  import jsonpointer
42
42
  import pydantic
43
- from imbi_common import clickhouse
44
- from imbi_common.json_pointer import JsonPointer
45
- from imbi_common.models import CommitRecord, TagRecord
46
- from imbi_common.plugins.base import (
43
+
44
+ from imbi.common import clickhouse
45
+ from imbi.common.json_pointer import JsonPointer
46
+ from imbi.common.models import CommitRecord, TagRecord
47
+ from imbi.common.plugins.base import (
47
48
  ActionDescriptor,
48
49
  CheckStatus,
49
50
  CommitSyncCapability,
50
51
  PluginContext,
51
52
  )
52
- from imbi_common.plugins.errors import PluginRateLimited
53
-
54
- from imbi_plugin_github import _app_auth
55
- from imbi_plugin_github._hosts import host_to_api_base, resolve_host
56
- from imbi_plugin_github._repos import resolve_owner_repo
57
- from imbi_plugin_github.deployment import (
53
+ from imbi.common.plugins.errors import PluginRateLimited
54
+ from imbi.plugins.github import _app_auth
55
+ from imbi.plugins.github._hosts import host_to_api_base, resolve_host
56
+ from imbi.plugins.github._repos import resolve_owner_repo
57
+ from imbi.plugins.github.deployment import (
58
58
  _auth_headers, # pyright: ignore[reportPrivateUsage]
59
59
  _check_runs_to_status, # pyright: ignore[reportPrivateUsage]
60
60
  _checks_disabled, # pyright: ignore[reportPrivateUsage]
@@ -254,7 +254,7 @@ async def _resolve_bearer(
254
254
  ) -> str:
255
255
  """Resolve the Bearer token used for the repo's GitHub API calls.
256
256
 
257
- Thin wrapper over :func:`imbi_plugin_github._app_auth.resolve_bearer`
257
+ Thin wrapper over :func:`imbi.plugins.github._app_auth.resolve_bearer`
258
258
  (the single source of truth), kept as a module-local name for the
259
259
  commit-sync call sites and tests.
260
260
  """
@@ -263,10 +263,7 @@ async def _resolve_bearer(
263
263
 
264
264
  def _resolve(pointer: jsonpointer.JsonPointer, event: object) -> object:
265
265
  """Resolve a JSON Pointer against the event, ``None`` if absent."""
266
- return typing.cast(
267
- 'object',
268
- pointer.resolve(event, None), # pyright: ignore[reportUnknownMemberType]
269
- )
266
+ return pointer.resolve(event, None)
270
267
 
271
268
 
272
269
  def _branch_short_name(ref: str) -> str:
@@ -721,7 +718,7 @@ async def _insert_best_effort(
721
718
  ) -> int:
722
719
  """Insert ``records`` into ``table``; return rows written (0 on fail).
723
720
 
724
- ``imbi_common.clickhouse.insert`` rejects an empty list, so an empty
721
+ ``imbi.common.clickhouse.insert`` rejects an empty list, so an empty
725
722
  set short-circuits to 0. A storage failure is logged and swallowed,
726
723
  mirroring the webhook actions -- an analytics hiccup must not fail the
727
724
  sync.
@@ -1178,10 +1175,10 @@ sync_commits_descriptor = ActionDescriptor(
1178
1175
  'API) and record them in the ClickHouse commits table.'
1179
1176
  ),
1180
1177
  callable=typing.cast(
1181
- 'typing.Any', 'imbi_plugin_github.commits:sync_commits'
1178
+ 'typing.Any', 'imbi.plugins.github.commits:sync_commits'
1182
1179
  ),
1183
1180
  config_model=typing.cast(
1184
- 'typing.Any', 'imbi_plugin_github.commits:SyncCommitsConfig'
1181
+ 'typing.Any', 'imbi.plugins.github.commits:SyncCommitsConfig'
1185
1182
  ),
1186
1183
  )
1187
1184
 
@@ -1192,9 +1189,11 @@ sync_tags_descriptor = ActionDescriptor(
1192
1189
  'Record the pushed tag (and, when reconcile_all is set, the full '
1193
1190
  'tag list) in the ClickHouse tags table.'
1194
1191
  ),
1195
- callable=typing.cast('typing.Any', 'imbi_plugin_github.commits:sync_tags'),
1192
+ callable=typing.cast(
1193
+ 'typing.Any', 'imbi.plugins.github.commits:sync_tags'
1194
+ ),
1196
1195
  config_model=typing.cast(
1197
- 'typing.Any', 'imbi_plugin_github.commits:SyncTagsConfig'
1196
+ 'typing.Any', 'imbi.plugins.github.commits:SyncTagsConfig'
1198
1197
  ),
1199
1198
  )
1200
1199
 
@@ -26,12 +26,14 @@ import contextlib
26
26
  import datetime
27
27
  import hashlib
28
28
  import logging
29
+ import re
29
30
  import time
30
31
  import typing
31
32
  import urllib.parse
32
33
 
33
34
  import httpx
34
- from imbi_common.plugins.base import (
35
+
36
+ from imbi.common.plugins.base import (
35
37
  CheckStatus,
36
38
  Commit,
37
39
  CompareResult,
@@ -46,10 +48,9 @@ from imbi_common.plugins.base import (
46
48
  RemoteDeployment,
47
49
  WorkflowFile,
48
50
  )
49
- from imbi_common.plugins.errors import PluginAuthenticationFailed
50
-
51
- from imbi_plugin_github._hosts import flavor_host, host_to_api_base
52
- from imbi_plugin_github._repos import (
51
+ from imbi.common.plugins.errors import PluginAuthenticationFailed
52
+ from imbi.plugins.github._hosts import flavor_host, host_to_api_base
53
+ from imbi.plugins.github._repos import (
53
54
  derive_owner_repo_from_links,
54
55
  parse_owner_repo,
55
56
  resolve_owner_repo,
@@ -345,7 +346,7 @@ class GitHubDeployment(DeploymentCapability):
345
346
  )
346
347
 
347
348
  # Backwards-compatible aliases for the previously private helpers.
348
- # The actual logic lives in :mod:`imbi_plugin_github._repos`; these
349
+ # The actual logic lives in :mod:`imbi.plugins.github._repos`; these
349
350
  # remain so existing tests that reach into the class continue to
350
351
  # work and so subclasses overriding the resolution path still have
351
352
  # a stable surface to hook into.
@@ -378,7 +379,7 @@ class GitHubDeployment(DeploymentCapability):
378
379
  """
379
380
  # Local import: ``_app_auth`` imports this module for its shared
380
381
  # HTTP helpers, so a top-level import here would be circular.
381
- from imbi_plugin_github import _app_auth
382
+ from imbi.plugins.github import _app_auth
382
383
 
383
384
  owner, repo = self._owner_repo(ctx)
384
385
  return await _app_auth.resolve_bearer(
@@ -397,7 +398,7 @@ class GitHubDeployment(DeploymentCapability):
397
398
  ``raise_for_status``. A response hook records that redirect; once
398
399
  the caller's request succeeds we resolve the repo's new
399
400
  ``full_name``/``html_url`` and stash a
400
- :class:`~imbi_common.plugins.base.LinkWriteback` on ``ctx`` so the
401
+ :class:`~imbi.common.plugins.base.LinkWriteback` on ``ctx`` so the
401
402
  host can persist the project's updated stored link. This is the
402
403
  single chokepoint for every deployment call.
403
404
  """
@@ -1002,10 +1003,16 @@ class GitHubDeployment(DeploymentCapability):
1002
1003
  with explicit pagination if it ever needs to.
1003
1004
  """
1004
1005
  page_size = max(1, min(limit, 100))
1006
+ # One run commonly backs several deployments in a sweep; cache
1007
+ # the triggering-actor lookup by run id so we resolve each run
1008
+ # at most once. Shared across the parallel per-env fan-out.
1009
+ run_cache: dict[str, tuple[str, str] | None] = {}
1005
1010
  async with self._client(ctx, credentials) as client:
1006
1011
  per_env = await asyncio.gather(
1007
1012
  *(
1008
- self._list_deployments_for_env(client, env, page_size)
1013
+ self._list_deployments_for_env(
1014
+ client, env, page_size, run_cache
1015
+ )
1009
1016
  for env in environments
1010
1017
  )
1011
1018
  )
@@ -1035,6 +1042,7 @@ class GitHubDeployment(DeploymentCapability):
1035
1042
  client: httpx.AsyncClient,
1036
1043
  environment: str,
1037
1044
  page_size: int,
1045
+ run_cache: dict[str, tuple[str, str] | None],
1038
1046
  ) -> list[RemoteDeployment]:
1039
1047
  try:
1040
1048
  resp = await client.get(
@@ -1067,7 +1075,7 @@ class GitHubDeployment(DeploymentCapability):
1067
1075
  observed: list[RemoteDeployment] = []
1068
1076
  for deployment in deployments:
1069
1077
  run = await self._observe_deployment(
1070
- client, environment, deployment
1078
+ client, environment, deployment, run_cache
1071
1079
  )
1072
1080
  if run is not None:
1073
1081
  observed.append(run)
@@ -1078,6 +1086,7 @@ class GitHubDeployment(DeploymentCapability):
1078
1086
  client: httpx.AsyncClient,
1079
1087
  environment: str,
1080
1088
  deployment: dict[str, typing.Any],
1089
+ run_cache: dict[str, tuple[str, str] | None],
1081
1090
  ) -> RemoteDeployment | None:
1082
1091
  deployment_id = deployment.get('id')
1083
1092
  sha = deployment.get('sha')
@@ -1113,6 +1122,18 @@ class GitHubDeployment(DeploymentCapability):
1113
1122
  creator_id = creator_dict.get('id')
1114
1123
  if isinstance(creator_id, int):
1115
1124
  creator_subject = str(creator_id)
1125
+ # Deployments made by an Actions workflow carry the app bot
1126
+ # as creator (``deployer[bot]``), never the human who
1127
+ # triggered the run. Re-attribute to the run's triggering
1128
+ # actor so the host can resolve the deploy to a real user.
1129
+ if _is_bot(creator_dict):
1130
+ run_id = _run_id_from_status_url(status_url)
1131
+ if run_id is not None:
1132
+ actor = await self._resolve_triggering_actor(
1133
+ client, run_id, run_cache
1134
+ )
1135
+ if actor is not None:
1136
+ creator_login, creator_subject = actor
1116
1137
  return RemoteDeployment(
1117
1138
  environment=environment,
1118
1139
  sha=str(sha),
@@ -1128,6 +1149,53 @@ class GitHubDeployment(DeploymentCapability):
1128
1149
  creator_subject=creator_subject,
1129
1150
  )
1130
1151
 
1152
+ async def _resolve_triggering_actor(
1153
+ self,
1154
+ client: httpx.AsyncClient,
1155
+ run_id: str,
1156
+ run_cache: dict[str, tuple[str, str] | None],
1157
+ ) -> tuple[str, str] | None:
1158
+ """Resolve a workflow run's human trigger to ``(login, subject)``.
1159
+
1160
+ Attributes a bot-created deployment to the person who started
1161
+ the Actions run via ``GET /actions/runs/{run_id}``, preferring
1162
+ ``triggering_actor`` (the account that re-ran or dispatched the
1163
+ run) and falling back to ``actor``. Best-effort: a fetch/parse
1164
+ error, a missing actor, or an actor that is itself a bot yields
1165
+ ``None`` so the caller keeps the bot creator -- attribution must
1166
+ never fail the resync. Results (including ``None``) are cached
1167
+ per sweep because one run backs several deployments.
1168
+ """
1169
+ if run_id in run_cache:
1170
+ return run_cache[run_id]
1171
+ result: tuple[str, str] | None = None
1172
+ try:
1173
+ resp = await client.get(f'/actions/runs/{run_id}')
1174
+ resp.raise_for_status()
1175
+ run = typing.cast(dict[str, typing.Any], resp.json())
1176
+ except (httpx.HTTPError, PluginAuthenticationFailed, ValueError):
1177
+ LOGGER.warning(
1178
+ 'Failed to fetch workflow run %s for deploy attribution',
1179
+ run_id,
1180
+ exc_info=True,
1181
+ )
1182
+ run_cache[run_id] = None
1183
+ return None
1184
+ actor_raw = run.get('triggering_actor') or run.get('actor')
1185
+ if isinstance(actor_raw, dict):
1186
+ actor = typing.cast(dict[str, typing.Any], actor_raw)
1187
+ login = actor.get('login')
1188
+ actor_id = actor.get('id')
1189
+ if (
1190
+ isinstance(login, str)
1191
+ and login
1192
+ and isinstance(actor_id, int)
1193
+ and not _is_bot(actor)
1194
+ ):
1195
+ result = (login, str(actor_id))
1196
+ run_cache[run_id] = result
1197
+ return result
1198
+
1131
1199
  async def _release_notes_for_ref(
1132
1200
  self, client: httpx.AsyncClient, ref: str
1133
1201
  ) -> str | None:
@@ -1195,6 +1263,38 @@ class GitHubDeployment(DeploymentCapability):
1195
1263
  return _to_event_status(state), str(log_url) if log_url else None
1196
1264
 
1197
1265
 
1266
+ _RUN_ID_RE = re.compile(r'/actions/runs/(\d+)')
1267
+
1268
+
1269
+ def _is_bot(user: dict[str, typing.Any]) -> bool:
1270
+ """Return ``True`` when a GitHub user object denotes an app/bot.
1271
+
1272
+ GitHub marks app identities with ``type == 'Bot'`` on the user
1273
+ object and gives them a ``login`` suffixed ``[bot]``
1274
+ (``github-actions[bot]``, ``deployer[bot]``). Either signal alone is
1275
+ sufficient; both comparisons are case-insensitive.
1276
+ """
1277
+ if str(user.get('type') or '').lower() == 'bot':
1278
+ return True
1279
+ login = user.get('login')
1280
+ return isinstance(login, str) and login.lower().endswith('[bot]')
1281
+
1282
+
1283
+ def _run_id_from_status_url(url: str | None) -> str | None:
1284
+ """Extract the Actions run id from a deployment status URL.
1285
+
1286
+ A GitHub Actions deploy posts its status ``log_url``/``target_url``
1287
+ pointing at the workflow run
1288
+ (``.../actions/runs/{run_id}`` optionally followed by
1289
+ ``/job/{job_id}``). Any URL that is not an Actions run link yields
1290
+ ``None``.
1291
+ """
1292
+ if not url:
1293
+ return None
1294
+ match = _RUN_ID_RE.search(url)
1295
+ return match.group(1) if match else None
1296
+
1297
+
1198
1298
  def _to_event_status(github_state: str) -> DeploymentEventStatus:
1199
1299
  """Map a GitHub deployment-status ``state`` to the host vocabulary.
1200
1300
 
@@ -20,7 +20,8 @@ import re
20
20
  import typing
21
21
 
22
22
  import httpx
23
- from imbi_common.plugins.base import (
23
+
24
+ from imbi.common.plugins.base import (
24
25
  AnalysisCapability,
25
26
  AnalysisResultItem,
26
27
  AnalysisResultStatus,
@@ -31,14 +32,13 @@ from imbi_common.plugins.base import (
31
32
  ServiceConnection,
32
33
  ServiceWriteback,
33
34
  )
34
- from imbi_common.plugins.errors import PluginAuthenticationFailed
35
-
36
- from imbi_plugin_github._hosts import (
35
+ from imbi.common.plugins.errors import PluginAuthenticationFailed
36
+ from imbi.plugins.github._hosts import (
37
37
  flavor_host,
38
38
  host_to_api_base,
39
39
  )
40
- from imbi_plugin_github._repos import derive_owner_repo_from_links
41
- from imbi_plugin_github.commits import (
40
+ from imbi.plugins.github._repos import derive_owner_repo_from_links
41
+ from imbi.plugins.github.commits import (
42
42
  _resolve_bearer, # pyright: ignore[reportPrivateUsage]
43
43
  )
44
44
 
@@ -18,15 +18,15 @@ import typing
18
18
  import urllib.parse
19
19
 
20
20
  import httpx
21
- from imbi_common.plugins.base import (
21
+
22
+ from imbi.common.plugins.base import (
22
23
  AuthorizationRequest,
23
24
  IdentityCapability,
24
25
  IdentityCredentials,
25
26
  IdentityProfile,
26
27
  PluginContext,
27
28
  )
28
-
29
- from imbi_plugin_github._hosts import flavor_host, host_to_api_base
29
+ from imbi.plugins.github._hosts import flavor_host, host_to_api_base
30
30
 
31
31
  LOGGER = logging.getLogger(__name__)
32
32
 
@@ -47,7 +47,8 @@ import logging
47
47
  import typing
48
48
 
49
49
  import httpx
50
- from imbi_common.plugins.base import (
50
+
51
+ from imbi.common.plugins.base import (
51
52
  LifecycleCapability,
52
53
  LifecycleResult,
53
54
  LinkWriteback,
@@ -55,11 +56,10 @@ from imbi_common.plugins.base import (
55
56
  RelocationTarget,
56
57
  ServiceWriteback,
57
58
  )
58
- from imbi_common.plugins.errors import PluginAuthenticationFailed
59
- from imbi_common.plugins.templates import expand_template
60
-
61
- from imbi_plugin_github._hosts import flavor_host, host_to_api_base
62
- from imbi_plugin_github._repos import (
59
+ from imbi.common.plugins.errors import PluginAuthenticationFailed
60
+ from imbi.common.plugins.templates import expand_template
61
+ from imbi.plugins.github._hosts import flavor_host, host_to_api_base
62
+ from imbi.plugins.github._repos import (
63
63
  derive_owner_repo_from_links,
64
64
  resolve_owner_repo,
65
65
  )