imbi-plugin-github 2.13.3__tar.gz → 2.14.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.13.3 → imbi_plugin_github-2.14.0}/PKG-INFO +2 -2
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/pyproject.toml +3 -3
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/_app_auth.py +51 -1
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/commits.py +17 -25
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/deployment.py +104 -11
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/doctor.py +97 -33
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/pull_requests.py +30 -1
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/tests/test_commits.py +37 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/tests/test_deployment.py +195 -4
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/tests/test_doctor.py +95 -2
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/tests/test_pull_requests.py +96 -1
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/uv.lock +6 -6
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/.github/workflows/publish.yml +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/.github/workflows/test.yml +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/.gitignore +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/.pre-commit-config.yaml +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/CLAUDE.md +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/LICENSE +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/README.md +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/justfile +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/__init__.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/_hosts.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/_repos.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/identity.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/lifecycle.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/plugin.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/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.0
|
|
4
4
|
Summary: GitHub identity plugin for Imbi (github.com / GHEC / GHES)
|
|
5
5
|
Author-email: "Gavin M. Roy" <gavinr@aweber.com>
|
|
6
6
|
License: BSD-3-Clause
|
|
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.14
|
|
14
14
|
Requires-Python: >=3.14
|
|
15
15
|
Requires-Dist: httpx>=0.27
|
|
16
|
-
Requires-Dist: imbi-common[databases]==2.
|
|
16
|
+
Requires-Dist: imbi-common[databases]==2.14.0
|
|
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.0"
|
|
4
4
|
description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -16,7 +16,7 @@ classifiers = [
|
|
|
16
16
|
]
|
|
17
17
|
dependencies = [
|
|
18
18
|
"httpx>=0.27",
|
|
19
|
-
"imbi-common[databases]==2.
|
|
19
|
+
"imbi-common[databases]==2.14.0",
|
|
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.0",
|
|
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.0}/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.
|
|
@@ -1022,6 +1075,11 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1022
1075
|
)
|
|
1023
1076
|
ref_value = deployment.get('ref')
|
|
1024
1077
|
description = deployment.get('description')
|
|
1078
|
+
release_notes = (
|
|
1079
|
+
await self._release_notes_for_ref(client, str(ref_value))
|
|
1080
|
+
if ref_value
|
|
1081
|
+
else None
|
|
1082
|
+
)
|
|
1025
1083
|
deployment_url = deployment.get('url') or deployment.get('html_url')
|
|
1026
1084
|
creator_login: str | None = None
|
|
1027
1085
|
creator_subject: str | None = None
|
|
@@ -1046,10 +1104,45 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1046
1104
|
run_url=status_url,
|
|
1047
1105
|
deployment_url=str(deployment_url) if deployment_url else None,
|
|
1048
1106
|
description=str(description) if description else None,
|
|
1107
|
+
release_notes=release_notes,
|
|
1049
1108
|
creator=creator_login,
|
|
1050
1109
|
creator_subject=creator_subject,
|
|
1051
1110
|
)
|
|
1052
1111
|
|
|
1112
|
+
async def _release_notes_for_ref(
|
|
1113
|
+
self, client: httpx.AsyncClient, ref: str
|
|
1114
|
+
) -> str | None:
|
|
1115
|
+
"""Return the GitHub release notes body for a deployed ref.
|
|
1116
|
+
|
|
1117
|
+
A deployment created against a semver tag has a matching GitHub
|
|
1118
|
+
release whose ``body`` is the "What's Changed" markdown the host
|
|
1119
|
+
persists on the ``Release`` node. Refs that aren't a release tag
|
|
1120
|
+
(branches, raw SHAs) 404 here and yield ``None`` -- resync is
|
|
1121
|
+
never blocked by a missing or unreadable release. A ``403``
|
|
1122
|
+
(token lacks scope to read releases) is cached process-wide so a
|
|
1123
|
+
forbidden token short-circuits instead of re-issuing the request
|
|
1124
|
+
for every deployment on every resync sweep.
|
|
1125
|
+
"""
|
|
1126
|
+
if _releases_forbidden(client):
|
|
1127
|
+
return None
|
|
1128
|
+
try:
|
|
1129
|
+
resp = await client.get(
|
|
1130
|
+
f'/releases/tags/{urllib.parse.quote(ref, safe="")}'
|
|
1131
|
+
)
|
|
1132
|
+
except httpx.HTTPError:
|
|
1133
|
+
return None
|
|
1134
|
+
if resp.status_code == 403:
|
|
1135
|
+
_record_releases_forbidden(client)
|
|
1136
|
+
return None
|
|
1137
|
+
if resp.status_code != 200:
|
|
1138
|
+
return None
|
|
1139
|
+
try:
|
|
1140
|
+
data = typing.cast(dict[str, typing.Any], resp.json())
|
|
1141
|
+
except ValueError:
|
|
1142
|
+
return None
|
|
1143
|
+
body = data.get('body')
|
|
1144
|
+
return str(body) if body else None
|
|
1145
|
+
|
|
1053
1146
|
async def _latest_status(
|
|
1054
1147
|
self, client: httpx.AsyncClient, deployment_id: str
|
|
1055
1148
|
) -> 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.0}/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
|
|
@@ -1336,6 +1336,43 @@ class SyncAllHistoryTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
1336
1336
|
)
|
|
1337
1337
|
self.assertEqual(1, token_route.call_count)
|
|
1338
1338
|
|
|
1339
|
+
@respx.mock
|
|
1340
|
+
async def test_app_not_installed_skips(self) -> None:
|
|
1341
|
+
# IMBI-37: a backfill for a repo the GitHub App isn't installed
|
|
1342
|
+
# on must skip cleanly -- return (0, 0), log a warning -- rather
|
|
1343
|
+
# than fail the worker with a Sentry error.
|
|
1344
|
+
_app_auth.reset_cache()
|
|
1345
|
+
self.addCleanup(_app_auth.reset_cache)
|
|
1346
|
+
discovery = respx.get(
|
|
1347
|
+
'https://api.github.com/repos/octo/demo/installation'
|
|
1348
|
+
).mock(return_value=httpx.Response(404, json={'message': 'Not Found'}))
|
|
1349
|
+
creds = {'app_id': '971', 'private_key': _APP_KEY_PEM}
|
|
1350
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
|
|
1351
|
+
with self.assertLogs(commits.LOGGER, level='WARNING') as cm:
|
|
1352
|
+
result = await commits.GitHubCommitSync().sync_all_history(
|
|
1353
|
+
ctx=self._ctx(), credentials=creds
|
|
1354
|
+
)
|
|
1355
|
+
self.assertEqual((0, 0), result)
|
|
1356
|
+
self.assertEqual(1, discovery.call_count)
|
|
1357
|
+
insert.assert_not_awaited()
|
|
1358
|
+
self.assertTrue(any('skipping backfill' in x for x in cm.output))
|
|
1359
|
+
|
|
1360
|
+
@respx.mock
|
|
1361
|
+
async def test_app_discovery_non_404_propagates(self) -> None:
|
|
1362
|
+
# A non-404 discovery failure is a real error and must NOT be
|
|
1363
|
+
# swallowed as an app-not-installed skip.
|
|
1364
|
+
_app_auth.reset_cache()
|
|
1365
|
+
self.addCleanup(_app_auth.reset_cache)
|
|
1366
|
+
respx.get('https://api.github.com/repos/octo/demo/installation').mock(
|
|
1367
|
+
return_value=httpx.Response(500, json={'message': 'boom'})
|
|
1368
|
+
)
|
|
1369
|
+
creds = {'app_id': '971', 'private_key': _APP_KEY_PEM}
|
|
1370
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()):
|
|
1371
|
+
with self.assertRaises(httpx.HTTPStatusError):
|
|
1372
|
+
await commits.GitHubCommitSync().sync_all_history(
|
|
1373
|
+
ctx=self._ctx(), credentials=creds
|
|
1374
|
+
)
|
|
1375
|
+
|
|
1339
1376
|
@respx.mock
|
|
1340
1377
|
async def test_annotated_tags_enriched_with_metadata(self) -> None:
|
|
1341
1378
|
self._mock_default_branch()
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Smoke tests for the GitHub deployment capability handler."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import datetime
|
|
4
5
|
import json
|
|
5
6
|
import time
|
|
@@ -256,12 +257,15 @@ class ManifestTestCase(unittest.TestCase):
|
|
|
256
257
|
dep._record_checks_disabled({}, 'github.com', 'octo', 'demo')
|
|
257
258
|
self.assertEqual(dep._CHECKS_DISABLED_TOKENS, original)
|
|
258
259
|
|
|
259
|
-
def
|
|
260
|
+
def test_bearer_requires_credentials(self) -> None:
|
|
260
261
|
with self.assertRaises(ValueError):
|
|
261
|
-
GitHubDeployment.
|
|
262
|
+
asyncio.run(GitHubDeployment()._bearer(_ctx(), {}))
|
|
262
263
|
|
|
263
|
-
def
|
|
264
|
-
|
|
264
|
+
def test_bearer_accepts_token_alias(self) -> None:
|
|
265
|
+
token = asyncio.run(
|
|
266
|
+
GitHubDeployment()._bearer(_ctx(), {'token': 'abc'})
|
|
267
|
+
)
|
|
268
|
+
self.assertEqual(token, 'abc')
|
|
265
269
|
|
|
266
270
|
def test_api_base_dot_com(self) -> None:
|
|
267
271
|
plugin = GitHubDeployment()
|
|
@@ -851,6 +855,59 @@ class GetDeploymentStatusTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
851
855
|
|
|
852
856
|
|
|
853
857
|
class ListRecentDeploymentsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
858
|
+
@respx.mock
|
|
859
|
+
async def test_app_credentials_mint_installation_token(self) -> None:
|
|
860
|
+
# A service configured with only GitHub App credentials (no acting
|
|
861
|
+
# user, e.g. the headless deployment-resync sweep) must mint an
|
|
862
|
+
# installation token and still backfill deployments.
|
|
863
|
+
from imbi_plugin_github import _app_auth
|
|
864
|
+
from tests.test_commits import _APP_KEY_PEM, _FAR_FUTURE
|
|
865
|
+
|
|
866
|
+
_app_auth.reset_cache()
|
|
867
|
+
self.addCleanup(_app_auth.reset_cache)
|
|
868
|
+
token_route = respx.post(
|
|
869
|
+
'https://api.github.com/app/installations/42/access_tokens'
|
|
870
|
+
).mock(
|
|
871
|
+
return_value=httpx.Response(
|
|
872
|
+
201, json={'token': 'ghs_minted', 'expires_at': _FAR_FUTURE}
|
|
873
|
+
)
|
|
874
|
+
)
|
|
875
|
+
respx.get(
|
|
876
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
877
|
+
params={'environment': 'production', 'per_page': '1'},
|
|
878
|
+
).mock(
|
|
879
|
+
return_value=httpx.Response(
|
|
880
|
+
200,
|
|
881
|
+
json=[
|
|
882
|
+
{
|
|
883
|
+
'id': 7,
|
|
884
|
+
'sha': 'appsha',
|
|
885
|
+
'ref': 'main',
|
|
886
|
+
'created_at': '2026-05-13T14:00:00Z',
|
|
887
|
+
}
|
|
888
|
+
],
|
|
889
|
+
)
|
|
890
|
+
)
|
|
891
|
+
respx.get(
|
|
892
|
+
'https://api.github.com/repos/octo/demo/deployments/7/statuses'
|
|
893
|
+
).mock(return_value=httpx.Response(200, json=[]))
|
|
894
|
+
respx.get(
|
|
895
|
+
'https://api.github.com/repos/octo/demo/releases/tags/main'
|
|
896
|
+
).mock(return_value=httpx.Response(404, json={'message': 'Not Found'}))
|
|
897
|
+
plugin = GitHubDeployment()
|
|
898
|
+
events = await plugin.list_recent_deployments(
|
|
899
|
+
_ctx(),
|
|
900
|
+
{
|
|
901
|
+
'app_id': '971',
|
|
902
|
+
'private_key': _APP_KEY_PEM,
|
|
903
|
+
'installation_id': '42',
|
|
904
|
+
},
|
|
905
|
+
['production'],
|
|
906
|
+
)
|
|
907
|
+
self.assertTrue(token_route.called)
|
|
908
|
+
self.assertEqual(len(events), 1)
|
|
909
|
+
self.assertEqual(events[0].sha, 'appsha')
|
|
910
|
+
|
|
854
911
|
@respx.mock
|
|
855
912
|
async def test_one_env_one_deployment_success(self) -> None:
|
|
856
913
|
respx.get(
|
|
@@ -889,12 +946,18 @@ class ListRecentDeploymentsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
889
946
|
],
|
|
890
947
|
)
|
|
891
948
|
)
|
|
949
|
+
# ``main`` is a branch, not a release tag -- the release lookup
|
|
950
|
+
# 404s and ``release_notes`` stays ``None``.
|
|
951
|
+
respx.get(
|
|
952
|
+
'https://api.github.com/repos/octo/demo/releases/tags/main'
|
|
953
|
+
).mock(return_value=httpx.Response(404, json={'message': 'Not Found'}))
|
|
892
954
|
plugin = GitHubDeployment()
|
|
893
955
|
events = await plugin.list_recent_deployments(
|
|
894
956
|
_ctx(), _CREDS, ['infrastructure-testing']
|
|
895
957
|
)
|
|
896
958
|
self.assertEqual(len(events), 1)
|
|
897
959
|
event = events[0]
|
|
960
|
+
self.assertIsNone(event.release_notes)
|
|
898
961
|
self.assertEqual(event.environment, 'infrastructure-testing')
|
|
899
962
|
self.assertEqual(event.sha, '2668cd0abc')
|
|
900
963
|
self.assertEqual(event.ref, 'main')
|
|
@@ -960,6 +1023,12 @@ class ListRecentDeploymentsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
960
1023
|
respx.get(
|
|
961
1024
|
'https://api.github.com/repos/octo/demo/deployments/2/statuses'
|
|
962
1025
|
).mock(return_value=httpx.Response(200, json=[]))
|
|
1026
|
+
respx.get(
|
|
1027
|
+
'https://api.github.com/repos/octo/demo/releases/tags/v1.0.0'
|
|
1028
|
+
).mock(return_value=httpx.Response(404, json={'message': 'Not Found'}))
|
|
1029
|
+
respx.get(
|
|
1030
|
+
'https://api.github.com/repos/octo/demo/releases/tags/main'
|
|
1031
|
+
).mock(return_value=httpx.Response(404, json={'message': 'Not Found'}))
|
|
963
1032
|
plugin = GitHubDeployment()
|
|
964
1033
|
events = await plugin.list_recent_deployments(
|
|
965
1034
|
_ctx(), _CREDS, ['production', 'staging']
|
|
@@ -1106,6 +1175,128 @@ class ListRecentDeploymentsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
1106
1175
|
)
|
|
1107
1176
|
self.assertEqual([e.external_run_id for e in events], ['11'])
|
|
1108
1177
|
|
|
1178
|
+
@respx.mock
|
|
1179
|
+
async def test_release_notes_populated_for_tag_ref(self) -> None:
|
|
1180
|
+
# A deployment against a release tag carries the release body so
|
|
1181
|
+
# the host can persist it as the Release node's notes.
|
|
1182
|
+
respx.get('https://api.github.com/repos/octo/demo/deployments').mock(
|
|
1183
|
+
return_value=httpx.Response(
|
|
1184
|
+
200,
|
|
1185
|
+
json=[
|
|
1186
|
+
{
|
|
1187
|
+
'id': 42,
|
|
1188
|
+
'sha': 'relsha',
|
|
1189
|
+
'ref': '5.9.0',
|
|
1190
|
+
'created_at': '2026-05-13T14:00:00Z',
|
|
1191
|
+
}
|
|
1192
|
+
],
|
|
1193
|
+
)
|
|
1194
|
+
)
|
|
1195
|
+
respx.get(
|
|
1196
|
+
'https://api.github.com/repos/octo/demo/deployments/42/statuses'
|
|
1197
|
+
).mock(return_value=httpx.Response(200, json=[]))
|
|
1198
|
+
respx.get(
|
|
1199
|
+
'https://api.github.com/repos/octo/demo/releases/tags/5.9.0'
|
|
1200
|
+
).mock(
|
|
1201
|
+
return_value=httpx.Response(
|
|
1202
|
+
200,
|
|
1203
|
+
json={
|
|
1204
|
+
'tag_name': '5.9.0',
|
|
1205
|
+
'body': "## What's Changed\n- Migrated to servicelib",
|
|
1206
|
+
'published_at': '2026-05-13T13:55:00Z',
|
|
1207
|
+
},
|
|
1208
|
+
)
|
|
1209
|
+
)
|
|
1210
|
+
plugin = GitHubDeployment()
|
|
1211
|
+
events = await plugin.list_recent_deployments(
|
|
1212
|
+
_ctx(), _CREDS, ['production']
|
|
1213
|
+
)
|
|
1214
|
+
self.assertEqual(
|
|
1215
|
+
events[0].release_notes,
|
|
1216
|
+
"## What's Changed\n- Migrated to servicelib",
|
|
1217
|
+
)
|
|
1218
|
+
|
|
1219
|
+
@respx.mock
|
|
1220
|
+
async def test_release_notes_none_when_body_empty(self) -> None:
|
|
1221
|
+
# A release with an empty body yields ``None`` rather than an
|
|
1222
|
+
# empty string, matching the "no notes" host semantics.
|
|
1223
|
+
respx.get('https://api.github.com/repos/octo/demo/deployments').mock(
|
|
1224
|
+
return_value=httpx.Response(
|
|
1225
|
+
200,
|
|
1226
|
+
json=[
|
|
1227
|
+
{
|
|
1228
|
+
'id': 43,
|
|
1229
|
+
'sha': 'relsha2',
|
|
1230
|
+
'ref': '6.0.0',
|
|
1231
|
+
'created_at': '2026-05-13T14:00:00Z',
|
|
1232
|
+
}
|
|
1233
|
+
],
|
|
1234
|
+
)
|
|
1235
|
+
)
|
|
1236
|
+
respx.get(
|
|
1237
|
+
'https://api.github.com/repos/octo/demo/deployments/43/statuses'
|
|
1238
|
+
).mock(return_value=httpx.Response(200, json=[]))
|
|
1239
|
+
respx.get(
|
|
1240
|
+
'https://api.github.com/repos/octo/demo/releases/tags/6.0.0'
|
|
1241
|
+
).mock(
|
|
1242
|
+
return_value=httpx.Response(
|
|
1243
|
+
200, json={'tag_name': '6.0.0', 'body': ''}
|
|
1244
|
+
)
|
|
1245
|
+
)
|
|
1246
|
+
plugin = GitHubDeployment()
|
|
1247
|
+
events = await plugin.list_recent_deployments(
|
|
1248
|
+
_ctx(), _CREDS, ['production']
|
|
1249
|
+
)
|
|
1250
|
+
self.assertIsNone(events[0].release_notes)
|
|
1251
|
+
|
|
1252
|
+
@respx.mock
|
|
1253
|
+
async def test_release_notes_403_suppresses_further_lookups(self) -> None:
|
|
1254
|
+
# A token that can't read releases 403s once; the process-wide
|
|
1255
|
+
# cache then short-circuits subsequent lookups (same repo+token)
|
|
1256
|
+
# so resync doesn't re-issue the failing request per deployment.
|
|
1257
|
+
from imbi_plugin_github.deployment import _RELEASES_FORBIDDEN_TOKENS
|
|
1258
|
+
|
|
1259
|
+
_RELEASES_FORBIDDEN_TOKENS.clear()
|
|
1260
|
+
self.addCleanup(_RELEASES_FORBIDDEN_TOKENS.clear)
|
|
1261
|
+
respx.get('https://api.github.com/repos/octo/demo/deployments').mock(
|
|
1262
|
+
return_value=httpx.Response(
|
|
1263
|
+
200,
|
|
1264
|
+
json=[
|
|
1265
|
+
{
|
|
1266
|
+
'id': 51,
|
|
1267
|
+
'sha': 'sha1',
|
|
1268
|
+
'ref': 'v1.0.0',
|
|
1269
|
+
'created_at': '2026-05-13T14:00:00Z',
|
|
1270
|
+
},
|
|
1271
|
+
{
|
|
1272
|
+
'id': 52,
|
|
1273
|
+
'sha': 'sha2',
|
|
1274
|
+
'ref': 'v2.0.0',
|
|
1275
|
+
'created_at': '2026-05-13T14:05:00Z',
|
|
1276
|
+
},
|
|
1277
|
+
],
|
|
1278
|
+
)
|
|
1279
|
+
)
|
|
1280
|
+
respx.get(
|
|
1281
|
+
'https://api.github.com/repos/octo/demo/deployments/51/statuses'
|
|
1282
|
+
).mock(return_value=httpx.Response(200, json=[]))
|
|
1283
|
+
respx.get(
|
|
1284
|
+
'https://api.github.com/repos/octo/demo/deployments/52/statuses'
|
|
1285
|
+
).mock(return_value=httpx.Response(200, json=[]))
|
|
1286
|
+
# Only the first release lookup is mocked (403). The second
|
|
1287
|
+
# (v2.0.0) is deliberately left unmocked: if the 403 weren't
|
|
1288
|
+
# cached, resync would issue it and respx would raise.
|
|
1289
|
+
first = respx.get(
|
|
1290
|
+
'https://api.github.com/repos/octo/demo/releases/tags/v1.0.0'
|
|
1291
|
+
).mock(return_value=httpx.Response(403, json={'message': 'Forbidden'}))
|
|
1292
|
+
plugin = GitHubDeployment()
|
|
1293
|
+
events = await plugin.list_recent_deployments(
|
|
1294
|
+
_ctx(), _CREDS, ['production'], limit=2
|
|
1295
|
+
)
|
|
1296
|
+
self.assertEqual(len(events), 2)
|
|
1297
|
+
self.assertTrue(all(e.release_notes is None for e in events))
|
|
1298
|
+
self.assertEqual(first.call_count, 1)
|
|
1299
|
+
|
|
1109
1300
|
|
|
1110
1301
|
class CheckStatusTestCase(unittest.IsolatedAsyncioTestCase):
|
|
1111
1302
|
@respx.mock
|
|
@@ -4,6 +4,8 @@ import unittest
|
|
|
4
4
|
|
|
5
5
|
import httpx
|
|
6
6
|
import respx
|
|
7
|
+
from cryptography.hazmat.primitives import serialization
|
|
8
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
7
9
|
from imbi_common.plugins.base import (
|
|
8
10
|
AnalysisResultItem,
|
|
9
11
|
PluginContext,
|
|
@@ -14,6 +16,7 @@ from imbi_common.plugins.errors import (
|
|
|
14
16
|
PluginRemediationNotSupported,
|
|
15
17
|
)
|
|
16
18
|
|
|
19
|
+
from imbi_plugin_github import _app_auth
|
|
17
20
|
from imbi_plugin_github.doctor import (
|
|
18
21
|
_REPAIR_EDGE,
|
|
19
22
|
_REPAIR_GITHUB_LINK,
|
|
@@ -30,6 +33,20 @@ _TPS_SLUG = 'aweber-github'
|
|
|
30
33
|
_CREDS = {'access_token': 'gho_test'}
|
|
31
34
|
|
|
32
35
|
|
|
36
|
+
def _gen_pem() -> str:
|
|
37
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
38
|
+
return key.private_bytes(
|
|
39
|
+
serialization.Encoding.PEM,
|
|
40
|
+
serialization.PrivateFormat.PKCS8,
|
|
41
|
+
serialization.NoEncryption(),
|
|
42
|
+
).decode()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Generated once for the module; signing only needs a valid RSA key.
|
|
46
|
+
_APP_KEY_PEM = _gen_pem()
|
|
47
|
+
_FAR_FUTURE = '2099-01-01T00:00:00Z'
|
|
48
|
+
|
|
49
|
+
|
|
33
50
|
def _ctx(
|
|
34
51
|
*,
|
|
35
52
|
service_slug: str | None = _TPS_SLUG,
|
|
@@ -98,6 +115,11 @@ class AnalyzeTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
98
115
|
self.assertEqual(len(results), 1)
|
|
99
116
|
self.assertEqual(results[0].slug, 'exists-in')
|
|
100
117
|
self.assertEqual(results[0].status, 'warn')
|
|
118
|
+
# The no-edge warning offers a Fix action to create the edge.
|
|
119
|
+
offer = results[0].remediation
|
|
120
|
+
self.assertIsNotNone(offer)
|
|
121
|
+
assert offer is not None
|
|
122
|
+
self.assertEqual(offer.id, _REPAIR_EDGE)
|
|
101
123
|
|
|
102
124
|
async def test_warns_when_no_integration_flavor(self) -> None:
|
|
103
125
|
# Without a valid integration flavor the host cannot be resolved.
|
|
@@ -107,6 +129,11 @@ class AnalyzeTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
107
129
|
self.assertEqual(results[0].slug, 'connection')
|
|
108
130
|
self.assertEqual(results[0].status, 'warn')
|
|
109
131
|
|
|
132
|
+
def tearDown(self) -> None:
|
|
133
|
+
# App installation tokens are cached process-wide; reset so an
|
|
134
|
+
# App-cred test doesn't leak a minted token into later tests.
|
|
135
|
+
_app_auth.reset_cache()
|
|
136
|
+
|
|
110
137
|
@respx.mock
|
|
111
138
|
async def test_happy_path(self) -> None:
|
|
112
139
|
respx.get(_CANONICAL).mock(
|
|
@@ -313,6 +340,37 @@ class AnalyzeTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
313
340
|
results = await plugin.analyze(_ctx(), _CREDS)
|
|
314
341
|
self.assertEqual(_by_slug(results)['canonical-url'].status, 'fail')
|
|
315
342
|
|
|
343
|
+
@respx.mock
|
|
344
|
+
async def test_app_credentials_mint_token_for_analysis(self) -> None:
|
|
345
|
+
# An App-only integration (app_id + private_key) must analyze
|
|
346
|
+
# successfully by minting an installation token via the sync
|
|
347
|
+
# path's resolver -- not warn "no token configured".
|
|
348
|
+
token_route = respx.post(
|
|
349
|
+
'https://api.aweber.ghe.com/app/installations/55/access_tokens'
|
|
350
|
+
).mock(
|
|
351
|
+
return_value=httpx.Response(
|
|
352
|
+
201, json={'token': 'ghs_minted', 'expires_at': _FAR_FUTURE}
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
repo_route = respx.get(_CANONICAL).mock(
|
|
356
|
+
return_value=httpx.Response(200, json=_REPO_PAYLOAD)
|
|
357
|
+
)
|
|
358
|
+
plugin = GitHubDoctor()
|
|
359
|
+
results = await plugin.analyze(
|
|
360
|
+
_ctx(),
|
|
361
|
+
{
|
|
362
|
+
'app_id': '971',
|
|
363
|
+
'private_key': _APP_KEY_PEM,
|
|
364
|
+
'installation_id': '55',
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
self.assertEqual(_by_slug(results)['canonical-url'].status, 'pass')
|
|
368
|
+
self.assertEqual(token_route.call_count, 1)
|
|
369
|
+
self.assertEqual(
|
|
370
|
+
'Bearer ghs_minted',
|
|
371
|
+
repo_route.calls.last.request.headers['authorization'],
|
|
372
|
+
)
|
|
373
|
+
|
|
316
374
|
@respx.mock
|
|
317
375
|
async def test_canonical_fetch_404(self) -> None:
|
|
318
376
|
respx.get(_CANONICAL).mock(return_value=httpx.Response(404))
|
|
@@ -407,10 +465,45 @@ class RemediateTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
407
465
|
with self.assertRaises(PluginRemediationNotSupported):
|
|
408
466
|
await plugin.remediate(_ctx(), _CREDS, 'bogus')
|
|
409
467
|
|
|
410
|
-
async def
|
|
468
|
+
async def test_no_service_binding_failed(self) -> None:
|
|
469
|
+
plugin = GitHubDoctor()
|
|
470
|
+
result = await plugin.remediate(
|
|
471
|
+
_ctx(service_slug=None), _CREDS, _REPAIR_EDGE
|
|
472
|
+
)
|
|
473
|
+
self.assertEqual(result.status, 'failed')
|
|
474
|
+
|
|
475
|
+
async def test_github_link_repair_no_connection_failed(self) -> None:
|
|
476
|
+
# The github-repository link repair still requires an edge.
|
|
477
|
+
plugin = GitHubDoctor()
|
|
478
|
+
result = await plugin.remediate(
|
|
479
|
+
_ctx(connections=[]), _CREDS, _REPAIR_GITHUB_LINK
|
|
480
|
+
)
|
|
481
|
+
self.assertEqual(result.status, 'failed')
|
|
482
|
+
|
|
483
|
+
@respx.mock
|
|
484
|
+
async def test_edge_create_when_no_connection(self) -> None:
|
|
485
|
+
# No EXISTS_IN edge yet: the repo is resolved from the project
|
|
486
|
+
# links and a ServiceWriteback is emitted to create the edge.
|
|
487
|
+
repo_url = f'{_API_BASE}/repos/aweber/demo'
|
|
488
|
+
respx.get(repo_url).mock(
|
|
489
|
+
return_value=httpx.Response(200, json=_REPO_PAYLOAD)
|
|
490
|
+
)
|
|
491
|
+
plugin = GitHubDoctor()
|
|
492
|
+
ctx = _ctx(connections=[])
|
|
493
|
+
result = await plugin.remediate(ctx, _CREDS, _REPAIR_EDGE)
|
|
494
|
+
self.assertEqual(result.status, 'fixed')
|
|
495
|
+
assert ctx.service_writeback is not None
|
|
496
|
+
self.assertEqual(ctx.service_writeback.identifier, '134741')
|
|
497
|
+
self.assertEqual(ctx.service_writeback.canonical_url, _CANONICAL)
|
|
498
|
+
self.assertEqual(
|
|
499
|
+
ctx.service_writeback.dashboard_links, {_TPS_SLUG: _DASHBOARD}
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
async def test_edge_create_unresolvable_repo_failed(self) -> None:
|
|
503
|
+
# No edge and no resolvable link -> cannot determine the repo.
|
|
411
504
|
plugin = GitHubDoctor()
|
|
412
505
|
result = await plugin.remediate(
|
|
413
|
-
_ctx(connections=[]), _CREDS, _REPAIR_EDGE
|
|
506
|
+
_ctx(connections=[], links={}), _CREDS, _REPAIR_EDGE
|
|
414
507
|
)
|
|
415
508
|
self.assertEqual(result.status, 'failed')
|
|
416
509
|
|
|
@@ -6,13 +6,28 @@ from unittest import mock
|
|
|
6
6
|
|
|
7
7
|
import httpx
|
|
8
8
|
import respx
|
|
9
|
+
from cryptography.hazmat.primitives import serialization
|
|
10
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
9
11
|
from imbi_common.plugins.base import PluginContext
|
|
10
12
|
|
|
11
|
-
from imbi_plugin_github import pull_requests
|
|
13
|
+
from imbi_plugin_github import _app_auth, pull_requests
|
|
12
14
|
from imbi_plugin_github.plugin import GitHubPlugin, GitHubWebhookActions
|
|
13
15
|
|
|
14
16
|
_CREDS = {'access_token': 'gho_test'}
|
|
15
17
|
_REPO = 'https://api.github.com/repos/octo/demo'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _gen_pem() -> str:
|
|
21
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
22
|
+
return key.private_bytes(
|
|
23
|
+
serialization.Encoding.PEM,
|
|
24
|
+
serialization.PrivateFormat.PKCS8,
|
|
25
|
+
serialization.NoEncryption(),
|
|
26
|
+
).decode()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Generated once for the module; signing only needs a valid RSA key.
|
|
30
|
+
_APP_KEY_PEM = _gen_pem()
|
|
16
31
|
# ``sync_all_history`` inserts through ``_insert_best_effort`` (defined in
|
|
17
32
|
# commits.py), so its ClickHouse call resolves in the commits namespace.
|
|
18
33
|
_INSERT_BACKFILL = 'imbi_plugin_github.commits.clickhouse.insert'
|
|
@@ -153,6 +168,86 @@ class SyncAllHistoryTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
153
168
|
ctx=_ctx(connection={}), credentials=_CREDS
|
|
154
169
|
)
|
|
155
170
|
|
|
171
|
+
@respx.mock
|
|
172
|
+
async def test_repo_gone_404_skips(self) -> None:
|
|
173
|
+
# IMBI-36: a renamed/removed repo returns 404 on the pulls list;
|
|
174
|
+
# the backfill must skip cleanly (return 0, log a warning) so the
|
|
175
|
+
# worker doesn't emit a Sentry error.
|
|
176
|
+
pulls = respx.get(f'{_REPO}/pulls').mock(
|
|
177
|
+
return_value=httpx.Response(404, json={'message': 'Not Found'})
|
|
178
|
+
)
|
|
179
|
+
handler = pull_requests.GitHubPullRequestSync()
|
|
180
|
+
with mock.patch(_INSERT_BACKFILL, new=mock.AsyncMock()) as insert:
|
|
181
|
+
with self.assertLogs(pull_requests.LOGGER, level='WARNING') as cm:
|
|
182
|
+
count = await handler.sync_all_history(
|
|
183
|
+
ctx=_ctx(), credentials=_CREDS
|
|
184
|
+
)
|
|
185
|
+
self.assertEqual(count, 0)
|
|
186
|
+
self.assertEqual(1, pulls.call_count)
|
|
187
|
+
insert.assert_not_awaited()
|
|
188
|
+
self.assertTrue(any('nothing to sync' in x for x in cm.output))
|
|
189
|
+
|
|
190
|
+
@respx.mock
|
|
191
|
+
async def test_later_page_404_keeps_partial(self) -> None:
|
|
192
|
+
# A 404 on a later page (not the first) stops pagination but keeps
|
|
193
|
+
# the PRs already collected; the count and warning must reflect the
|
|
194
|
+
# partial result rather than claiming "nothing to sync".
|
|
195
|
+
link = f'<{_REPO}/pulls?page=2>; rel="next"'
|
|
196
|
+
pulls = respx.get(f'{_REPO}/pulls').mock(
|
|
197
|
+
side_effect=[
|
|
198
|
+
httpx.Response(
|
|
199
|
+
200,
|
|
200
|
+
json=[_pr(id=1, number=1)],
|
|
201
|
+
headers={'link': link},
|
|
202
|
+
),
|
|
203
|
+
httpx.Response(404, json={'message': 'Not Found'}),
|
|
204
|
+
]
|
|
205
|
+
)
|
|
206
|
+
handler = pull_requests.GitHubPullRequestSync()
|
|
207
|
+
with mock.patch(_INSERT_BACKFILL, new=mock.AsyncMock()) as insert:
|
|
208
|
+
with self.assertLogs(pull_requests.LOGGER, level='WARNING') as cm:
|
|
209
|
+
count = await handler.sync_all_history(
|
|
210
|
+
ctx=_ctx(), credentials=_CREDS
|
|
211
|
+
)
|
|
212
|
+
self.assertEqual(count, 1)
|
|
213
|
+
self.assertEqual(2, pulls.call_count)
|
|
214
|
+
insert.assert_awaited_once()
|
|
215
|
+
self.assertTrue(any('stopping after 1 PRs' in x for x in cm.output))
|
|
216
|
+
|
|
217
|
+
@respx.mock
|
|
218
|
+
async def test_pulls_non_404_propagates(self) -> None:
|
|
219
|
+
# A non-404 failure on the pulls list is a real error and must
|
|
220
|
+
# still propagate rather than be swallowed as "repo gone".
|
|
221
|
+
respx.get(f'{_REPO}/pulls').mock(
|
|
222
|
+
return_value=httpx.Response(500, json={'message': 'boom'})
|
|
223
|
+
)
|
|
224
|
+
with mock.patch(_INSERT_BACKFILL, new=mock.AsyncMock()):
|
|
225
|
+
with self.assertRaises(httpx.HTTPStatusError):
|
|
226
|
+
await pull_requests.GitHubPullRequestSync().sync_all_history(
|
|
227
|
+
ctx=_ctx(), credentials=_CREDS
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
@respx.mock
|
|
231
|
+
async def test_app_not_installed_skips(self) -> None:
|
|
232
|
+
# IMBI-37: the shared GitHub App discovery 404s when the App is
|
|
233
|
+
# not installed for the repo; the backfill must skip cleanly.
|
|
234
|
+
_app_auth.reset_cache()
|
|
235
|
+
self.addCleanup(_app_auth.reset_cache)
|
|
236
|
+
discovery = respx.get(f'{_REPO}/installation').mock(
|
|
237
|
+
return_value=httpx.Response(404, json={'message': 'Not Found'})
|
|
238
|
+
)
|
|
239
|
+
creds = {'app_id': '971', 'private_key': _APP_KEY_PEM}
|
|
240
|
+
handler = pull_requests.GitHubPullRequestSync()
|
|
241
|
+
with mock.patch(_INSERT_BACKFILL, new=mock.AsyncMock()) as insert:
|
|
242
|
+
with self.assertLogs(pull_requests.LOGGER, level='WARNING') as cm:
|
|
243
|
+
count = await handler.sync_all_history(
|
|
244
|
+
ctx=_ctx(), credentials=creds
|
|
245
|
+
)
|
|
246
|
+
self.assertEqual(count, 0)
|
|
247
|
+
self.assertEqual(1, discovery.call_count)
|
|
248
|
+
insert.assert_not_awaited()
|
|
249
|
+
self.assertTrue(any('skipping backfill' in x for x in cm.output))
|
|
250
|
+
|
|
156
251
|
|
|
157
252
|
class CheckAvailableTestCase(unittest.IsolatedAsyncioTestCase):
|
|
158
253
|
async def test_available_with_host_and_repo(self) -> None:
|
|
@@ -667,7 +667,7 @@ wheels = [
|
|
|
667
667
|
|
|
668
668
|
[[package]]
|
|
669
669
|
name = "imbi-common"
|
|
670
|
-
version = "2.
|
|
670
|
+
version = "2.14.0"
|
|
671
671
|
source = { registry = "https://pypi.org/simple" }
|
|
672
672
|
dependencies = [
|
|
673
673
|
{ name = "cryptography" },
|
|
@@ -683,9 +683,9 @@ dependencies = [
|
|
|
683
683
|
{ name = "python-slugify" },
|
|
684
684
|
{ name = "typer" },
|
|
685
685
|
]
|
|
686
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
686
|
+
sdist = { url = "https://files.pythonhosted.org/packages/61/4c/c8837aef3cbbb5e886ce51085f0bd4c315fcc8b0325fc6060b6167e25296/imbi_common-2.14.0.tar.gz", hash = "sha256:b0a8d2168e72892a51df9054ae90b4f9e1ba668a490d673c7e9077996f8635f3", size = 361115, upload-time = "2026-07-15T21:52:14.795Z" }
|
|
687
687
|
wheels = [
|
|
688
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
688
|
+
{ url = "https://files.pythonhosted.org/packages/af/17/32614176cb1772f0c973d313d2d82f43d11e6892817c868e75b9b269323c/imbi_common-2.14.0-py3-none-any.whl", hash = "sha256:34e1b05e8e165aed8e7fc61fdd2ddbbaff55f733de58ce5de4174d7bb4f93647", size = 120818, upload-time = "2026-07-15T21:52:13.461Z" },
|
|
689
689
|
]
|
|
690
690
|
|
|
691
691
|
[package.optional-dependencies]
|
|
@@ -703,7 +703,7 @@ server = [
|
|
|
703
703
|
|
|
704
704
|
[[package]]
|
|
705
705
|
name = "imbi-plugin-github"
|
|
706
|
-
version = "2.
|
|
706
|
+
version = "2.14.0"
|
|
707
707
|
source = { editable = "." }
|
|
708
708
|
dependencies = [
|
|
709
709
|
{ name = "httpx" },
|
|
@@ -732,7 +732,7 @@ dist = [
|
|
|
732
732
|
[package.metadata]
|
|
733
733
|
requires-dist = [
|
|
734
734
|
{ name = "httpx", specifier = ">=0.27" },
|
|
735
|
-
{ name = "imbi-common", extras = ["databases"], specifier = "==2.
|
|
735
|
+
{ name = "imbi-common", extras = ["databases"], specifier = "==2.14.0" },
|
|
736
736
|
{ name = "pydantic", specifier = ">=2" },
|
|
737
737
|
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.8" },
|
|
738
738
|
]
|
|
@@ -741,7 +741,7 @@ requires-dist = [
|
|
|
741
741
|
dev = [
|
|
742
742
|
{ name = "basedpyright" },
|
|
743
743
|
{ name = "coverage", extras = ["toml"] },
|
|
744
|
-
{ name = "imbi-common", extras = ["server", "databases"], specifier = "==2.
|
|
744
|
+
{ name = "imbi-common", extras = ["server", "databases"], specifier = "==2.14.0" },
|
|
745
745
|
{ name = "pre-commit" },
|
|
746
746
|
{ name = "pytest" },
|
|
747
747
|
{ name = "pytest-asyncio" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|