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.
Files changed (31) hide show
  1. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/pyproject.toml +3 -3
  3. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/_app_auth.py +51 -1
  4. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/commits.py +17 -25
  5. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/deployment.py +104 -11
  6. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/doctor.py +97 -33
  7. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/pull_requests.py +30 -1
  8. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/tests/test_commits.py +37 -0
  9. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/tests/test_deployment.py +195 -4
  10. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/tests/test_doctor.py +95 -2
  11. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/tests/test_pull_requests.py +96 -1
  12. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/uv.lock +6 -6
  13. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/.github/workflows/publish.yml +0 -0
  14. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/.github/workflows/test.yml +0 -0
  15. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/.gitignore +0 -0
  16. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/.pre-commit-config.yaml +0 -0
  17. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/CLAUDE.md +0 -0
  18. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/LICENSE +0 -0
  19. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/README.md +0 -0
  20. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/justfile +0 -0
  21. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/__init__.py +0 -0
  22. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/_hosts.py +0 -0
  23. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/_repos.py +0 -0
  24. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/identity.py +0 -0
  25. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/lifecycle.py +0 -0
  26. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/src/imbi_plugin_github/plugin.py +0 -0
  27. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/tests/__init__.py +0 -0
  28. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/tests/test_hosts.py +0 -0
  29. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/tests/test_identity.py +0 -0
  30. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.0}/tests/test_lifecycle.py +0 -0
  31. {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.13.3
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.13.3
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.13.3"
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.13.3",
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.13.3",
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 ValueError(
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
- Prefers an explicit PAT (``access_token``/``token``). Otherwise
258
- mints a short-lived GitHub App installation token from ``app_id`` +
259
- ``private_key`` (with an optional ``installation_id`` that skips
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
- token = credentials.get('access_token') or credentials.get('token')
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). Propagates :class:`PluginRateLimited` when a
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
- token = await _resolve_bearer(credentials, base, owner, repo)
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
@@ -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
- @staticmethod
326
- def _token(credentials: dict[str, str]) -> str:
327
- token = credentials.get('access_token') or credentials.get('token')
328
- if not token:
329
- raise ValueError(
330
- 'GitHub deployment plugin requires an OAuth access token; '
331
- 'expected ``credentials["access_token"]``'
332
- )
333
- return token
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._token(credentials)),
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 (httpx.HTTPError, PluginAuthenticationFailed):
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
- 'Run the lifecycle plugin to create the repository '
193
- 'link and re-index this project.',
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
- token = credentials.get('access_token') or credentials.get('token')
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 access token configured; the repository may be '
260
- 'private. Configure an access_token credential to '
261
- 'inspect private repositories.'
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 (ValueError, TypeError):
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
- """Repair the EXISTS_IN edge or github-repository link from GitHub.
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. Idempotent: returns ``noop`` when Imbi already
520
- matches GitHub. A 401 propagates as ``PluginAuthenticationFailed``
521
- so the host can refresh the actor's identity and retry once.
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
- connection = (
533
- _find_connection(ctx.service_connections, slug) if slug else None
534
- )
535
- if slug is None or connection is None:
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.get('access_token') or credentials.get('token')
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 + dashboard
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
- already_correct = (
620
- connection.identifier == str(api_id)
621
- and connection.canonical_url == desired_canonical
622
- and ctx.project_links.get(slug) == html_url
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'Repaired the EXISTS_IN edge for {slug!r} '
701
+ f'{verb} the EXISTS_IN edge for {slug!r} '
638
702
  f'(identifier={api_id}).'
639
703
  ),
640
704
  )
@@ -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
- token = await _resolve_bearer(credentials, base, owner, repo)
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 test_token_required(self) -> None:
260
+ def test_bearer_requires_credentials(self) -> None:
260
261
  with self.assertRaises(ValueError):
261
- GitHubDeployment._token({})
262
+ asyncio.run(GitHubDeployment()._bearer(_ctx(), {}))
262
263
 
263
- def test_token_accepts_token_alias(self) -> None:
264
- self.assertEqual(GitHubDeployment._token({'token': 'abc'}), 'abc')
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 test_no_connection_failed(self) -> None:
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.13.3"
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/9c/c0/b763c967428c8a804046a62d52bba1e61121193fff526484906383bb9bce/imbi_common-2.13.3.tar.gz", hash = "sha256:76de3f2a1d2191275e3fa4ef2370484b61bd4ad5dff50eb996dd63539f72010e", size = 360800, upload-time = "2026-07-15T14:55:53.31Z" }
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/35/07/5c9274570493763e570f0e10a5dee627f958cc60b60fbf2df081c6a97795/imbi_common-2.13.3-py3-none-any.whl", hash = "sha256:d073a53080e6b9f1c8d895b23b30f4cd03da8e8f359cccc77b6230c639d072c7", size = 120605, upload-time = "2026-07-15T14:55:51.732Z" },
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.13.3"
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.13.3" },
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.13.3" },
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" },