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.
- imbi_plugin_github-2.18.0/.gitignore +22 -0
- {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/PKG-INFO +3 -4
- imbi_plugin_github-2.18.0/moon.yml +22 -0
- imbi_plugin_github-2.18.0/pyproject.toml +46 -0
- imbi_plugin_github-2.18.0/src/imbi/plugins/github/README.md +107 -0
- {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/__init__.py +2 -2
- {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
- {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/_repos.py +1 -1
- {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/commits.py +20 -21
- {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/deployment.py +110 -10
- {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/doctor.py +6 -6
- {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/identity.py +3 -3
- {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/lifecycle.py +6 -6
- {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/plugin.py +7 -8
- {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
- imbi_plugin_github-2.18.0/tests/__init__.py +0 -0
- {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/tests/test_commits.py +8 -8
- {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/tests/test_deployment.py +236 -10
- {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/tests/test_doctor.py +6 -6
- {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/tests/test_hosts.py +1 -1
- {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/tests/test_identity.py +4 -4
- {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/tests/test_lifecycle.py +8 -8
- {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/tests/test_pull_requests.py +5 -5
- {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/tests/test_repos.py +2 -2
- imbi_plugin_github-2.16.1/.github/workflows/publish.yml +0 -45
- imbi_plugin_github-2.16.1/.github/workflows/test.yml +0 -54
- imbi_plugin_github-2.16.1/.gitignore +0 -10
- imbi_plugin_github-2.16.1/.pre-commit-config.yaml +0 -39
- imbi_plugin_github-2.16.1/CLAUDE.md +0 -138
- imbi_plugin_github-2.16.1/LICENSE +0 -28
- imbi_plugin_github-2.16.1/justfile +0 -59
- imbi_plugin_github-2.16.1/pyproject.toml +0 -119
- imbi_plugin_github-2.16.1/uv.lock +0 -2024
- {imbi_plugin_github-2.16.1 → imbi_plugin_github-2.18.0}/README.md +0 -0
- {imbi_plugin_github-2.16.1/src/imbi_plugin_github → imbi_plugin_github-2.18.0/src/imbi/plugins/github}/_hosts.py +0 -0
- /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.
|
|
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.
|
|
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:`~
|
|
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
|
|
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:`
|
|
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
|
|
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
|
|
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:`
|
|
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
|
-
|
|
44
|
-
from
|
|
45
|
-
from
|
|
46
|
-
from
|
|
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
|
|
53
|
-
|
|
54
|
-
from
|
|
55
|
-
from
|
|
56
|
-
from
|
|
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:`
|
|
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
|
|
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
|
-
``
|
|
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', '
|
|
1178
|
+
'typing.Any', 'imbi.plugins.github.commits:sync_commits'
|
|
1182
1179
|
),
|
|
1183
1180
|
config_model=typing.cast(
|
|
1184
|
-
'typing.Any', '
|
|
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(
|
|
1192
|
+
callable=typing.cast(
|
|
1193
|
+
'typing.Any', 'imbi.plugins.github.commits:sync_tags'
|
|
1194
|
+
),
|
|
1196
1195
|
config_model=typing.cast(
|
|
1197
|
-
'typing.Any', '
|
|
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
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
from
|
|
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:`
|
|
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
|
|
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:`~
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
41
|
-
from
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
59
|
-
from
|
|
60
|
-
|
|
61
|
-
from
|
|
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
|
)
|