imbi-plugin-github 2.13.3__tar.gz → 2.14.1__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.13.3 → imbi_plugin_github-2.14.1}/PKG-INFO +2 -2
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/pyproject.toml +3 -3
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/_app_auth.py +51 -1
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/commits.py +17 -25
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/deployment.py +123 -11
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/doctor.py +97 -33
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/pull_requests.py +30 -1
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/test_commits.py +37 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/test_deployment.py +227 -4
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/test_doctor.py +95 -2
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/test_pull_requests.py +96 -1
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/uv.lock +6 -6
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/.github/workflows/publish.yml +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/.github/workflows/test.yml +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/.gitignore +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/.pre-commit-config.yaml +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/CLAUDE.md +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/LICENSE +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/README.md +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/justfile +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/__init__.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/_hosts.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/_repos.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/identity.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/lifecycle.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/plugin.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/test_repos.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: imbi-plugin-github
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.14.1
|
|
4
4
|
Summary: GitHub identity plugin for Imbi (github.com / GHEC / GHES)
|
|
5
5
|
Author-email: "Gavin M. Roy" <gavinr@aweber.com>
|
|
6
6
|
License: BSD-3-Clause
|
|
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.14
|
|
14
14
|
Requires-Python: >=3.14
|
|
15
15
|
Requires-Dist: httpx>=0.27
|
|
16
|
-
Requires-Dist: imbi-common[databases]==2.
|
|
16
|
+
Requires-Dist: imbi-common[databases]==2.14.1
|
|
17
17
|
Requires-Dist: pydantic>=2
|
|
18
18
|
Requires-Dist: pyjwt[crypto]>=2.8
|
|
19
19
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "imbi-plugin-github"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.14.1"
|
|
4
4
|
description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -16,7 +16,7 @@ classifiers = [
|
|
|
16
16
|
]
|
|
17
17
|
dependencies = [
|
|
18
18
|
"httpx>=0.27",
|
|
19
|
-
"imbi-common[databases]==2.
|
|
19
|
+
"imbi-common[databases]==2.14.1",
|
|
20
20
|
"pydantic>=2",
|
|
21
21
|
"pyjwt[crypto]>=2.8",
|
|
22
22
|
]
|
|
@@ -25,7 +25,7 @@ dependencies = [
|
|
|
25
25
|
dev = [
|
|
26
26
|
"basedpyright",
|
|
27
27
|
"coverage[toml]",
|
|
28
|
-
"imbi-common[server,databases]==2.
|
|
28
|
+
"imbi-common[server,databases]==2.14.1",
|
|
29
29
|
"pre-commit",
|
|
30
30
|
"pytest",
|
|
31
31
|
"pytest-asyncio",
|
|
@@ -33,6 +33,18 @@ from imbi_plugin_github.deployment import (
|
|
|
33
33
|
|
|
34
34
|
LOGGER = logging.getLogger(__name__)
|
|
35
35
|
|
|
36
|
+
|
|
37
|
+
class AppNotInstalledError(Exception):
|
|
38
|
+
"""The GitHub App is not installed for the target repository.
|
|
39
|
+
|
|
40
|
+
Raised by :func:`_discover_installation_id` when GitHub answers the
|
|
41
|
+
installation lookup with a 404 -- the App has not been installed on
|
|
42
|
+
the repo/org, or the repo was renamed/removed. Sync callers treat
|
|
43
|
+
this as a clean skip rather than a hard failure, so an uninstalled
|
|
44
|
+
App never surfaces as a Sentry error on a backfill worker.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
36
48
|
_HTTP_TIMEOUT_SECONDS = 10.0
|
|
37
49
|
# GitHub rejects an App JWT whose ``exp`` is more than 10 minutes out;
|
|
38
50
|
# sign for 9 to leave room for clock skew between us and GitHub.
|
|
@@ -114,11 +126,15 @@ async def _discover_installation_id(
|
|
|
114
126
|
client: httpx.AsyncClient, owner: str, repo: str
|
|
115
127
|
) -> str:
|
|
116
128
|
resp = await client.get(f'/repos/{owner}/{repo}/installation')
|
|
129
|
+
if resp.status_code == 404:
|
|
130
|
+
raise AppNotInstalledError(
|
|
131
|
+
f'no GitHub App installation found for {owner}/{repo}'
|
|
132
|
+
)
|
|
117
133
|
resp.raise_for_status()
|
|
118
134
|
data = typing.cast('dict[str, typing.Any]', resp.json())
|
|
119
135
|
install_id = data.get('id')
|
|
120
136
|
if install_id is None:
|
|
121
|
-
raise
|
|
137
|
+
raise AppNotInstalledError(
|
|
122
138
|
f'no GitHub App installation found for {owner}/{repo}'
|
|
123
139
|
)
|
|
124
140
|
return str(install_id)
|
|
@@ -193,3 +209,37 @@ async def installation_token(
|
|
|
193
209
|
_token_deadline(expires_at),
|
|
194
210
|
)
|
|
195
211
|
return token
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def resolve_bearer(
|
|
215
|
+
credentials: dict[str, str], base: str, owner: str, repo: str
|
|
216
|
+
) -> str:
|
|
217
|
+
"""Resolve the Bearer token used for a repo's GitHub API calls.
|
|
218
|
+
|
|
219
|
+
Prefers an explicit PAT (``access_token``/``token``). Otherwise mints
|
|
220
|
+
a short-lived GitHub App installation token from ``app_id`` +
|
|
221
|
+
``private_key`` (with an optional ``installation_id`` that skips
|
|
222
|
+
per-repo installation discovery). Tokens are cached process-wide.
|
|
223
|
+
|
|
224
|
+
Shared by every host-agnostic behavioral plugin (commit-sync,
|
|
225
|
+
pr-sync, deployment) so a service configured with only App
|
|
226
|
+
credentials -- and therefore no acting user -- can still act.
|
|
227
|
+
"""
|
|
228
|
+
token = credentials.get('access_token') or credentials.get('token')
|
|
229
|
+
if token:
|
|
230
|
+
return token
|
|
231
|
+
app_id = credentials.get('app_id')
|
|
232
|
+
private_key = credentials.get('private_key')
|
|
233
|
+
if app_id and private_key:
|
|
234
|
+
return await installation_token(
|
|
235
|
+
base=base,
|
|
236
|
+
app_id=app_id,
|
|
237
|
+
private_key=private_key,
|
|
238
|
+
installation_id=credentials.get('installation_id') or None,
|
|
239
|
+
owner=owner,
|
|
240
|
+
repo=repo,
|
|
241
|
+
)
|
|
242
|
+
raise ValueError(
|
|
243
|
+
'GitHub plugin requires either an access_token (PAT) or '
|
|
244
|
+
'app_id + private_key (GitHub App) credentials'
|
|
245
|
+
)
|
|
@@ -254,30 +254,11 @@ async def _resolve_bearer(
|
|
|
254
254
|
) -> str:
|
|
255
255
|
"""Resolve the Bearer token used for the repo's GitHub API calls.
|
|
256
256
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
per-repo installation discovery). Tokens are cached process-wide by
|
|
261
|
-
:mod:`imbi_plugin_github._app_auth`.
|
|
257
|
+
Thin wrapper over :func:`imbi_plugin_github._app_auth.resolve_bearer`
|
|
258
|
+
(the single source of truth), kept as a module-local name for the
|
|
259
|
+
commit-sync call sites and tests.
|
|
262
260
|
"""
|
|
263
|
-
|
|
264
|
-
if token:
|
|
265
|
-
return token
|
|
266
|
-
app_id = credentials.get('app_id')
|
|
267
|
-
private_key = credentials.get('private_key')
|
|
268
|
-
if app_id and private_key:
|
|
269
|
-
return await _app_auth.installation_token(
|
|
270
|
-
base=base,
|
|
271
|
-
app_id=app_id,
|
|
272
|
-
private_key=private_key,
|
|
273
|
-
installation_id=credentials.get('installation_id') or None,
|
|
274
|
-
owner=owner,
|
|
275
|
-
repo=repo,
|
|
276
|
-
)
|
|
277
|
-
raise ValueError(
|
|
278
|
-
'github-commit-sync requires either an access_token (PAT) or '
|
|
279
|
-
'app_id + private_key (GitHub App) credentials'
|
|
280
|
-
)
|
|
261
|
+
return await _app_auth.resolve_bearer(credentials, base, owner, repo)
|
|
281
262
|
|
|
282
263
|
|
|
283
264
|
def _resolve(pointer: jsonpointer.JsonPointer, event: object) -> object:
|
|
@@ -1273,7 +1254,10 @@ class GitHubCommitSync(CommitSyncCapability):
|
|
|
1273
1254
|
Returns ``(commits_recorded, tags_recorded)``. Raises
|
|
1274
1255
|
:class:`ValueError` only when the host or repository can't be
|
|
1275
1256
|
resolved; ClickHouse failures are swallowed (the count reflects
|
|
1276
|
-
what was written).
|
|
1257
|
+
what was written). Returns ``(0, 0)`` (logged as a warning, no
|
|
1258
|
+
error) when the GitHub App is not installed for the repo, so an
|
|
1259
|
+
uninstalled App never fails the backfill worker. Propagates
|
|
1260
|
+
:class:`PluginRateLimited` when a
|
|
1277
1261
|
GitHub rate-limit reset is further out than
|
|
1278
1262
|
``_BACKFILL_MAX_WAIT_SECONDS`` so the host can pause the worker and
|
|
1279
1263
|
keep the job queued until GitHub resumes rather than fail it.
|
|
@@ -1287,7 +1271,15 @@ class GitHubCommitSync(CommitSyncCapability):
|
|
|
1287
1271
|
base = host_to_api_base(host)
|
|
1288
1272
|
owner, repo = resolve_owner_repo(ctx, host, 'github-commit-sync')
|
|
1289
1273
|
pushed_at = datetime.datetime.now(datetime.UTC)
|
|
1290
|
-
|
|
1274
|
+
try:
|
|
1275
|
+
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
1276
|
+
except _app_auth.AppNotInstalledError as exc:
|
|
1277
|
+
LOGGER.warning(
|
|
1278
|
+
'github-commit-sync: %s; skipping backfill for project %s',
|
|
1279
|
+
exc,
|
|
1280
|
+
ctx.project_id,
|
|
1281
|
+
)
|
|
1282
|
+
return 0, 0
|
|
1291
1283
|
async with _client(base, owner, repo, token) as client:
|
|
1292
1284
|
branch = await _fetch_default_branch(
|
|
1293
1285
|
client, max_wait=_BACKFILL_MAX_WAIT_SECONDS
|
{imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/deployment.py
RENAMED
|
@@ -236,6 +236,49 @@ def _record_checks_disabled(
|
|
|
236
236
|
_CHECKS_DISABLED_TOKENS[key] = now
|
|
237
237
|
|
|
238
238
|
|
|
239
|
+
# Process-wide 403 suppression for release-notes lookups, mirroring the
|
|
240
|
+
# check-runs cache above: a token that lacks the scope to read releases
|
|
241
|
+
# must not re-issue ``GET /releases/tags/{ref}`` for every deployment on
|
|
242
|
+
# every resync sweep. Keyed by a hash of the repo-scoped client's base
|
|
243
|
+
# URL (host + ``owner/repo``) plus its bearer header (the token), so a
|
|
244
|
+
# single forbidden repo doesn't suppress release reads elsewhere.
|
|
245
|
+
_RELEASES_FORBIDDEN_TOKENS: dict[str, float] = {}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _releases_cache_key(client: httpx.AsyncClient) -> str:
|
|
249
|
+
material = f'{client.base_url}\n{client.headers.get("authorization", "")}'
|
|
250
|
+
return hashlib.sha256(material.encode()).hexdigest()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _releases_forbidden(client: httpx.AsyncClient) -> bool:
|
|
254
|
+
"""Whether this client's token has recently 403'd on releases."""
|
|
255
|
+
key = _releases_cache_key(client)
|
|
256
|
+
recorded = _RELEASES_FORBIDDEN_TOKENS.get(key)
|
|
257
|
+
if recorded is None:
|
|
258
|
+
return False
|
|
259
|
+
if time.monotonic() - recorded > _CHECKS_DISABLED_TTL_SECONDS:
|
|
260
|
+
_RELEASES_FORBIDDEN_TOKENS.pop(key, None)
|
|
261
|
+
return False
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _record_releases_forbidden(client: httpx.AsyncClient) -> None:
|
|
266
|
+
"""Mark this client's token as forbidden from releases for the TTL.
|
|
267
|
+
|
|
268
|
+
Opportunistically evicts expired entries so the dict can't grow
|
|
269
|
+
unbounded, mirroring :func:`_record_checks_disabled`.
|
|
270
|
+
"""
|
|
271
|
+
now = time.monotonic()
|
|
272
|
+
expired = [
|
|
273
|
+
k
|
|
274
|
+
for k, recorded in _RELEASES_FORBIDDEN_TOKENS.items()
|
|
275
|
+
if now - recorded > _CHECKS_DISABLED_TTL_SECONDS
|
|
276
|
+
]
|
|
277
|
+
for k in expired:
|
|
278
|
+
_RELEASES_FORBIDDEN_TOKENS.pop(k, None)
|
|
279
|
+
_RELEASES_FORBIDDEN_TOKENS[_releases_cache_key(client)] = now
|
|
280
|
+
|
|
281
|
+
|
|
239
282
|
def _commit_from_payload(payload: dict[str, typing.Any]) -> Commit:
|
|
240
283
|
"""Convert a GitHub commit list/object payload into a :class:`Commit`."""
|
|
241
284
|
sha = str(payload.get('sha', ''))
|
|
@@ -322,15 +365,25 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
322
365
|
return value.strip()
|
|
323
366
|
return default
|
|
324
367
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
368
|
+
async def _bearer(
|
|
369
|
+
self, ctx: PluginContext, credentials: dict[str, str]
|
|
370
|
+
) -> str:
|
|
371
|
+
"""Resolve the Bearer token for this deployment call.
|
|
372
|
+
|
|
373
|
+
Prefers the per-user OAuth token the host threads in as
|
|
374
|
+
``access_token``; when a service configured with only GitHub App
|
|
375
|
+
credentials drives the call (e.g. the headless deployment-resync
|
|
376
|
+
sweep, which has no acting user), mints an installation token
|
|
377
|
+
from ``app_id`` + ``private_key`` instead.
|
|
378
|
+
"""
|
|
379
|
+
# Local import: ``_app_auth`` imports this module for its shared
|
|
380
|
+
# HTTP helpers, so a top-level import here would be circular.
|
|
381
|
+
from imbi_plugin_github import _app_auth
|
|
382
|
+
|
|
383
|
+
owner, repo = self._owner_repo(ctx)
|
|
384
|
+
return await _app_auth.resolve_bearer(
|
|
385
|
+
credentials, self._api_base(ctx), owner, repo
|
|
386
|
+
)
|
|
334
387
|
|
|
335
388
|
@contextlib.asynccontextmanager
|
|
336
389
|
async def _client(
|
|
@@ -358,7 +411,7 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
358
411
|
|
|
359
412
|
client = httpx.AsyncClient(
|
|
360
413
|
timeout=_HTTP_TIMEOUT_SECONDS,
|
|
361
|
-
headers=_auth_headers(self.
|
|
414
|
+
headers=_auth_headers(await self._bearer(ctx, credentials)),
|
|
362
415
|
base_url=self._repo_url(ctx),
|
|
363
416
|
follow_redirects=True,
|
|
364
417
|
event_hooks={'response': [_capture_redirect, _raise_on_401]},
|
|
@@ -388,7 +441,7 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
388
441
|
return
|
|
389
442
|
try:
|
|
390
443
|
resp = await client.get(repo_root)
|
|
391
|
-
except
|
|
444
|
+
except httpx.HTTPError, PluginAuthenticationFailed:
|
|
392
445
|
# The user-facing request already succeeded; a probe failure
|
|
393
446
|
# (network, or a 401 surfaced by the response hook) must not
|
|
394
447
|
# turn that success into an error during teardown.
|
|
@@ -958,6 +1011,25 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
958
1011
|
)
|
|
959
1012
|
return [observed for group in per_env for observed in group]
|
|
960
1013
|
|
|
1014
|
+
async def get_release_notes(
|
|
1015
|
+
self,
|
|
1016
|
+
ctx: PluginContext,
|
|
1017
|
+
credentials: dict[str, str],
|
|
1018
|
+
tag: str,
|
|
1019
|
+
) -> str | None:
|
|
1020
|
+
"""Return the GitHub release body for ``tag``.
|
|
1021
|
+
|
|
1022
|
+
Tag-keyed enrichment path: the host calls this when it knows the
|
|
1023
|
+
release tag but not its notes -- a webhook that created the
|
|
1024
|
+
``Release`` node from a deployment event (whose payload carries no
|
|
1025
|
+
body), or a resync whose deployment ``ref`` was a raw SHA. Opens
|
|
1026
|
+
a repo-scoped client and defers to :meth:`_release_notes_for_ref`,
|
|
1027
|
+
which 404s to ``None`` for tags without a release and caches 403s
|
|
1028
|
+
so a scope-limited token short-circuits.
|
|
1029
|
+
"""
|
|
1030
|
+
async with self._client(ctx, credentials) as client:
|
|
1031
|
+
return await self._release_notes_for_ref(client, tag)
|
|
1032
|
+
|
|
961
1033
|
async def _list_deployments_for_env(
|
|
962
1034
|
self,
|
|
963
1035
|
client: httpx.AsyncClient,
|
|
@@ -1022,6 +1094,11 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1022
1094
|
)
|
|
1023
1095
|
ref_value = deployment.get('ref')
|
|
1024
1096
|
description = deployment.get('description')
|
|
1097
|
+
release_notes = (
|
|
1098
|
+
await self._release_notes_for_ref(client, str(ref_value))
|
|
1099
|
+
if ref_value
|
|
1100
|
+
else None
|
|
1101
|
+
)
|
|
1025
1102
|
deployment_url = deployment.get('url') or deployment.get('html_url')
|
|
1026
1103
|
creator_login: str | None = None
|
|
1027
1104
|
creator_subject: str | None = None
|
|
@@ -1046,10 +1123,45 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1046
1123
|
run_url=status_url,
|
|
1047
1124
|
deployment_url=str(deployment_url) if deployment_url else None,
|
|
1048
1125
|
description=str(description) if description else None,
|
|
1126
|
+
release_notes=release_notes,
|
|
1049
1127
|
creator=creator_login,
|
|
1050
1128
|
creator_subject=creator_subject,
|
|
1051
1129
|
)
|
|
1052
1130
|
|
|
1131
|
+
async def _release_notes_for_ref(
|
|
1132
|
+
self, client: httpx.AsyncClient, ref: str
|
|
1133
|
+
) -> str | None:
|
|
1134
|
+
"""Return the GitHub release notes body for a deployed ref.
|
|
1135
|
+
|
|
1136
|
+
A deployment created against a semver tag has a matching GitHub
|
|
1137
|
+
release whose ``body`` is the "What's Changed" markdown the host
|
|
1138
|
+
persists on the ``Release`` node. Refs that aren't a release tag
|
|
1139
|
+
(branches, raw SHAs) 404 here and yield ``None`` -- resync is
|
|
1140
|
+
never blocked by a missing or unreadable release. A ``403``
|
|
1141
|
+
(token lacks scope to read releases) is cached process-wide so a
|
|
1142
|
+
forbidden token short-circuits instead of re-issuing the request
|
|
1143
|
+
for every deployment on every resync sweep.
|
|
1144
|
+
"""
|
|
1145
|
+
if _releases_forbidden(client):
|
|
1146
|
+
return None
|
|
1147
|
+
try:
|
|
1148
|
+
resp = await client.get(
|
|
1149
|
+
f'/releases/tags/{urllib.parse.quote(ref, safe="")}'
|
|
1150
|
+
)
|
|
1151
|
+
except httpx.HTTPError:
|
|
1152
|
+
return None
|
|
1153
|
+
if resp.status_code == 403:
|
|
1154
|
+
_record_releases_forbidden(client)
|
|
1155
|
+
return None
|
|
1156
|
+
if resp.status_code != 200:
|
|
1157
|
+
return None
|
|
1158
|
+
try:
|
|
1159
|
+
data = typing.cast(dict[str, typing.Any], resp.json())
|
|
1160
|
+
except ValueError:
|
|
1161
|
+
return None
|
|
1162
|
+
body = data.get('body')
|
|
1163
|
+
return str(body) if body else None
|
|
1164
|
+
|
|
1053
1165
|
async def _latest_status(
|
|
1054
1166
|
self, client: httpx.AsyncClient, deployment_id: str
|
|
1055
1167
|
) -> tuple[DeploymentEventStatus, str | None]:
|
|
@@ -15,6 +15,7 @@ its own.
|
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
+
import logging
|
|
18
19
|
import re
|
|
19
20
|
import typing
|
|
20
21
|
|
|
@@ -37,6 +38,11 @@ from imbi_plugin_github._hosts import (
|
|
|
37
38
|
host_to_api_base,
|
|
38
39
|
)
|
|
39
40
|
from imbi_plugin_github._repos import derive_owner_repo_from_links
|
|
41
|
+
from imbi_plugin_github.commits import (
|
|
42
|
+
_resolve_bearer, # pyright: ignore[reportPrivateUsage]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
LOGGER = logging.getLogger(__name__)
|
|
40
46
|
|
|
41
47
|
_TIMEOUT = 15.0
|
|
42
48
|
# Matches the canonical URL shape written by the lifecycle plugin:
|
|
@@ -100,7 +106,7 @@ def _item(
|
|
|
100
106
|
|
|
101
107
|
|
|
102
108
|
def _repo_fetch_url(
|
|
103
|
-
connection: ServiceConnection,
|
|
109
|
+
connection: ServiceConnection | None,
|
|
104
110
|
ctx: PluginContext,
|
|
105
111
|
host: str,
|
|
106
112
|
api_base: str,
|
|
@@ -109,7 +115,9 @@ def _repo_fetch_url(
|
|
|
109
115
|
|
|
110
116
|
Prefer the rename-stable canonical URL on the ``EXISTS_IN`` edge;
|
|
111
117
|
otherwise derive ``(owner, repo)`` from the project links. Returns
|
|
112
|
-
``None`` when neither is available.
|
|
118
|
+
``None`` when neither is available. ``connection`` is ``None`` when
|
|
119
|
+
no ``EXISTS_IN`` edge exists yet (the create-edge remediation), in
|
|
120
|
+
which case the URL is derived from the project links alone.
|
|
113
121
|
|
|
114
122
|
The stored canonical URL is never fetched verbatim: only the numeric
|
|
115
123
|
repository id is trusted from it, and the request URL is rebuilt
|
|
@@ -117,7 +125,7 @@ def _repo_fetch_url(
|
|
|
117
125
|
truth). This keeps the Bearer token from ever being sent to an
|
|
118
126
|
arbitrary host smuggled onto the ``EXISTS_IN`` edge.
|
|
119
127
|
"""
|
|
120
|
-
if connection.canonical_url:
|
|
128
|
+
if connection is not None and connection.canonical_url:
|
|
121
129
|
match = _REPO_ID_RE.fullmatch(connection.canonical_url)
|
|
122
130
|
if match is not None:
|
|
123
131
|
return f'{api_base}/repositories/{match.group(1)}'
|
|
@@ -147,6 +155,38 @@ def _find_connection(
|
|
|
147
155
|
)
|
|
148
156
|
|
|
149
157
|
|
|
158
|
+
async def _resolve_doctor_bearer(
|
|
159
|
+
credentials: dict[str, str],
|
|
160
|
+
api_base: str,
|
|
161
|
+
ctx: PluginContext,
|
|
162
|
+
host: str,
|
|
163
|
+
) -> str | None:
|
|
164
|
+
"""Resolve a Bearer token for analysis / remediation, or ``None``.
|
|
165
|
+
|
|
166
|
+
Accepts the same credential shapes as the sync capabilities via
|
|
167
|
+
:func:`_resolve_bearer` — a PAT (``access_token`` / ``token``) or
|
|
168
|
+
GitHub App creds (``app_id`` + ``private_key``, minting a short-lived
|
|
169
|
+
installation token). App-token minting needs ``(owner, repo)`` for
|
|
170
|
+
installation discovery, derived from the project links (an explicit
|
|
171
|
+
``installation_id`` credential skips that). Never raises: the doctor
|
|
172
|
+
must not hard-fail on a missing or unusable credential, so it degrades
|
|
173
|
+
to an unauthenticated request, which the caller reports softly.
|
|
174
|
+
"""
|
|
175
|
+
derived = derive_owner_repo_from_links(
|
|
176
|
+
ctx.project_links, host, preferred_key=ctx.integration_slug
|
|
177
|
+
)
|
|
178
|
+
owner, repo = derived or ('', '')
|
|
179
|
+
try:
|
|
180
|
+
return await _resolve_bearer(credentials, api_base, owner, repo)
|
|
181
|
+
except Exception: # noqa: BLE001 -- analysis is best-effort
|
|
182
|
+
LOGGER.warning(
|
|
183
|
+
'github-doctor: could not resolve a GitHub credential; '
|
|
184
|
+
'proceeding unauthenticated',
|
|
185
|
+
exc_info=True,
|
|
186
|
+
)
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
150
190
|
class GitHubDoctor(AnalysisCapability):
|
|
151
191
|
"""GitHub project-doctor analysis capability.
|
|
152
192
|
|
|
@@ -189,8 +229,13 @@ class GitHubDoctor(AnalysisCapability):
|
|
|
189
229
|
'EXISTS_IN edge',
|
|
190
230
|
'warn',
|
|
191
231
|
f'No EXISTS_IN edge found for service {slug!r}. '
|
|
192
|
-
'
|
|
193
|
-
'
|
|
232
|
+
'Use the Fix action to create the repository link '
|
|
233
|
+
'from GitHub, or run the lifecycle plugin to '
|
|
234
|
+
're-index this project.',
|
|
235
|
+
RemediationOffer(
|
|
236
|
+
id=_REPAIR_EDGE,
|
|
237
|
+
label='Create the EXISTS_IN edge from GitHub',
|
|
238
|
+
),
|
|
194
239
|
)
|
|
195
240
|
]
|
|
196
241
|
|
|
@@ -204,8 +249,11 @@ class GitHubDoctor(AnalysisCapability):
|
|
|
204
249
|
)
|
|
205
250
|
)
|
|
206
251
|
|
|
207
|
-
# Step 2: build the Bearer token (optional).
|
|
208
|
-
|
|
252
|
+
# Step 2: build the Bearer token (optional). Accepts both a PAT
|
|
253
|
+
# and GitHub App creds (minting an installation token), matching
|
|
254
|
+
# the sync capabilities -- an App-only integration must not be
|
|
255
|
+
# reported as "no token configured".
|
|
256
|
+
token = await _resolve_doctor_bearer(credentials, api_base, ctx, host)
|
|
209
257
|
headers: dict[str, str] = {}
|
|
210
258
|
if token:
|
|
211
259
|
headers['Authorization'] = f'Bearer {token}'
|
|
@@ -256,9 +304,10 @@ class GitHubDoctor(AnalysisCapability):
|
|
|
256
304
|
else:
|
|
257
305
|
status = 'warn'
|
|
258
306
|
hint = (
|
|
259
|
-
'No
|
|
260
|
-
'private. Configure an access_token
|
|
261
|
-
'
|
|
307
|
+
'No usable GitHub credential was resolved; the '
|
|
308
|
+
'repository may be private. Configure an access_token '
|
|
309
|
+
'(PAT) or app_id + private_key (GitHub App) credential '
|
|
310
|
+
'to inspect private repositories.'
|
|
262
311
|
)
|
|
263
312
|
results.append(
|
|
264
313
|
_item(
|
|
@@ -324,7 +373,7 @@ class GitHubDoctor(AnalysisCapability):
|
|
|
324
373
|
# identifier-type
|
|
325
374
|
try:
|
|
326
375
|
int(connection.identifier)
|
|
327
|
-
except
|
|
376
|
+
except ValueError, TypeError:
|
|
328
377
|
results.append(
|
|
329
378
|
_item(
|
|
330
379
|
'identifier-type',
|
|
@@ -506,19 +555,22 @@ class GitHubDoctor(AnalysisCapability):
|
|
|
506
555
|
|
|
507
556
|
return results
|
|
508
557
|
|
|
509
|
-
async def remediate(
|
|
558
|
+
async def remediate( # noqa: C901 — flat sequence of guard clauses
|
|
510
559
|
self,
|
|
511
560
|
ctx: PluginContext,
|
|
512
561
|
credentials: dict[str, str],
|
|
513
562
|
remediation_id: str,
|
|
514
563
|
) -> RemediationResult:
|
|
515
|
-
"""
|
|
564
|
+
"""Create or repair the EXISTS_IN edge / github-repository link.
|
|
516
565
|
|
|
517
566
|
Re-fetches the repository (the live source of truth) and emits a
|
|
518
567
|
``ServiceWriteback`` / ``LinkWriteback`` on ``ctx`` for the host
|
|
519
|
-
to persist.
|
|
520
|
-
|
|
521
|
-
|
|
568
|
+
to persist. ``_REPAIR_EDGE`` also creates the edge when none
|
|
569
|
+
exists yet (the host's writeback is an upsert), deriving the repo
|
|
570
|
+
from the project links. Idempotent: returns ``noop`` when Imbi
|
|
571
|
+
already matches GitHub. A 401 propagates as
|
|
572
|
+
``PluginAuthenticationFailed`` so the host can refresh the actor's
|
|
573
|
+
identity and retry once.
|
|
522
574
|
"""
|
|
523
575
|
if remediation_id not in (_REPAIR_EDGE, _REPAIR_GITHUB_LINK):
|
|
524
576
|
return await super().remediate(ctx, credentials, remediation_id)
|
|
@@ -529,10 +581,20 @@ class GitHubDoctor(AnalysisCapability):
|
|
|
529
581
|
return RemediationResult(status='failed', message=str(exc))
|
|
530
582
|
api_base = host_to_api_base(host)
|
|
531
583
|
slug = ctx.integration_slug
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
584
|
+
if slug is None:
|
|
585
|
+
return RemediationResult(
|
|
586
|
+
status='failed',
|
|
587
|
+
message=(
|
|
588
|
+
'This plugin is not bound to a service — no EXISTS_IN '
|
|
589
|
+
'edge to create or repair.'
|
|
590
|
+
),
|
|
591
|
+
)
|
|
592
|
+
connection = _find_connection(ctx.service_connections, slug)
|
|
593
|
+
# ``_REPAIR_EDGE`` creates a missing edge from GitHub, so a
|
|
594
|
+
# missing connection is only fatal for the github-repository
|
|
595
|
+
# link repair (which never needs the edge but is only ever
|
|
596
|
+
# offered once the edge is present).
|
|
597
|
+
if connection is None and remediation_id == _REPAIR_GITHUB_LINK:
|
|
536
598
|
return RemediationResult(
|
|
537
599
|
status='failed',
|
|
538
600
|
message=(
|
|
@@ -551,7 +613,7 @@ class GitHubDoctor(AnalysisCapability):
|
|
|
551
613
|
),
|
|
552
614
|
)
|
|
553
615
|
|
|
554
|
-
token = credentials
|
|
616
|
+
token = await _resolve_doctor_bearer(credentials, api_base, ctx, host)
|
|
555
617
|
headers: dict[str, str] = {}
|
|
556
618
|
if token:
|
|
557
619
|
headers['Authorization'] = f'Bearer {token}'
|
|
@@ -613,28 +675,30 @@ class GitHubDoctor(AnalysisCapability):
|
|
|
613
675
|
message=f'Set github-repository link to {html_url}.',
|
|
614
676
|
)
|
|
615
677
|
|
|
616
|
-
# _REPAIR_EDGE: rewrite identifier + canonical URL +
|
|
617
|
-
# link in one ServiceWriteback.
|
|
678
|
+
# _REPAIR_EDGE: create or rewrite identifier + canonical URL +
|
|
679
|
+
# dashboard link in one ServiceWriteback.
|
|
618
680
|
desired_canonical = f'{api_base}/repositories/{api_id}'
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
if already_correct:
|
|
625
|
-
return RemediationResult(
|
|
626
|
-
status='noop',
|
|
627
|
-
message='EXISTS_IN edge already matches GitHub.',
|
|
681
|
+
if connection is not None:
|
|
682
|
+
already_correct = (
|
|
683
|
+
connection.identifier == str(api_id)
|
|
684
|
+
and connection.canonical_url == desired_canonical
|
|
685
|
+
and ctx.project_links.get(slug) == html_url
|
|
628
686
|
)
|
|
687
|
+
if already_correct:
|
|
688
|
+
return RemediationResult(
|
|
689
|
+
status='noop',
|
|
690
|
+
message='EXISTS_IN edge already matches GitHub.',
|
|
691
|
+
)
|
|
629
692
|
ctx.service_writeback = ServiceWriteback(
|
|
630
693
|
identifier=str(api_id),
|
|
631
694
|
canonical_url=desired_canonical,
|
|
632
695
|
dashboard_links={slug: html_url},
|
|
633
696
|
)
|
|
697
|
+
verb = 'Repaired' if connection is not None else 'Created'
|
|
634
698
|
return RemediationResult(
|
|
635
699
|
status='fixed',
|
|
636
700
|
message=(
|
|
637
|
-
f'
|
|
701
|
+
f'{verb} the EXISTS_IN edge for {slug!r} '
|
|
638
702
|
f'(identifier={api_id}).'
|
|
639
703
|
),
|
|
640
704
|
)
|
{imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/pull_requests.py
RENAMED
|
@@ -37,6 +37,7 @@ from imbi_common.plugins.base import (
|
|
|
37
37
|
PullRequestSyncCapability,
|
|
38
38
|
)
|
|
39
39
|
|
|
40
|
+
from imbi_plugin_github._app_auth import AppNotInstalledError
|
|
40
41
|
from imbi_plugin_github._hosts import host_to_api_base
|
|
41
42
|
from imbi_plugin_github._repos import resolve_owner_repo
|
|
42
43
|
from imbi_plugin_github.commits import (
|
|
@@ -133,6 +134,21 @@ async def _fetch_all_prs(
|
|
|
133
134
|
resp = await _request(
|
|
134
135
|
client, 'GET', '/pulls', params=params, max_wait=max_wait
|
|
135
136
|
)
|
|
137
|
+
if resp.status_code == 404:
|
|
138
|
+
if out:
|
|
139
|
+
LOGGER.warning(
|
|
140
|
+
'github-pr-sync: repository %s returned 404; stopping '
|
|
141
|
+
'after %d PRs',
|
|
142
|
+
str(client.base_url).rstrip('/'),
|
|
143
|
+
len(out),
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
LOGGER.warning(
|
|
147
|
+
'github-pr-sync: repository %s returned 404 (renamed '
|
|
148
|
+
'or removed); nothing to sync',
|
|
149
|
+
str(client.base_url).rstrip('/'),
|
|
150
|
+
)
|
|
151
|
+
return out
|
|
136
152
|
resp.raise_for_status()
|
|
137
153
|
page: list[dict[str, typing.Any]] = resp.json()
|
|
138
154
|
if not page:
|
|
@@ -301,6 +317,11 @@ class GitHubPullRequestSync(PullRequestSyncCapability):
|
|
|
301
317
|
Returns the number of PRs recorded. Raises :class:`ValueError`
|
|
302
318
|
only when the host or repository can't be resolved; ClickHouse
|
|
303
319
|
failures are swallowed (the count reflects what was written).
|
|
320
|
+
Returns ``0`` (logged as a warning, no error) when the GitHub App
|
|
321
|
+
is not installed for the repo, or when the first page returns 404
|
|
322
|
+
(repo renamed/removed); a 404 on a later page stops pagination and
|
|
323
|
+
returns the PRs collected so far. Neither condition fails the
|
|
324
|
+
backfill worker.
|
|
304
325
|
Propagates :class:`PluginRateLimited` when a GitHub rate-limit
|
|
305
326
|
reset is further out than ``_BACKFILL_MAX_WAIT_SECONDS`` so the
|
|
306
327
|
host can pause the worker and keep the job queued until GitHub
|
|
@@ -314,7 +335,15 @@ class GitHubPullRequestSync(PullRequestSyncCapability):
|
|
|
314
335
|
)
|
|
315
336
|
base = host_to_api_base(host)
|
|
316
337
|
owner, repo = resolve_owner_repo(ctx, host, _SELF_SLUG)
|
|
317
|
-
|
|
338
|
+
try:
|
|
339
|
+
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
340
|
+
except AppNotInstalledError as exc:
|
|
341
|
+
LOGGER.warning(
|
|
342
|
+
'github-pr-sync: %s; skipping backfill for project %s',
|
|
343
|
+
exc,
|
|
344
|
+
ctx.project_id,
|
|
345
|
+
)
|
|
346
|
+
return 0
|
|
318
347
|
async with _client(base, owner, repo, token) as client:
|
|
319
348
|
raw_prs = await _fetch_all_prs(
|
|
320
349
|
client, max_wait=_BACKFILL_MAX_WAIT_SECONDS
|