imbi-plugin-github 2.13.3__tar.gz → 2.14.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/pyproject.toml +3 -3
  3. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/_app_auth.py +51 -1
  4. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/commits.py +17 -25
  5. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/deployment.py +123 -11
  6. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/doctor.py +97 -33
  7. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/pull_requests.py +30 -1
  8. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/test_commits.py +37 -0
  9. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/test_deployment.py +227 -4
  10. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/test_doctor.py +95 -2
  11. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/test_pull_requests.py +96 -1
  12. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/uv.lock +6 -6
  13. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/.github/workflows/publish.yml +0 -0
  14. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/.github/workflows/test.yml +0 -0
  15. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/.gitignore +0 -0
  16. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/.pre-commit-config.yaml +0 -0
  17. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/CLAUDE.md +0 -0
  18. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/LICENSE +0 -0
  19. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/README.md +0 -0
  20. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/justfile +0 -0
  21. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/__init__.py +0 -0
  22. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/_hosts.py +0 -0
  23. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/_repos.py +0 -0
  24. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/identity.py +0 -0
  25. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/lifecycle.py +0 -0
  26. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/src/imbi_plugin_github/plugin.py +0 -0
  27. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/__init__.py +0 -0
  28. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/test_hosts.py +0 -0
  29. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/test_identity.py +0 -0
  30. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/test_lifecycle.py +0 -0
  31. {imbi_plugin_github-2.13.3 → imbi_plugin_github-2.14.1}/tests/test_repos.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: imbi-plugin-github
3
- Version: 2.13.3
3
+ Version: 2.14.1
4
4
  Summary: GitHub identity plugin for Imbi (github.com / GHEC / GHES)
5
5
  Author-email: "Gavin M. Roy" <gavinr@aweber.com>
6
6
  License: BSD-3-Clause
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Programming Language :: Python :: 3.14
14
14
  Requires-Python: >=3.14
15
15
  Requires-Dist: httpx>=0.27
16
- Requires-Dist: imbi-common[databases]==2.13.3
16
+ Requires-Dist: imbi-common[databases]==2.14.1
17
17
  Requires-Dist: pydantic>=2
18
18
  Requires-Dist: pyjwt[crypto]>=2.8
19
19
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "imbi-plugin-github"
3
- version = "2.13.3"
3
+ version = "2.14.1"
4
4
  description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -16,7 +16,7 @@ classifiers = [
16
16
  ]
17
17
  dependencies = [
18
18
  "httpx>=0.27",
19
- "imbi-common[databases]==2.13.3",
19
+ "imbi-common[databases]==2.14.1",
20
20
  "pydantic>=2",
21
21
  "pyjwt[crypto]>=2.8",
22
22
  ]
