imbi-plugin-github 2.41.0__py3-none-any.whl → 2.43.0__py3-none-any.whl
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/plugins/github/deployment.py +5 -0
- imbi/plugins/github/plugin.py +10 -0
- imbi/plugins/github/public.py +84 -0
- imbi/plugins/github/secret_provider.py +734 -0
- {imbi_plugin_github-2.41.0.dist-info → imbi_plugin_github-2.43.0.dist-info}/METADATA +2 -2
- {imbi_plugin_github-2.41.0.dist-info → imbi_plugin_github-2.43.0.dist-info}/RECORD +7 -5
- {imbi_plugin_github-2.41.0.dist-info → imbi_plugin_github-2.43.0.dist-info}/WHEEL +1 -1
|
@@ -65,6 +65,7 @@ from imbi.common.plugins.base import (
|
|
|
65
65
|
WorkflowFile,
|
|
66
66
|
)
|
|
67
67
|
from imbi.common.plugins.errors import PluginAuthenticationFailed
|
|
68
|
+
from imbi.plugins.github import public
|
|
68
69
|
from imbi.plugins.github._hosts import flavor_host, host_to_api_base
|
|
69
70
|
from imbi.plugins.github._repos import (
|
|
70
71
|
derive_owner_repo_from_links,
|
|
@@ -654,6 +655,10 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
654
655
|
per call.
|
|
655
656
|
"""
|
|
656
657
|
|
|
658
|
+
@classmethod
|
|
659
|
+
def public_reader(cls, url: str) -> public.PublicRepository | None:
|
|
660
|
+
return public.PublicRepository.for_url(url)
|
|
661
|
+
|
|
657
662
|
def _resolve_host(self, ctx: PluginContext) -> str:
|
|
658
663
|
return flavor_host(ctx.integration_options, 'github deployment')
|
|
659
664
|
|
imbi/plugins/github/plugin.py
CHANGED
|
@@ -25,6 +25,7 @@ from imbi.common.plugins.base import (
|
|
|
25
25
|
PluginManifest,
|
|
26
26
|
PluginOption,
|
|
27
27
|
WebhookActionsCapability,
|
|
28
|
+
WebhookSignature,
|
|
28
29
|
)
|
|
29
30
|
from imbi.plugins.github.commits import (
|
|
30
31
|
GitHubCommitSync,
|
|
@@ -39,6 +40,7 @@ from imbi.plugins.github.pull_requests import (
|
|
|
39
40
|
GitHubPullRequestSync,
|
|
40
41
|
sync_pull_requests_descriptor,
|
|
41
42
|
)
|
|
43
|
+
from imbi.plugins.github.secret_provider import GitHubApp
|
|
42
44
|
|
|
43
45
|
|
|
44
46
|
class GitHubWebhookActions(WebhookActionsCapability):
|
|
@@ -58,6 +60,13 @@ class GitHubWebhookActions(WebhookActionsCapability):
|
|
|
58
60
|
sync_pull_requests_descriptor,
|
|
59
61
|
]
|
|
60
62
|
|
|
63
|
+
@classmethod
|
|
64
|
+
def signature(cls) -> WebhookSignature:
|
|
65
|
+
# The secret of the GitHub webhook is the Webhook.secret.
|
|
66
|
+
return WebhookSignature(
|
|
67
|
+
header='x-hub-signature-256', prefix='sha256=', secret='webhook'
|
|
68
|
+
)
|
|
69
|
+
|
|
61
70
|
|
|
62
71
|
# Integration-level options, asked once per Integration.
|
|
63
72
|
_OPTIONS: list[PluginOption] = [
|
|
@@ -418,4 +427,5 @@ class GitHubPlugin(Plugin):
|
|
|
418
427
|
),
|
|
419
428
|
],
|
|
420
429
|
ops_log_templates=_OPS_LOG_TEMPLATES,
|
|
430
|
+
secret_providers=[GitHubApp],
|
|
421
431
|
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Credential-free reads of a public ``github.com`` repository.
|
|
2
|
+
|
|
3
|
+
:meth:`GitHubDeployment.public_reader
|
|
4
|
+
<imbi.plugins.github.deployment.GitHubDeployment.public_reader>`
|
|
5
|
+
returns a :class:`PublicRepository`. The host uses it when no
|
|
6
|
+
Integration of the organization can read a repository, for example a
|
|
7
|
+
public Claude Code plugin marketplace.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import typing
|
|
12
|
+
import urllib.parse
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from imbi.common.plugins import base
|
|
17
|
+
|
|
18
|
+
_GITHUB_REPO = re.compile(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PublicRepository:
|
|
22
|
+
"""The :class:`~imbi.common.plugins.base.RepositoryReader` of a public
|
|
23
|
+
``github.com`` repository. It ignores ``ctx`` and ``credentials``."""
|
|
24
|
+
|
|
25
|
+
# ponytail: 60 unauthenticated requests an hour per API pod IP; use
|
|
26
|
+
# an org token if many public marketplaces sync often.
|
|
27
|
+
def __init__(self, owner: str, repo: str) -> None:
|
|
28
|
+
self.base = f'https://api.github.com/repos/{owner}/{repo}'
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def for_url(cls, url: str) -> PublicRepository | None:
|
|
32
|
+
"""Return the reader of a ``github.com`` repository URL, or
|
|
33
|
+
``None`` for another host or a path that is not a repository."""
|
|
34
|
+
parts = urllib.parse.urlsplit(url)
|
|
35
|
+
path = parts.path.strip('/').removesuffix('.git')
|
|
36
|
+
if parts.hostname != 'github.com' or not _GITHUB_REPO.fullmatch(path):
|
|
37
|
+
return None
|
|
38
|
+
return cls(*path.split('/'))
|
|
39
|
+
|
|
40
|
+
async def _get(
|
|
41
|
+
self, path: str, accept: str = 'application/vnd.github+json'
|
|
42
|
+
) -> httpx.Response:
|
|
43
|
+
# A renamed or moved repository answers 301 to /repositories/{id}.
|
|
44
|
+
async with httpx.AsyncClient(
|
|
45
|
+
timeout=30, follow_redirects=True
|
|
46
|
+
) as client:
|
|
47
|
+
return await client.get(
|
|
48
|
+
f'{self.base}{path}', headers={'Accept': accept}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
async def resolve_committish(
|
|
52
|
+
self, _ctx: object, _credentials: object, ref: str, /
|
|
53
|
+
) -> base.Ref:
|
|
54
|
+
response = await self._get(f'/commits/{urllib.parse.quote(ref)}')
|
|
55
|
+
response.raise_for_status()
|
|
56
|
+
return base.Ref(name=ref, kind='branch', sha=response.json()['sha'])
|
|
57
|
+
|
|
58
|
+
async def list_refs(
|
|
59
|
+
self,
|
|
60
|
+
_ctx: object,
|
|
61
|
+
_credentials: object,
|
|
62
|
+
/,
|
|
63
|
+
kind: typing.Literal['default', 'branch', 'tag', 'all'] = 'all',
|
|
64
|
+
) -> list[base.Ref]:
|
|
65
|
+
"""Return the default branch only, whatever ``kind`` is."""
|
|
66
|
+
del kind
|
|
67
|
+
response = await self._get('')
|
|
68
|
+
response.raise_for_status()
|
|
69
|
+
branch = str(response.json()['default_branch'])
|
|
70
|
+
ref = await self.resolve_committish(None, None, branch)
|
|
71
|
+
return [ref.model_copy(update={'kind': 'default', 'is_default': True})]
|
|
72
|
+
|
|
73
|
+
async def get_file(
|
|
74
|
+
self, _ctx: object, _credentials: object, path: str, ref: str, /
|
|
75
|
+
) -> str | None:
|
|
76
|
+
response = await self._get(
|
|
77
|
+
f'/contents/{urllib.parse.quote(path)}?'
|
|
78
|
+
f'{urllib.parse.urlencode({"ref": ref})}',
|
|
79
|
+
'application/vnd.github.raw+json',
|
|
80
|
+
)
|
|
81
|
+
if response.status_code == 404:
|
|
82
|
+
return None
|
|
83
|
+
response.raise_for_status()
|
|
84
|
+
return response.text
|
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
"""The ``github_app`` secret provider (ADR 0022, plan section 9.8).
|
|
2
|
+
|
|
3
|
+
The provider issues a GitHub App installation access token for each
|
|
4
|
+
resolution. The token is limited to the repositories of the task and to
|
|
5
|
+
the permissions of the purpose, and it lasts one hour at most. Release
|
|
6
|
+
revokes it with ``DELETE /installation/token``.
|
|
7
|
+
|
|
8
|
+
A binding without an ``installation_id`` finds the installation of the
|
|
9
|
+
repositories with the App JWT (``GET /repos/{owner}/{repo}/installation``
|
|
10
|
+
or ``GET /repositories/{id}/installation``), so one binding works in each
|
|
11
|
+
org that the App is installed in. The repositories of one token must be
|
|
12
|
+
in one installation.
|
|
13
|
+
|
|
14
|
+
The App private key is not in Imbi: the binding names the ``secret://``
|
|
15
|
+
reference of another binding (for example an ``aws_secrets_manager``
|
|
16
|
+
binding), and imbi-api reads the key through the broker for each call
|
|
17
|
+
(:meth:`SecretProviderHost.provider_key
|
|
18
|
+
<imbi.common.plugins.secrets.SecretProviderHost.provider_key>`). That key
|
|
19
|
+
binding must be marked as a provider key for the same host and App id,
|
|
20
|
+
and must list the requesting binding as a consumer.
|
|
21
|
+
|
|
22
|
+
The GitHub host comes from an allowlist, never from free tenant input:
|
|
23
|
+
``github.com``, a GHEC tenant (``<tenant>.ghe.com``), or a GHES host that
|
|
24
|
+
the operator lists in ``IMBI_SECRETS_GITHUB_GHES_HOSTS``. A JWT signed
|
|
25
|
+
with an App key goes only to such a host.
|
|
26
|
+
|
|
27
|
+
The JWT and the token exchange come from :mod:`._app_auth`. Its
|
|
28
|
+
process-wide token cache is not used: each resolution gets its own
|
|
29
|
+
token, so that release can revoke it.
|
|
30
|
+
|
|
31
|
+
The lease id holds the token and the host it came from, because GitHub
|
|
32
|
+
revokes a token only with the token itself, and only at that host. The
|
|
33
|
+
broker keeps lease ids encrypted.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import collections.abc
|
|
37
|
+
import contextlib
|
|
38
|
+
import datetime
|
|
39
|
+
import ipaddress
|
|
40
|
+
import logging
|
|
41
|
+
import os
|
|
42
|
+
import re
|
|
43
|
+
import typing
|
|
44
|
+
import uuid
|
|
45
|
+
|
|
46
|
+
import httpx
|
|
47
|
+
import jwt
|
|
48
|
+
import orjson
|
|
49
|
+
import pydantic
|
|
50
|
+
|
|
51
|
+
from imbi.common import graph
|
|
52
|
+
from imbi.common.plugins import secrets
|
|
53
|
+
from imbi.plugins.github import _app_auth, _hosts, _repos
|
|
54
|
+
from imbi.plugins.github.deployment import (
|
|
55
|
+
_accept_header, # pyright: ignore[reportPrivateUsage]
|
|
56
|
+
_auth_headers, # pyright: ignore[reportPrivateUsage]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
LOGGER = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
#: The token permissions of each purpose. The App's own permissions are
|
|
62
|
+
#: the upper limit: GitHub refuses a request for more (422).
|
|
63
|
+
PURPOSE_PERMISSIONS: dict[str, dict[str, str]] = {
|
|
64
|
+
'workspace_checkout': {'contents': 'read'},
|
|
65
|
+
'marketplace_fetch': {'contents': 'read'},
|
|
66
|
+
'github_write': {
|
|
67
|
+
'contents': 'write',
|
|
68
|
+
'pull_requests': 'write',
|
|
69
|
+
'issues': 'write',
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#: The binding test asks for the smallest permission set.
|
|
74
|
+
_TEST_PERMISSIONS = {'metadata': 'read'}
|
|
75
|
+
|
|
76
|
+
_TIMEOUT_SECONDS = 10.0
|
|
77
|
+
#: When GitHub gives no expiry, assume less than its one hour.
|
|
78
|
+
_DEFAULT_TTL = datetime.timedelta(minutes=55)
|
|
79
|
+
_REPOSITORY = re.compile(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+')
|
|
80
|
+
_GHEC_TENANT = re.compile(r'[a-z0-9][a-z0-9-]*\.ghe\.com')
|
|
81
|
+
_REPOSITORY_ID = re.compile(r'[0-9]{1,20}')
|
|
82
|
+
#: The plugin of the Integrations that verify a project's repository.
|
|
83
|
+
GITHUB_PLUGIN = 'github'
|
|
84
|
+
#: The verified repository ids of a project, one row for each
|
|
85
|
+
#: ``EXISTS_IN`` edge to a GitHub Integration. An edge whose
|
|
86
|
+
#: identifier was changed by hand after verification does not count.
|
|
87
|
+
_VERIFIED_REPOSITORIES: typing.LiteralString = (
|
|
88
|
+
'MATCH (:Project {{id: {project_id}}})-[ei:EXISTS_IN]->'
|
|
89
|
+
'(i:Integration {{plugin: {plugin}}}) '
|
|
90
|
+
'WHERE ei.verified_identifier = ei.identifier '
|
|
91
|
+
'RETURN i.options AS options, '
|
|
92
|
+
'ei.verified_identifier AS repository_id'
|
|
93
|
+
)
|
|
94
|
+
#: The operator's GHES hosts, comma separated. Never tenant input.
|
|
95
|
+
GHES_HOSTS_ENV = 'IMBI_SECRETS_GITHUB_GHES_HOSTS'
|
|
96
|
+
|
|
97
|
+
#: GitHub status codes, by safe reason code.
|
|
98
|
+
_HTTP_REASONS = {
|
|
99
|
+
401: 'app_authentication_failed',
|
|
100
|
+
403: 'access_denied',
|
|
101
|
+
404: 'installation_not_found',
|
|
102
|
+
422: 'permission_or_repository_not_granted',
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _internal_address(host: str) -> bool:
|
|
107
|
+
"""True for a private, loopback, link-local, or other non-public IP."""
|
|
108
|
+
try:
|
|
109
|
+
address = ipaddress.ip_address(host.strip('[]'))
|
|
110
|
+
except ValueError:
|
|
111
|
+
return False
|
|
112
|
+
return not address.is_global
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def ghes_hosts() -> frozenset[str]:
|
|
116
|
+
"""The GHES hosts of :data:`GHES_HOSTS_ENV`, without IP addresses
|
|
117
|
+
that are not public."""
|
|
118
|
+
hosts: set[str] = set()
|
|
119
|
+
for raw in os.environ.get(GHES_HOSTS_ENV, '').split(','):
|
|
120
|
+
if not raw.strip():
|
|
121
|
+
continue
|
|
122
|
+
try:
|
|
123
|
+
host = _hosts.normalize_host(raw, GHES_HOSTS_ENV).lower()
|
|
124
|
+
except ValueError:
|
|
125
|
+
LOGGER.warning('%s: a value is not a host', GHES_HOSTS_ENV)
|
|
126
|
+
continue
|
|
127
|
+
if _internal_address(host):
|
|
128
|
+
LOGGER.warning(
|
|
129
|
+
'%s: %s is not a public address', GHES_HOSTS_ENV, host
|
|
130
|
+
)
|
|
131
|
+
continue
|
|
132
|
+
hosts.add(host)
|
|
133
|
+
return frozenset(hosts)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def allowed_host(host: str) -> bool:
|
|
137
|
+
"""True for github.com, a GHEC tenant, or an operator GHES host."""
|
|
138
|
+
return (
|
|
139
|
+
host == 'github.com'
|
|
140
|
+
or _GHEC_TENANT.fullmatch(host) is not None
|
|
141
|
+
or host in ghes_hosts()
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def check_host(value: str) -> str:
|
|
146
|
+
"""Return the normalized host, or raise ``ValueError``."""
|
|
147
|
+
host = _hosts.normalize_host(value, 'host').lower()
|
|
148
|
+
if not allowed_host(host):
|
|
149
|
+
raise ValueError(
|
|
150
|
+
'use github.com, a <tenant>.ghe.com host, or a GHES host in '
|
|
151
|
+
+ GHES_HOSTS_ENV
|
|
152
|
+
)
|
|
153
|
+
return host
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class _Config(pydantic.BaseModel):
|
|
157
|
+
"""One provider for each GitHub host: github.com, a GHEC tenant
|
|
158
|
+
(``<tenant>.ghe.com``), or a GHES appliance."""
|
|
159
|
+
|
|
160
|
+
model_config = pydantic.ConfigDict(extra='forbid')
|
|
161
|
+
|
|
162
|
+
host: str = 'github.com'
|
|
163
|
+
|
|
164
|
+
@pydantic.field_validator('host')
|
|
165
|
+
@classmethod
|
|
166
|
+
def _host(cls, value: str) -> str:
|
|
167
|
+
return check_host(value)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class _Locator(pydantic.BaseModel):
|
|
171
|
+
model_config = pydantic.ConfigDict(extra='forbid')
|
|
172
|
+
|
|
173
|
+
app_id: str = pydantic.Field(pattern=r'^[0-9]{1,20}$')
|
|
174
|
+
#: ``None``: find the installation of each request's repositories.
|
|
175
|
+
installation_id: str | None = pydantic.Field(
|
|
176
|
+
default=None, pattern=r'^[0-9]{1,20}$'
|
|
177
|
+
)
|
|
178
|
+
#: The ``secret://`` reference of the App private key (PEM, or
|
|
179
|
+
#: base64 PEM), in a binding that is not ``dynamic``.
|
|
180
|
+
private_key: str
|
|
181
|
+
#: ``owner/name`` repositories for ``marketplace_fetch``: the
|
|
182
|
+
#: marketplaces and their external plugins. Other purposes use the
|
|
183
|
+
#: repositories of the task's project.
|
|
184
|
+
repositories: list[str] = pydantic.Field(default_factory=list[str])
|
|
185
|
+
|
|
186
|
+
@pydantic.field_validator('private_key')
|
|
187
|
+
@classmethod
|
|
188
|
+
def _reference(cls, value: str) -> str:
|
|
189
|
+
try:
|
|
190
|
+
return str(secrets.parse_reference(value))
|
|
191
|
+
except secrets.BrokerError:
|
|
192
|
+
raise ValueError('not a valid secret:// reference') from None
|
|
193
|
+
|
|
194
|
+
@pydantic.field_validator('repositories')
|
|
195
|
+
@classmethod
|
|
196
|
+
def _repositories(cls, value: list[str]) -> list[str]:
|
|
197
|
+
if not all(_REPOSITORY.fullmatch(name) for name in value):
|
|
198
|
+
raise ValueError('use the owner/name form')
|
|
199
|
+
return value
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _host(config: dict[str, typing.Any]) -> str:
|
|
203
|
+
return str(config.get('host', 'github.com'))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _api_base(host: str) -> str:
|
|
207
|
+
"""The API base of an allowed host. The allowlist is checked again
|
|
208
|
+
at each call, because the operator list can change."""
|
|
209
|
+
if not allowed_host(host):
|
|
210
|
+
raise secrets.BrokerError('secret_access_denied', 'host_not_allowed')
|
|
211
|
+
return _hosts.host_to_api_base(host)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _lease(host: str, token: str) -> str:
|
|
215
|
+
return orjson.dumps({'host': host, 'token': token}).decode()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@contextlib.contextmanager
|
|
219
|
+
def _github_errors(code: str) -> collections.abc.Generator[None]:
|
|
220
|
+
"""Translate a GitHub or key failure into a :class:`BrokerError`.
|
|
221
|
+
|
|
222
|
+
Only the status code and the exception type are read. A response
|
|
223
|
+
body, a header, or a key never goes into the error or the log.
|
|
224
|
+
"""
|
|
225
|
+
try:
|
|
226
|
+
yield
|
|
227
|
+
except secrets.BrokerError:
|
|
228
|
+
raise
|
|
229
|
+
except httpx.HTTPStatusError as err:
|
|
230
|
+
status = err.response.status_code
|
|
231
|
+
LOGGER.warning('GitHub App call failed: HTTP %d', status)
|
|
232
|
+
if status == 429 or status >= 500:
|
|
233
|
+
raise secrets.BrokerError(
|
|
234
|
+
'secret_provider_unavailable', 'throttled'
|
|
235
|
+
) from None
|
|
236
|
+
raise secrets.BrokerError(
|
|
237
|
+
code, _HTTP_REASONS.get(status, 'provider_error')
|
|
238
|
+
) from None
|
|
239
|
+
except httpx.TransportError as err:
|
|
240
|
+
LOGGER.warning('GitHub App call failed: %s', type(err).__name__)
|
|
241
|
+
raise secrets.BrokerError(
|
|
242
|
+
'secret_provider_unavailable', 'unreachable'
|
|
243
|
+
) from None
|
|
244
|
+
except (ValueError, TypeError, jwt.PyJWTError) as err:
|
|
245
|
+
LOGGER.warning('GitHub App key not usable: %s', type(err).__name__)
|
|
246
|
+
raise secrets.BrokerError(code, 'invalid_private_key') from None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _options(raw: object) -> dict[str, typing.Any]:
|
|
250
|
+
"""Decode an Integration ``options`` value, as AGE returns it."""
|
|
251
|
+
parsed = graph.parse_agtype(raw)
|
|
252
|
+
if isinstance(parsed, str):
|
|
253
|
+
try:
|
|
254
|
+
parsed = orjson.loads(parsed)
|
|
255
|
+
except orjson.JSONDecodeError:
|
|
256
|
+
return {}
|
|
257
|
+
if isinstance(parsed, dict):
|
|
258
|
+
return typing.cast(dict[str, typing.Any], parsed)
|
|
259
|
+
return {}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _expiry(value: object) -> datetime.datetime:
|
|
263
|
+
if isinstance(value, str):
|
|
264
|
+
try:
|
|
265
|
+
parsed = datetime.datetime.fromisoformat(value)
|
|
266
|
+
except ValueError:
|
|
267
|
+
pass
|
|
268
|
+
else:
|
|
269
|
+
if parsed.tzinfo is None:
|
|
270
|
+
parsed = parsed.replace(tzinfo=datetime.UTC)
|
|
271
|
+
return parsed
|
|
272
|
+
return datetime.datetime.now(datetime.UTC) + _DEFAULT_TTL
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class GitHubApp(secrets.SecretProvider):
|
|
276
|
+
"""GitHub App installation access tokens, one for each resolution."""
|
|
277
|
+
|
|
278
|
+
type = 'github_app'
|
|
279
|
+
label = 'GitHub App'
|
|
280
|
+
capabilities = frozenset[secrets.SecretCapability](
|
|
281
|
+
{'dynamic_issuance', 'lease_revocation', 'health_check'}
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def validate_config(
|
|
285
|
+
self, config: dict[str, typing.Any]
|
|
286
|
+
) -> dict[str, typing.Any]:
|
|
287
|
+
return secrets.validate_model(_Config, config)
|
|
288
|
+
|
|
289
|
+
def validate_locator(
|
|
290
|
+
self,
|
|
291
|
+
locator: dict[str, typing.Any],
|
|
292
|
+
credential_class: secrets.CredentialClass,
|
|
293
|
+
) -> dict[str, typing.Any]:
|
|
294
|
+
if credential_class != 'dynamic':
|
|
295
|
+
raise secrets.InvalidConfiguration(
|
|
296
|
+
'A github_app binding is dynamic'
|
|
297
|
+
)
|
|
298
|
+
return secrets.validate_model(_Locator, locator)
|
|
299
|
+
|
|
300
|
+
def check_key_host(self, host: str) -> str:
|
|
301
|
+
return check_host(host)
|
|
302
|
+
|
|
303
|
+
def repository(
|
|
304
|
+
self, config: dict[str, typing.Any], url: str
|
|
305
|
+
) -> tuple[str, str] | None:
|
|
306
|
+
host = _host(config)
|
|
307
|
+
found = _repos.parse_owner_repo(url, host)
|
|
308
|
+
return None if found is None else (host, f'{found[0]}/{found[1]}')
|
|
309
|
+
|
|
310
|
+
async def project_repository_ids(
|
|
311
|
+
self, host: str, project_id: str | None
|
|
312
|
+
) -> list[int]:
|
|
313
|
+
"""Return the verified GitHub repository ids of a project on ``host``.
|
|
314
|
+
|
|
315
|
+
Only the ``verified_identifier`` of an ``EXISTS_IN`` edge to a GitHub
|
|
316
|
+
Integration for ``host`` counts. The GitHub plugin writes it from the
|
|
317
|
+
GitHub API (the lifecycle plugin and the doctor repair). A project
|
|
318
|
+
link, or an edge identifier that a user edits, gives no access.
|
|
319
|
+
|
|
320
|
+
Raises:
|
|
321
|
+
BrokerError: ``project_repository_unverified`` when the project
|
|
322
|
+
has no verified repository on ``host``.
|
|
323
|
+
|
|
324
|
+
"""
|
|
325
|
+
if project_id is None:
|
|
326
|
+
return []
|
|
327
|
+
records = await self.host.graph().execute(
|
|
328
|
+
_VERIFIED_REPOSITORIES,
|
|
329
|
+
{'project_id': project_id, 'plugin': GITHUB_PLUGIN},
|
|
330
|
+
['options', 'repository_id'],
|
|
331
|
+
)
|
|
332
|
+
found: set[int] = set()
|
|
333
|
+
for record in records:
|
|
334
|
+
value = str(graph.parse_agtype(record.get('repository_id')) or '')
|
|
335
|
+
if _REPOSITORY_ID.fullmatch(value) is None:
|
|
336
|
+
continue
|
|
337
|
+
try:
|
|
338
|
+
integration_host = _hosts.flavor_host(
|
|
339
|
+
_options(record.get('options')), 'github_app'
|
|
340
|
+
)
|
|
341
|
+
except ValueError:
|
|
342
|
+
continue
|
|
343
|
+
if integration_host.lower() == host:
|
|
344
|
+
found.add(int(value))
|
|
345
|
+
if not found:
|
|
346
|
+
raise secrets.BrokerError(
|
|
347
|
+
'secret_access_denied', 'project_repository_unverified'
|
|
348
|
+
)
|
|
349
|
+
return sorted(found)
|
|
350
|
+
|
|
351
|
+
async def check_locator(
|
|
352
|
+
self,
|
|
353
|
+
config: dict[str, typing.Any],
|
|
354
|
+
locator: dict[str, typing.Any],
|
|
355
|
+
organization_id: str,
|
|
356
|
+
) -> None:
|
|
357
|
+
"""The key binding must be a provider key for this host and App."""
|
|
358
|
+
try:
|
|
359
|
+
await self.host.check_provider_key(
|
|
360
|
+
organization_id,
|
|
361
|
+
locator['private_key'],
|
|
362
|
+
host=_host(config),
|
|
363
|
+
app_id=locator['app_id'],
|
|
364
|
+
)
|
|
365
|
+
except secrets.BrokerError as err:
|
|
366
|
+
raise secrets.InvalidConfiguration(
|
|
367
|
+
'private_key must name a provider key binding for host '
|
|
368
|
+
f'{_host(config)} and App {locator["app_id"]}',
|
|
369
|
+
code=err.reason,
|
|
370
|
+
) from None
|
|
371
|
+
|
|
372
|
+
@contextlib.asynccontextmanager
|
|
373
|
+
async def _app_client(
|
|
374
|
+
self,
|
|
375
|
+
config: dict[str, typing.Any],
|
|
376
|
+
locator: dict[str, typing.Any],
|
|
377
|
+
organization_id: str,
|
|
378
|
+
*,
|
|
379
|
+
consumer: uuid.UUID | None,
|
|
380
|
+
task_id: uuid.UUID | None,
|
|
381
|
+
) -> collections.abc.AsyncGenerator[httpx.AsyncClient]:
|
|
382
|
+
"""A client that authenticates as the App (a JWT)."""
|
|
383
|
+
host = _host(config)
|
|
384
|
+
base = _api_base(host)
|
|
385
|
+
material = await self.host.provider_key(
|
|
386
|
+
organization_id,
|
|
387
|
+
locator['private_key'],
|
|
388
|
+
consumer=consumer,
|
|
389
|
+
host=host,
|
|
390
|
+
app_id=locator['app_id'],
|
|
391
|
+
task_id=task_id,
|
|
392
|
+
)
|
|
393
|
+
key = material.value.decode()
|
|
394
|
+
app_jwt = _app_auth._app_jwt( # pyright: ignore[reportPrivateUsage]
|
|
395
|
+
locator['app_id'], key
|
|
396
|
+
)
|
|
397
|
+
async with httpx.AsyncClient(
|
|
398
|
+
base_url=base,
|
|
399
|
+
headers=_auth_headers(app_jwt),
|
|
400
|
+
timeout=_TIMEOUT_SECONDS,
|
|
401
|
+
) as client:
|
|
402
|
+
yield client
|
|
403
|
+
|
|
404
|
+
async def _installation(
|
|
405
|
+
self, client: httpx.AsyncClient, installation_id: str
|
|
406
|
+
) -> dict[str, typing.Any]:
|
|
407
|
+
response = await client.get(f'/app/installations/{installation_id}')
|
|
408
|
+
response.raise_for_status()
|
|
409
|
+
return typing.cast(dict[str, typing.Any], response.json())
|
|
410
|
+
|
|
411
|
+
async def _installation_for(
|
|
412
|
+
self,
|
|
413
|
+
client: httpx.AsyncClient,
|
|
414
|
+
repositories: list[str],
|
|
415
|
+
repository_ids: list[int] | None,
|
|
416
|
+
) -> dict[str, typing.Any]:
|
|
417
|
+
"""The one installation of the repositories.
|
|
418
|
+
|
|
419
|
+
Raises:
|
|
420
|
+
BrokerError: ``repositories_span_installations`` when two
|
|
421
|
+
repositories are in different installations.
|
|
422
|
+
|
|
423
|
+
"""
|
|
424
|
+
paths = [f'/repos/{name}/installation' for name in repositories] + [
|
|
425
|
+
f'/repositories/{rid}/installation' for rid in repository_ids or []
|
|
426
|
+
]
|
|
427
|
+
# ponytail: one lookup for each repository and resolution; cache
|
|
428
|
+
# by repository when this is too many GitHub calls.
|
|
429
|
+
found: dict[str, dict[str, typing.Any]] = {}
|
|
430
|
+
for path in paths:
|
|
431
|
+
response = await client.get(path)
|
|
432
|
+
response.raise_for_status()
|
|
433
|
+
installation = typing.cast(dict[str, typing.Any], response.json())
|
|
434
|
+
found[str(installation.get('id'))] = installation
|
|
435
|
+
if len(found) != 1:
|
|
436
|
+
raise secrets.BrokerError(
|
|
437
|
+
'secret_access_denied', 'repositories_span_installations'
|
|
438
|
+
)
|
|
439
|
+
return next(iter(found.values()))
|
|
440
|
+
|
|
441
|
+
async def _mint(
|
|
442
|
+
self,
|
|
443
|
+
client: httpx.AsyncClient,
|
|
444
|
+
installation_id: str,
|
|
445
|
+
permissions: dict[str, str],
|
|
446
|
+
repositories: list[str] | None = None,
|
|
447
|
+
repository_ids: list[int] | None = None,
|
|
448
|
+
) -> tuple[str, datetime.datetime]:
|
|
449
|
+
token, expires_at = await _app_auth._mint( # pyright: ignore[reportPrivateUsage]
|
|
450
|
+
client,
|
|
451
|
+
installation_id,
|
|
452
|
+
_app_auth.freeze_scope(permissions),
|
|
453
|
+
repositories,
|
|
454
|
+
repository_ids,
|
|
455
|
+
)
|
|
456
|
+
return token, _expiry(expires_at)
|
|
457
|
+
|
|
458
|
+
async def health(
|
|
459
|
+
self, config: dict[str, typing.Any], organization_id: str
|
|
460
|
+
) -> secrets.Health:
|
|
461
|
+
"""Check that the GitHub API answers. No credentials are used."""
|
|
462
|
+
try:
|
|
463
|
+
async with httpx.AsyncClient(
|
|
464
|
+
base_url=_api_base(_host(config)),
|
|
465
|
+
headers=_accept_header(),
|
|
466
|
+
timeout=_TIMEOUT_SECONDS,
|
|
467
|
+
) as client:
|
|
468
|
+
response = await client.get('/meta')
|
|
469
|
+
except secrets.BrokerError as err:
|
|
470
|
+
return secrets.Health('unhealthy', err.reason)
|
|
471
|
+
except httpx.TransportError:
|
|
472
|
+
return secrets.Health('unhealthy', 'unreachable')
|
|
473
|
+
if response.status_code != 200:
|
|
474
|
+
return secrets.Health('unhealthy', 'provider_error')
|
|
475
|
+
return secrets.Health('healthy', identity={'host': _host(config)})
|
|
476
|
+
|
|
477
|
+
async def validate_binding(
|
|
478
|
+
self,
|
|
479
|
+
config: dict[str, typing.Any],
|
|
480
|
+
locator: dict[str, typing.Any],
|
|
481
|
+
organization_id: str,
|
|
482
|
+
binding_id: uuid.UUID | None = None,
|
|
483
|
+
) -> dict[str, typing.Any]:
|
|
484
|
+
"""Mint a token and return its metadata only, never the token.
|
|
485
|
+
|
|
486
|
+
The token has only ``metadata: read`` and is revoked before this
|
|
487
|
+
returns. Without an ``installation_id``, list the installations
|
|
488
|
+
of the App instead; no token is minted.
|
|
489
|
+
"""
|
|
490
|
+
if not locator.get('installation_id'):
|
|
491
|
+
return await self._installations(
|
|
492
|
+
config, locator, organization_id, binding_id
|
|
493
|
+
)
|
|
494
|
+
with _github_errors('secret_materialization_failed'):
|
|
495
|
+
async with self._app_client(
|
|
496
|
+
config,
|
|
497
|
+
locator,
|
|
498
|
+
organization_id,
|
|
499
|
+
consumer=binding_id,
|
|
500
|
+
task_id=None,
|
|
501
|
+
) as client:
|
|
502
|
+
installation = await self._installation(
|
|
503
|
+
client, locator['installation_id']
|
|
504
|
+
)
|
|
505
|
+
token, expires_at = await self._mint(
|
|
506
|
+
client, locator['installation_id'], _TEST_PERMISSIONS
|
|
507
|
+
)
|
|
508
|
+
try:
|
|
509
|
+
async with httpx.AsyncClient(
|
|
510
|
+
base_url=_api_base(_host(config)),
|
|
511
|
+
headers=_auth_headers(token),
|
|
512
|
+
timeout=_TIMEOUT_SECONDS,
|
|
513
|
+
) as client:
|
|
514
|
+
response = await client.get(
|
|
515
|
+
'/installation/repositories',
|
|
516
|
+
params={'per_page': 100},
|
|
517
|
+
)
|
|
518
|
+
response.raise_for_status()
|
|
519
|
+
listed = typing.cast(
|
|
520
|
+
dict[str, typing.Any], response.json()
|
|
521
|
+
)
|
|
522
|
+
finally:
|
|
523
|
+
await _revoke_token(_host(config), token)
|
|
524
|
+
return {
|
|
525
|
+
'app_slug': installation.get('app_slug'),
|
|
526
|
+
'installation_id': locator['installation_id'],
|
|
527
|
+
'account': _account(installation),
|
|
528
|
+
'permissions': installation.get('permissions') or {},
|
|
529
|
+
'repository_selection': installation.get('repository_selection'),
|
|
530
|
+
'repository_count': listed.get('total_count'),
|
|
531
|
+
'repositories': [
|
|
532
|
+
repo['full_name'] for repo in listed.get('repositories', [])
|
|
533
|
+
],
|
|
534
|
+
'token_expires_at': expires_at.isoformat(),
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async def _installations(
|
|
538
|
+
self,
|
|
539
|
+
config: dict[str, typing.Any],
|
|
540
|
+
locator: dict[str, typing.Any],
|
|
541
|
+
organization_id: str,
|
|
542
|
+
binding_id: uuid.UUID | None,
|
|
543
|
+
) -> dict[str, typing.Any]:
|
|
544
|
+
"""The App and the accounts it is installed on."""
|
|
545
|
+
with _github_errors('secret_materialization_failed'):
|
|
546
|
+
async with self._app_client(
|
|
547
|
+
config,
|
|
548
|
+
locator,
|
|
549
|
+
organization_id,
|
|
550
|
+
consumer=binding_id,
|
|
551
|
+
task_id=None,
|
|
552
|
+
) as client:
|
|
553
|
+
app = await client.get('/app')
|
|
554
|
+
app.raise_for_status()
|
|
555
|
+
installations: list[dict[str, typing.Any]] = []
|
|
556
|
+
page = 1
|
|
557
|
+
while True:
|
|
558
|
+
listed = await client.get(
|
|
559
|
+
'/app/installations',
|
|
560
|
+
params={'per_page': 100, 'page': page},
|
|
561
|
+
)
|
|
562
|
+
listed.raise_for_status()
|
|
563
|
+
batch = typing.cast(
|
|
564
|
+
list[dict[str, typing.Any]], listed.json()
|
|
565
|
+
)
|
|
566
|
+
installations.extend(batch)
|
|
567
|
+
if len(batch) < 100:
|
|
568
|
+
break
|
|
569
|
+
page += 1
|
|
570
|
+
return {
|
|
571
|
+
'app_slug': typing.cast(dict[str, typing.Any], app.json()).get(
|
|
572
|
+
'slug'
|
|
573
|
+
),
|
|
574
|
+
'installation_id': None,
|
|
575
|
+
'installations': [
|
|
576
|
+
{'id': str(i.get('id')), 'account': _account(i)}
|
|
577
|
+
for i in installations
|
|
578
|
+
],
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async def resolve(
|
|
582
|
+
self,
|
|
583
|
+
config: dict[str, typing.Any],
|
|
584
|
+
locator: dict[str, typing.Any],
|
|
585
|
+
field: str | None,
|
|
586
|
+
context: secrets.ResolveContext,
|
|
587
|
+
) -> secrets.Material:
|
|
588
|
+
"""Mint a token for the task's repositories and the purpose.
|
|
589
|
+
|
|
590
|
+
``marketplace_fetch`` uses the repository that the runner asks
|
|
591
|
+
for, which must be in the ``repositories`` of the binding; with
|
|
592
|
+
none, all of them. The other purposes use the verified repository
|
|
593
|
+
ids of the task's project (:meth:`project_repository_ids`).
|
|
594
|
+
|
|
595
|
+
Raises:
|
|
596
|
+
BrokerError: The purpose is unknown, the task has no
|
|
597
|
+
verified repository, a repository is outside the
|
|
598
|
+
installation, or GitHub refused the request.
|
|
599
|
+
|
|
600
|
+
"""
|
|
601
|
+
if field is not None:
|
|
602
|
+
raise secrets.BrokerError('secret_field_not_allowed')
|
|
603
|
+
permissions = PURPOSE_PERMISSIONS.get(context.purpose)
|
|
604
|
+
if permissions is None:
|
|
605
|
+
raise secrets.BrokerError(
|
|
606
|
+
'secret_access_denied', 'purpose_not_supported'
|
|
607
|
+
)
|
|
608
|
+
repositories: list[str] = []
|
|
609
|
+
repository_ids: list[int] | None = None
|
|
610
|
+
if context.purpose == 'marketplace_fetch':
|
|
611
|
+
repositories = list(locator.get('repositories', []))
|
|
612
|
+
if context.repository is not None:
|
|
613
|
+
if context.repository.lower() not in {
|
|
614
|
+
name.lower() for name in repositories
|
|
615
|
+
}:
|
|
616
|
+
raise secrets.BrokerError(
|
|
617
|
+
'secret_access_denied', 'repository_not_allowed'
|
|
618
|
+
)
|
|
619
|
+
repositories = [context.repository]
|
|
620
|
+
else:
|
|
621
|
+
repository_ids = await self.project_repository_ids(
|
|
622
|
+
_host(config), context.project_id
|
|
623
|
+
)
|
|
624
|
+
# An empty list never reaches GitHub: no repositories would give
|
|
625
|
+
# a token for every repository of the installation.
|
|
626
|
+
if not repositories and not repository_ids:
|
|
627
|
+
raise secrets.BrokerError(
|
|
628
|
+
'secret_access_denied', 'no_repositories'
|
|
629
|
+
)
|
|
630
|
+
with _github_errors('secret_materialization_failed'):
|
|
631
|
+
async with self._app_client(
|
|
632
|
+
config,
|
|
633
|
+
locator,
|
|
634
|
+
context.organization_id,
|
|
635
|
+
consumer=context.binding_id,
|
|
636
|
+
task_id=context.task_id,
|
|
637
|
+
) as client:
|
|
638
|
+
installation_id = locator.get('installation_id')
|
|
639
|
+
installation = None
|
|
640
|
+
if not installation_id:
|
|
641
|
+
installation = await self._installation_for(
|
|
642
|
+
client, repositories, repository_ids
|
|
643
|
+
)
|
|
644
|
+
installation_id = str(installation['id'])
|
|
645
|
+
elif repositories:
|
|
646
|
+
installation = await self._installation(
|
|
647
|
+
client, installation_id
|
|
648
|
+
)
|
|
649
|
+
names = None
|
|
650
|
+
if installation is not None and repositories:
|
|
651
|
+
names = _names(installation, repositories)
|
|
652
|
+
token, expires_at = await self._mint(
|
|
653
|
+
client,
|
|
654
|
+
installation_id,
|
|
655
|
+
permissions,
|
|
656
|
+
names,
|
|
657
|
+
repository_ids,
|
|
658
|
+
)
|
|
659
|
+
return secrets.Material(
|
|
660
|
+
token.encode(),
|
|
661
|
+
credential_expires_at=expires_at,
|
|
662
|
+
lease_id=_lease(_host(config), token),
|
|
663
|
+
revocable=True,
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
async def revoke(
|
|
667
|
+
self, config: dict[str, typing.Any], lease_id: str
|
|
668
|
+
) -> None:
|
|
669
|
+
"""Revoke a token at the host that issued it, from the lease.
|
|
670
|
+
|
|
671
|
+
The provider configuration is not used: its host can change
|
|
672
|
+
after the token was minted.
|
|
673
|
+
"""
|
|
674
|
+
try:
|
|
675
|
+
lease = typing.cast(dict[str, str], orjson.loads(lease_id))
|
|
676
|
+
host, token = lease['host'], lease['token']
|
|
677
|
+
except (orjson.JSONDecodeError, KeyError, TypeError):
|
|
678
|
+
raise secrets.BrokerError(
|
|
679
|
+
'provider_revocation_failed', 'invalid_lease'
|
|
680
|
+
) from None
|
|
681
|
+
await _revoke_token(host, token)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
async def _revoke_token(host: str, token: str) -> None:
|
|
685
|
+
"""Revoke a token. A 401 means it no longer works: success."""
|
|
686
|
+
base = _api_base(host)
|
|
687
|
+
try:
|
|
688
|
+
async with httpx.AsyncClient(
|
|
689
|
+
base_url=base,
|
|
690
|
+
headers=_auth_headers(token),
|
|
691
|
+
timeout=_TIMEOUT_SECONDS,
|
|
692
|
+
) as client:
|
|
693
|
+
response = await client.delete('/installation/token')
|
|
694
|
+
except httpx.TransportError as err:
|
|
695
|
+
LOGGER.warning('GitHub token revocation: %s', type(err).__name__)
|
|
696
|
+
raise secrets.BrokerError(
|
|
697
|
+
'provider_revocation_failed', 'unreachable'
|
|
698
|
+
) from None
|
|
699
|
+
if response.status_code not in (204, 401):
|
|
700
|
+
LOGGER.warning(
|
|
701
|
+
'GitHub token revocation: HTTP %d', response.status_code
|
|
702
|
+
)
|
|
703
|
+
raise secrets.BrokerError(
|
|
704
|
+
'provider_revocation_failed', 'provider_error'
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _names(
|
|
709
|
+
installation: dict[str, typing.Any], repositories: list[str]
|
|
710
|
+
) -> list[str]:
|
|
711
|
+
"""Return the repository names for the token request.
|
|
712
|
+
|
|
713
|
+
GitHub takes names without the owner, inside the installation's
|
|
714
|
+
account. A repository of another owner is refused here, because its
|
|
715
|
+
name could match a different repository of that account.
|
|
716
|
+
"""
|
|
717
|
+
account = _account(installation)
|
|
718
|
+
names: list[str] = []
|
|
719
|
+
for repository in repositories:
|
|
720
|
+
owner, _, name = repository.partition('/')
|
|
721
|
+
if not account or owner.lower() != account.lower():
|
|
722
|
+
raise secrets.BrokerError(
|
|
723
|
+
'secret_access_denied', 'repository_outside_installation'
|
|
724
|
+
)
|
|
725
|
+
names.append(name)
|
|
726
|
+
return names
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _account(installation: dict[str, typing.Any]) -> str:
|
|
730
|
+
"""The login of the account that the App is installed on."""
|
|
731
|
+
account = typing.cast(
|
|
732
|
+
dict[str, typing.Any], installation.get('account') or {}
|
|
733
|
+
)
|
|
734
|
+
return str(account.get('login') or '')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: imbi-plugin-github
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.43.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-Expression: BSD-3-Clause
|
|
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.14
|
|
13
13
|
Requires-Python: >=3.14
|
|
14
14
|
Requires-Dist: httpx>=0.27
|
|
15
|
-
Requires-Dist: imbi-common[databases]==2.
|
|
15
|
+
Requires-Dist: imbi-common[databases]==2.43.0
|
|
16
16
|
Requires-Dist: pydantic>=2
|
|
17
17
|
Requires-Dist: pyjwt[crypto]>=2.8
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
@@ -4,13 +4,15 @@ imbi/plugins/github/_app_auth.py,sha256=qr3azBExZSI0vDIIKMKV8nSllJNKCJLKBXV86lIp
|
|
|
4
4
|
imbi/plugins/github/_hosts.py,sha256=sYuE3f7934125VZ6GGTqm8t3sVa3m0DNt3jL9Hb8nM0,4014
|
|
5
5
|
imbi/plugins/github/_repos.py,sha256=BeSwrpzQnFBtlUGW-eP7v6_gAcSaSkqObq-vhj5wcYA,6575
|
|
6
6
|
imbi/plugins/github/commits.py,sha256=wHs_NUl4cmhfvD3WrnOCmqaaJS-Eku-E-lOMh6Cb8IU,71882
|
|
7
|
-
imbi/plugins/github/deployment.py,sha256=
|
|
7
|
+
imbi/plugins/github/deployment.py,sha256=bC5qMvbO-N0f5DTNo7ObyZIUtN4cl8uyjHgpp57W5_Y,111242
|
|
8
8
|
imbi/plugins/github/doctor.py,sha256=sIps9YyDFLva7JHyKJU0IkKSW8tiI-PArC0s1ytxv3w,33177
|
|
9
9
|
imbi/plugins/github/identity.py,sha256=9t_e-_jUbQ1iL0Vb6vaK2d7bT1ki6pw9aWwc4ics6gI,10354
|
|
10
10
|
imbi/plugins/github/lifecycle.py,sha256=KJSzB36zFqetQBY5HNXLsRGRoIwGhrBJ-I1mA8G9H_Q,37711
|
|
11
|
-
imbi/plugins/github/plugin.py,sha256=
|
|
11
|
+
imbi/plugins/github/plugin.py,sha256=KmAu1lrGX2tkaNqTd3GV9DwLaPd78ZrODGi6jUt2Z0c,15981
|
|
12
|
+
imbi/plugins/github/public.py,sha256=GZnTLbj_Gl5sHWuRqFuajnoO2xPudNQumAt4h_uJpYw,3114
|
|
12
13
|
imbi/plugins/github/pull_requests.py,sha256=FiHbjdGqghJ2Q1--d15XfOtpUe_KZE7hYRb8JLQ5U1I,13510
|
|
13
14
|
imbi/plugins/github/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
-
|
|
15
|
-
imbi_plugin_github-2.
|
|
16
|
-
imbi_plugin_github-2.
|
|
15
|
+
imbi/plugins/github/secret_provider.py,sha256=83BQwbG6AuIlyCWAskkN5hrVbRc9X5UqQJl5qt7Pij0,27317
|
|
16
|
+
imbi_plugin_github-2.43.0.dist-info/METADATA,sha256=IJBROVQR2tagTrCI0PddNXFWxlbGmcPZSkieQLVPQbQ,7618
|
|
17
|
+
imbi_plugin_github-2.43.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
18
|
+
imbi_plugin_github-2.43.0.dist-info/RECORD,,
|