@@ -25,7 +25,7 @@ dependencies = [
25
25
  dev = [
26
26
  "basedpyright",
27
27
  "coverage[toml]",
28
- "imbi-common[server,databases]==2.13.3",
28
+ "imbi-common[server,databases]==2.14.1",
29
29
  "pre-commit",
30
30
  "pytest",
31
31
  "pytest-asyncio",
@@ -33,6 +33,18 @@ from imbi_plugin_github.deployment import (
33
33
 
34
34
  LOGGER = logging.getLogger(__name__)
35
35
 
36
+
37
+ class AppNotInstalledError(Exception):
38
+ """The GitHub App is not installed for the target repository.
39
+
40
+ Raised by :func:`_discover_installation_id` when GitHub answers the
41
+ installation lookup with a 404 -- the App has not been installed on
42
+ the repo/org, or the repo was renamed/removed. Sync callers treat
43
+ this as a clean skip rather than a hard failure, so an uninstalled
44
+ App never surfaces as a Sentry error on a backfill worker.
45
+ """
46
+
47
+
36
48
  _HTTP_TIMEOUT_SECONDS = 10.0
37
49
  # GitHub rejects an App JWT whose ``exp`` is more than 10 minutes out;
38
50
  # sign for 9 to leave room for clock skew between us and GitHub.
@@ -114,11 +126,15 @@ async def _discover_installation_id(
114
126
  client: httpx.AsyncClient, owner: str, repo: str
115
127
  ) -> str:
116
128
  resp = await client.get(f'/repos/{owner}/{repo}/installation')
129
+ if resp.status_code == 404:
130
+ raise AppNotInstalledError(
131
+ f'no GitHub App installation found for {owner}/{repo}'
132
+ )
117
133
  resp.raise_for_status()
118
134
  data = typing.cast('dict[str, typing.Any]', resp.json())
119
135
  install_id = data.get('id')
120
136
  if install_id is None:
121
- raise 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.
@@ -958,6 +1011,25 @@ class GitHubDeployment(DeploymentCapability):
958
1011
  )
959
1012
  return [observed for group in per_env for observed in group]
960
1013
 
1014
+ async def get_release_notes(
1015
+ self,
1016
+ ctx: PluginContext,
1017
+ credentials: dict[str, str],
1018
+ tag: str,
1019
+ ) -> str | None:
1020
+ """Return the GitHub release body for ``tag``.
1021
+
1022
+ Tag-keyed enrichment path: the host calls this when it knows the
1023
+ release tag but not its notes -- a webhook that created the
1024
+ ``Release`` node from a deployment event (whose payload carries no
1025
+ body), or a resync whose deployment ``ref`` was a raw SHA. Opens
1026
+ a repo-scoped client and defers to :meth:`_release_notes_for_ref`,
1027
+ which 404s to ``None`` for tags without a release and caches 403s
1028
+ so a scope-limited token short-circuits.
1029
+ """
1030
+ async with self._client(ctx, credentials) as client:
1031
+ return await self._release_notes_for_ref(client, tag)
1032
+
961
1033
  async def _list_deployments_for_env(
962
1034
  self,
963
1035
  client: httpx.AsyncClient,
@@ -1022,6 +1094,11 @@ class GitHubDeployment(DeploymentCapability):
1022
1094
  )
1023
1095
  ref_value = deployment.get('ref')
1024
1096
  description = deployment.get('description')
1097
+ release_notes = (
1098
+ await self._release_notes_for_ref(client, str(ref_value))
1099
+ if ref_value
1100
+ else None
1101
+ )
1025
1102
  deployment_url = deployment.get('url') or deployment.get('html_url')
1026
1103
  creator_login: str | None = None
1027
1104
  creator_subject: str | None = None
@@ -1046,10 +1123,45 @@ class GitHubDeployment(DeploymentCapability):
1046
1123
  run_url=status_url,
1047
1124
  deployment_url=str(deployment_url) if deployment_url else None,
1048
1125
  description=str(description) if description else None,
1126
+ release_notes=release_notes,
1049
1127
  creator=creator_login,
1050
1128
  creator_subject=creator_subject,
1051
1129
  )
1052
1130
 
1131
+ async def _release_notes_for_ref(
1132
+ self, client: httpx.AsyncClient, ref: str
1133
+ ) -> str | None:
1134
+ """Return the GitHub release notes body for a deployed ref.
1135
+
1136
+ A deployment created against a semver tag has a matching GitHub
1137
+ release whose ``body`` is the "What's Changed" markdown the host
1138
+ persists on the ``Release`` node. Refs that aren't a release tag
1139
+ (branches, raw SHAs) 404 here and yield ``None`` -- resync is
1140
+ never blocked by a missing or unreadable release. A ``403``
1141
+ (token lacks scope to read releases) is cached process-wide so a
1142
+ forbidden token short-circuits instead of re-issuing the request
1143
+ for every deployment on every resync sweep.
1144
+ """
1145
+ if _releases_forbidden(client):
1146
+ return None
1147
+ try:
1148
+ resp = await client.get(
1149
+ f'/releases/tags/{urllib.parse.quote(ref, safe="")}'
1150
+ )
1151
+ except httpx.HTTPError:
1152
+ return None
1153
+ if resp.status_code == 403:
1154
+ _record_releases_forbidden(client)
1155
+ return None
1156
+ if resp.status_code != 200:
1157
+ return None
1158
+ try:
1159
+ data = typing.cast(dict[str, typing.Any], resp.json())
1160
+ except ValueError:
1161
+ return None
1162
+ body = data.get('body')
1163
+ return str(body) if body else None
1164
+
1053
1165
  async def _latest_status(
1054
1166
  self, client: httpx.AsyncClient, deployment_id: str
1055
1167
  ) -> tuple[DeploymentEventStatus, str | None]:
@@ -15,6 +15,7 @@ its own.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import logging
18
19
  import re
19
20
  import typing
20
21
 
@@ -37,6 +38,11 @@ from imbi_plugin_github._hosts import (
37
38
  host_to_api_base,
38
39
  )
39
40
  from imbi_plugin_github._repos import derive_owner_repo_from_links
41
+ from imbi_plugin_github.commits import (
42
+ _resolve_bearer, # pyright: ignore[reportPrivateUsage]
43
+ )
44
+
45
+ LOGGER = logging.getLogger(__name__)
40
46
 
41
47
  _TIMEOUT = 15.0
42
48
  # Matches the canonical URL shape written by the lifecycle plugin:
@@ -100,7 +106,7 @@ def _item(
100
106
 
101
107
 
102
108
  def _repo_fetch_url(
103
- connection: ServiceConnection,
109
+ connection: ServiceConnection | None,
104
110
  ctx: PluginContext,
105
111
  host: str,
106
112
  api_base: str,
@@ -109,7 +115,9 @@ def _repo_fetch_url(
109
115
 
110
116
  Prefer the rename-stable canonical URL on the ``EXISTS_IN`` edge;
111
117
  otherwise derive ``(owner, repo)`` from the project links. Returns
112
- ``None`` when neither is available.
118
+ ``None`` when neither is available. ``connection`` is ``None`` when
119
+ no ``EXISTS_IN`` edge exists yet (the create-edge remediation), in
120
+ which case the URL is derived from the project links alone.
113
121
 
114
122
  The stored canonical URL is never fetched verbatim: only the numeric
115
123
  repository id is trusted from it, and the request URL is rebuilt
@@ -117,7 +125,7 @@ def _repo_fetch_url(
117
125
  truth). This keeps the Bearer token from ever being sent to an
118
126
  arbitrary host smuggled onto the ``EXISTS_IN`` edge.
119
127
  """
120
- if connection.canonical_url:
128
+ if connection is not None and connection.canonical_url:
121
129
  match = _REPO_ID_RE.fullmatch(connection.canonical_url)
122
130
  if match is not None:
123
131
  return f'{api_base}/repositories/{match.group(1)}'
@@ -147,6 +155,38 @@ def _find_connection(
147
155
  )
148
156
 
149
157
 
158
+ async def _resolve_doctor_bearer(
159
+ credentials: dict[str, str],
160
+ api_base: str,
161
+ ctx: PluginContext,
162
+ host: str,
163
+ ) -> str | None:
164
+ """Resolve a Bearer token for analysis / remediation, or ``None``.
165
+
166
+ Accepts the same credential shapes as the sync capabilities via
167
+ :func:`_resolve_bearer` — a PAT (``access_token`` / ``token``) or
168
+ GitHub App creds (``app_id`` + ``private_key``, minting a short-lived
169
+ installation token). App-token minting needs ``(owner, repo)`` for
170
+ installation discovery, derived from the project links (an explicit
171
+ ``installation_id`` credential skips that). Never raises: the doctor
172
+ must not hard-fail on a missing or unusable credential, so it degrades
173
+ to an unauthenticated request, which the caller reports softly.
174
+ """
175
+ derived = derive_owner_repo_from_links(
176
+ ctx.project_links, host, preferred_key=ctx.integration_slug
177
+ )
178
+ owner, repo = derived or ('', '')
179
+ try:
180
+ return await _resolve_bearer(credentials, api_base, owner, repo)
181
+ except Exception: # noqa: BLE001 -- analysis is best-effort
182
+ LOGGER.warning(
183
+ 'github-doctor: could not resolve a GitHub credential; '
184
+ 'proceeding unauthenticated',
185
+ exc_info=True,
186
+ )
187
+ return None
188
+
189
+
150
190
  class GitHubDoctor(AnalysisCapability):
151
191
  """GitHub project-doctor analysis capability.
152
192
 
@@ -189,8 +229,13 @@ class GitHubDoctor(AnalysisCapability):
189
229
  'EXISTS_IN edge',
190
230
  'warn',
191
231
  f'No EXISTS_IN edge found for service {slug!r}. '
192
- '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