imbi-plugin-github 2.24.0__tar.gz → 2.26.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 (27) hide show
  1. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/PKG-INFO +3 -3
  2. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/pyproject.toml +2 -2
  3. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/deployment.py +383 -18
  4. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/plugin.py +16 -0
  5. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/tests/test_deployment.py +719 -0
  6. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/.gitignore +0 -0
  7. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/README.md +0 -0
  8. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/moon.yml +0 -0
  9. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/README.md +0 -0
  10. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/__init__.py +0 -0
  11. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/_app_auth.py +0 -0
  12. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/_hosts.py +0 -0
  13. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/_repos.py +0 -0
  14. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/commits.py +0 -0
  15. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/doctor.py +0 -0
  16. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/identity.py +0 -0
  17. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/lifecycle.py +0 -0
  18. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/pull_requests.py +0 -0
  19. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/py.typed +0 -0
  20. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/tests/__init__.py +0 -0
  21. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/tests/test_commits.py +0 -0
  22. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/tests/test_doctor.py +0 -0
  23. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/tests/test_hosts.py +0 -0
  24. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/tests/test_identity.py +0 -0
  25. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/tests/test_lifecycle.py +0 -0
  26. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/tests/test_pull_requests.py +0 -0
  27. {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/tests/test_repos.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: imbi-plugin-github
3
- Version: 2.24.0
3
+ Version: 2.26.0
4
4
  Summary: GitHub identity plugin for Imbi (github.com / GHEC / GHES)
5
5
  Author-email: "Gavin M. Roy" <gavinr@aweber.com>
6
6
  License-Expression: BSD-3-Clause
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.14
13
13
  Requires-Python: >=3.14
14
14
  Requires-Dist: httpx>=0.27
15
- Requires-Dist: imbi-common[databases]==2.24.0
15
+ Requires-Dist: imbi-common[databases]==2.26.0
16
16
  Requires-Dist: pydantic>=2
17
17
  Requires-Dist: pyjwt[crypto]>=2.8
18
18
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "imbi-plugin-github"
3
- version = "2.24.0"
3
+ version = "2.26.0"
4
4
  description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -18,7 +18,7 @@ classifiers = [
18
18
  ]
19
19
  dependencies = [
20
20
  "httpx>=0.27",
21
- "imbi-common[databases]==2.24.0",
21
+ "imbi-common[databases]==2.26.0",
22
22
  "pydantic>=2",
23
23
  "pyjwt[crypto]>=2.8",
24
24
  ]
@@ -2,7 +2,10 @@
2
2
 
3
3
  :class:`GitHubDeployment` resolves its host from the Integration's
4
4
  ``flavor`` + ``host`` options on ``ctx.integration_options`` (github.com,
5
- a ``*.ghe.com`` GHEC tenant, or an operator-managed GHES appliance).
5
+ a ``*.ghe.com`` GHEC tenant, or an operator-managed GHES appliance), and
6
+ reads ``mainline_branches`` from the same map to know which deployment
7
+ refs are branches rather than release tags (see
8
+ :func:`_mainline_branches`).
6
9
 
7
10
  *Deploying* drives the GitHub Deployments API
8
11
  (``POST /repos/{owner}/{repo}/deployments``) rather than
@@ -29,6 +32,7 @@ access token through the Integration credential blob's
29
32
  from __future__ import annotations
30
33
 
31
34
  import asyncio
35
+ import base64
32
36
  import collections.abc
33
37
  import contextlib
34
38
  import datetime
@@ -136,6 +140,31 @@ def _auth_headers(token: str) -> dict[str, str]:
136
140
  }
137
141
 
138
142
 
143
+ #: A push event's ``before`` when the ref did not exist until now.
144
+ _ZERO_SHA = '0' * 40
145
+ # GitHub's compare endpoint lists at most 300 changed files and offers
146
+ # no pagination for them -- a list this long may be incomplete.
147
+ _COMPARE_FILES_CAP = 300
148
+ # How many note-blob reads run at once when listing a whole notes tree.
149
+ _NOTE_BLOB_CONCURRENCY = 10
150
+
151
+ _FULL_SHA_PATTERN = re.compile(r'^[0-9a-f]{40}$')
152
+
153
+
154
+ def _note_sha(path: object) -> str | None:
155
+ """Annotated commit SHA for a notes-tree path, or ``None``.
156
+
157
+ Notes trees key blobs by the annotated commit's full SHA, optionally
158
+ fanned out into subtrees (``ab/cdef...``); flattening the path
159
+ recovers the SHA. Non-note files (``README`` and friends can live
160
+ on a notes ref) do not flatten to 40 hex chars and are skipped.
161
+ """
162
+ if not isinstance(path, str):
163
+ return None
164
+ flattened = path.replace('/', '').lower()
165
+ return flattened if _FULL_SHA_PATTERN.match(flattened) else None
166
+
167
+
139
168
  def _short_sha(sha: str) -> str:
140
169
  return sha[:7]
141
170
 
@@ -371,17 +400,52 @@ def _record_releases_forbidden(client: httpx.AsyncClient) -> None:
371
400
  _RELEASES_FORBIDDEN_TOKENS[_releases_cache_key(client)] = now
372
401
 
373
402
 
403
+ # Fallback for the ``mainline_branches`` integration option: deployment
404
+ # refs that are branch names rather than release tags. A repo that deploys
405
+ # off its default branch reports ``ref == 'main'`` on every deployment, so
406
+ # ``GET /releases/tags/main`` is a guaranteed 404 — skip the request
407
+ # outright instead of paying for it once per deployment row.
408
+ _DEFAULT_MAINLINE_BRANCHES = frozenset({'main', 'master'})
409
+
410
+
411
+ def _mainline_branches(
412
+ integration_options: dict[str, typing.Any],
413
+ ) -> frozenset[str]:
414
+ """Resolve the ``mainline_branches`` integration option to a set.
415
+
416
+ Declared integration-level (beside ``flavor`` / ``host``) because
417
+ mainline branch naming is a property of the org's repos rather than of
418
+ any one capability, so every capability can read the same value. The
419
+ manifest's ``default`` is a form pre-fill only -- the host does not
420
+ substitute it -- so an absent or blank value resolves to
421
+ :data:`_DEFAULT_MAINLINE_BRANCHES` here, mirroring how
422
+ ``artifact_version_input`` re-applies its own default. Consequently
423
+ the guard can be *retargeted* but not switched off; a repo that cuts
424
+ releases tagged with a branch name is not a case worth supporting.
425
+
426
+ Operator-entered, so tolerate commas as well as the advertised spaces
427
+ and discard surrounding whitespace -- a stray separator would
428
+ otherwise register as a branch named ``''``.
429
+ """
430
+ raw = integration_options.get('mainline_branches')
431
+ if not isinstance(raw, str):
432
+ return _DEFAULT_MAINLINE_BRANCHES
433
+ configured = frozenset(raw.replace(',', ' ').split())
434
+ return configured or _DEFAULT_MAINLINE_BRANCHES
435
+
436
+
374
437
  def _commit_from_payload(payload: dict[str, typing.Any]) -> Commit:
375
438
  """Convert a GitHub commit list/object payload into a :class:`Commit`."""
376
439
  sha = str(payload.get('sha', ''))
377
440
  commit_meta: dict[str, typing.Any] = payload.get('commit') or {}
378
441
  author_meta: dict[str, typing.Any] = commit_meta.get('author') or {}
379
442
  raw_message = str(commit_meta.get('message') or '')
380
- message_lines = raw_message.splitlines()
443
+ subject, _, body = raw_message.partition('\n')
381
444
  return Commit(
382
445
  sha=sha,
383
446
  short_sha=_short_sha(sha),
384
- message=message_lines[0] if message_lines else '',
447
+ message=subject,
448
+ body=body.strip() or None,
385
449
  author=author_meta.get('name'),
386
450
  authored_at=_parse_iso(author_meta.get('date')),
387
451
  url=payload.get('html_url'),
@@ -811,6 +875,238 @@ class GitHubDeployment(DeploymentCapability):
811
875
  deletions=deletions,
812
876
  )
813
877
 
878
+ # -- Git notes ----------------------------------------------------------
879
+
880
+ async def get_commit_note(
881
+ self,
882
+ ctx: PluginContext,
883
+ credentials: dict[str, str],
884
+ namespace: str,
885
+ committish: str,
886
+ ) -> str | None:
887
+ """Read the note on ``committish`` from ``refs/notes/<namespace>``.
888
+
889
+ The notes tree keys blobs by the *full* SHA of the annotated
890
+ commit (with optional fan-out subtrees like ``ab/cdef...``), so a
891
+ short committish is resolved through the commits endpoint first.
892
+ """
893
+ async with self._client(ctx, credentials) as client:
894
+ full_sha = committish.lower()
895
+ if not _FULL_SHA_PATTERN.match(full_sha):
896
+ commit = await client.get(
897
+ f'/commits/{urllib.parse.quote(committish, safe="")}'
898
+ )
899
+ commit.raise_for_status()
900
+ full_sha = str(commit.json()['sha']).lower()
901
+ notes = await self._notes_tree(client, namespace)
902
+ if notes is None:
903
+ return None
904
+ blob_sha = notes.get(full_sha)
905
+ if blob_sha is None:
906
+ return None
907
+ return await self._blob_text(client, blob_sha)
908
+
909
+ async def diff_commit_notes(
910
+ self,
911
+ ctx: PluginContext,
912
+ credentials: dict[str, str],
913
+ namespace: str,
914
+ before: str,
915
+ after: str,
916
+ ) -> dict[str, str | None]:
917
+ """Diff ``refs/notes/<namespace>`` between two of its commits.
918
+
919
+ ``before``/``after`` come from a push event on the notes ref;
920
+ the *files* changed between them are the notes of the annotated
921
+ commits, so the tree diff -- not the push's ``commits`` list --
922
+ is what names the commits whose notes changed. An all-zero
923
+ ``before`` (the ref was just created) returns every note at
924
+ ``after``.
925
+
926
+ Known limitation: GitHub's compare endpoint is three-dot only
927
+ (``after`` against the merge base), so a *force-pushed* notes
928
+ ref whose ``before`` is not an ancestor of ``after`` can miss a
929
+ note that the rewrite flipped or removed. The 404 fallback
930
+ below covers a ``before`` GitHub no longer has at all, and the
931
+ sweep backfill only repairs verdicts that are still ``null`` --
932
+ a stale non-null verdict from this window persists until the
933
+ next ordinary push touches the note.
934
+ """
935
+ async with self._client(ctx, credentials) as client:
936
+ if before == _ZERO_SHA:
937
+ return await self._all_notes(client, after)
938
+ quoted = urllib.parse.quote(f'{before}...{after}', safe='.')
939
+ resp = await client.get(f'/compare/{quoted}')
940
+ if resp.status_code == 404:
941
+ # ``before`` was garbage-collected or the ref history
942
+ # was rewritten; fall back to the full tree at ``after``
943
+ # so the push still lands rather than being dropped.
944
+ return await self._all_notes(client, after)
945
+ resp.raise_for_status()
946
+ payload = typing.cast('dict[str, typing.Any]', resp.json())
947
+ files: list[dict[str, typing.Any]] = payload.get('files') or []
948
+ if len(files) >= _COMPARE_FILES_CAP:
949
+ # The compare endpoint stops listing files at 300 and
950
+ # offers no pagination for them, so a full list this
951
+ # long may be missing entries. Fall back to the whole
952
+ # tree at ``after`` -- same trade-off as the 404 path:
953
+ # removals in the window are missed until the sweep or
954
+ # the next push touches them.
955
+ LOGGER.warning(
956
+ 'Notes compare %s...%s hit the %d-file cap; '
957
+ 'reading the full tree instead',
958
+ before,
959
+ after,
960
+ _COMPARE_FILES_CAP,
961
+ )
962
+ return await self._all_notes(client, after)
963
+ out: dict[str, str | None] = {}
964
+ for item in files:
965
+ status = str(item.get('status') or '')
966
+ previous = _note_sha(item.get('previous_filename'))
967
+ if status == 'renamed' and previous is not None:
968
+ # Fan-out reshuffle: the note moved paths. The old
969
+ # path's flattened SHA only differs from the new one
970
+ # if the note now annotates a different commit.
971
+ out.setdefault(previous, None)
972
+ annotated = _note_sha(item.get('filename'))
973
+ if annotated is None:
974
+ continue
975
+ if status == 'removed':
976
+ out[annotated] = None
977
+ continue
978
+ blob_sha = item.get('sha')
979
+ if not blob_sha:
980
+ continue
981
+ try:
982
+ body = await self._blob_text(client, str(blob_sha))
983
+ except httpx.HTTPError:
984
+ LOGGER.warning(
985
+ 'Could not read note blob %s for %s; skipping',
986
+ blob_sha,
987
+ annotated,
988
+ )
989
+ body = None
990
+ if body is None:
991
+ # Skip rather than record ``None``: in the diff a
992
+ # ``None`` means "note removed" and would resolve a
993
+ # drift blocker over a note we merely could not
994
+ # read (``_blob_text`` already logged why).
995
+ out.pop(annotated, None)
996
+ continue
997
+ out[annotated] = body
998
+ return out
999
+
1000
+ async def _notes_tree(
1001
+ self, client: httpx.AsyncClient, namespace: str
1002
+ ) -> dict[str, str] | None:
1003
+ """Map annotated full SHA -> note blob SHA, or ``None`` sans ref."""
1004
+ ref = await client.get(
1005
+ f'/git/ref/{urllib.parse.quote(f"notes/{namespace}", safe="/")}'
1006
+ )
1007
+ if ref.status_code == 404:
1008
+ return None
1009
+ ref.raise_for_status()
1010
+ tip = str(ref.json()['object']['sha'])
1011
+ return await self._tree_notes(client, tip)
1012
+
1013
+ async def _tree_notes(
1014
+ self, client: httpx.AsyncClient, commit_sha: str
1015
+ ) -> dict[str, str]:
1016
+ """Flatten one notes-ref commit's tree to annotated SHA -> blob."""
1017
+ commit = await client.get(f'/git/commits/{commit_sha}')
1018
+ commit.raise_for_status()
1019
+ tree_sha = str(commit.json()['tree']['sha'])
1020
+ tree = await client.get(f'/git/trees/{tree_sha}?recursive=1')
1021
+ tree.raise_for_status()
1022
+ payload = typing.cast('dict[str, typing.Any]', tree.json())
1023
+ if payload.get('truncated'):
1024
+ LOGGER.warning(
1025
+ 'Notes tree %s is truncated; some notes will be missed',
1026
+ tree_sha,
1027
+ )
1028
+ entries: list[dict[str, typing.Any]] = payload.get('tree') or []
1029
+ out: dict[str, str] = {}
1030
+ for entry in entries:
1031
+ if entry.get('type') != 'blob':
1032
+ continue
1033
+ annotated = _note_sha(entry.get('path'))
1034
+ if annotated is not None:
1035
+ out[annotated] = str(entry['sha'])
1036
+ return out
1037
+
1038
+ async def _all_notes(
1039
+ self, client: httpx.AsyncClient, commit_sha: str
1040
+ ) -> dict[str, str | None]:
1041
+ """Every note at one notes-ref commit, bodies included.
1042
+
1043
+ Blob reads run a few at a time (one request per note) and an
1044
+ unreadable note is skipped rather than failing the batch or
1045
+ recording a false "removed".
1046
+ """
1047
+ notes = await self._tree_notes(client, commit_sha)
1048
+ gate = asyncio.Semaphore(_NOTE_BLOB_CONCURRENCY)
1049
+
1050
+ async def _read(blob_sha: str) -> str | BaseException | None:
1051
+ async with gate:
1052
+ try:
1053
+ return await self._blob_text(client, blob_sha)
1054
+ except httpx.HTTPError as exc:
1055
+ return exc
1056
+
1057
+ items = list(notes.items())
1058
+ bodies = await asyncio.gather(
1059
+ *(_read(blob_sha) for _, blob_sha in items)
1060
+ )
1061
+ out: dict[str, str | None] = {}
1062
+ for (annotated, blob_sha), body in zip(items, bodies, strict=True):
1063
+ if isinstance(body, BaseException):
1064
+ LOGGER.warning(
1065
+ 'Could not read note blob %s for %s; skipping',
1066
+ blob_sha,
1067
+ annotated,
1068
+ )
1069
+ continue
1070
+ if body is None:
1071
+ # ``_blob_text`` could not decode it and logged why;
1072
+ # a ``None`` here would read as "note removed".
1073
+ continue
1074
+ out[annotated] = body
1075
+ return out
1076
+
1077
+ @staticmethod
1078
+ async def _blob_text(
1079
+ client: httpx.AsyncClient, blob_sha: str
1080
+ ) -> str | None:
1081
+ """Fetch and decode one note blob; ``None`` when undecodable."""
1082
+ resp = await client.get(f'/git/blobs/{blob_sha}')
1083
+ resp.raise_for_status()
1084
+ payload = typing.cast('dict[str, typing.Any]', resp.json())
1085
+ content = str(payload.get('content') or '')
1086
+ if payload.get('encoding') != 'base64':
1087
+ if not content:
1088
+ # ``encoding: none`` with an empty body is how GitHub
1089
+ # answers for blobs above the inline size limit --
1090
+ # "cannot read", not "empty note".
1091
+ LOGGER.warning(
1092
+ 'Note blob %s answered encoding %r with no content',
1093
+ blob_sha,
1094
+ payload.get('encoding'),
1095
+ )
1096
+ return None
1097
+ return content
1098
+ try:
1099
+ # Strip GitHub's line wrapping, then decode strictly --
1100
+ # the default decoder silently discards invalid characters,
1101
+ # turning garbage like '%%%%' into an empty body instead of
1102
+ # landing on this "cannot read" path.
1103
+ return base64.b64decode(
1104
+ ''.join(content.split()), validate=True
1105
+ ).decode('utf-8')
1106
+ except (ValueError, UnicodeDecodeError):
1107
+ LOGGER.warning('Could not decode note blob %s', blob_sha)
1108
+ return None
1109
+
814
1110
  # -- Tags / Releases ----------------------------------------------------
815
1111
 
816
1112
  async def create_tag(
@@ -1221,11 +1517,25 @@ class GitHubDeployment(DeploymentCapability):
1221
1517
  # the triggering-actor lookup by run id so we resolve each run
1222
1518
  # at most once. Shared across the parallel per-env fan-out.
1223
1519
  run_cache: dict[str, tuple[str, str] | None] = {}
1520
+ # Likewise, deployments in a sweep share a handful of refs; the
1521
+ # lookup for each is memoised here as a task so it happens once
1522
+ # per ref instead of once per deployment. Holding the task (not
1523
+ # its result) also coalesces the per-env fan-out below: envs that
1524
+ # deployed the same ref await one in-flight request rather than
1525
+ # each issuing their own, which is the common shape at the host's
1526
+ # default limit=1 where no single env repeats a ref.
1527
+ release_lookups: dict[str, asyncio.Task[RemoteRelease | None]] = {}
1528
+ mainline = _mainline_branches(ctx.integration_options)
1224
1529
  async with self._client(ctx, credentials) as client:
1225
1530
  per_env = await asyncio.gather(
1226
1531
  *(
1227
1532
  self._list_deployments_for_env(
1228
- client, env, page_size, run_cache
1533
+ client,
1534
+ env,
1535
+ page_size,
1536
+ run_cache,
1537
+ release_lookups,
1538
+ mainline,
1229
1539
  )
1230
1540
  for env in environments
1231
1541
  )
@@ -1248,8 +1558,9 @@ class GitHubDeployment(DeploymentCapability):
1248
1558
  which 404s to ``None`` for tags without a release and caches 403s
1249
1559
  so a scope-limited token short-circuits.
1250
1560
  """
1561
+ mainline = _mainline_branches(ctx.integration_options)
1251
1562
  async with self._client(ctx, credentials) as client:
1252
- return await self._release_notes_for_ref(client, tag)
1563
+ return await self._release_notes_for_ref(client, tag, mainline)
1253
1564
 
1254
1565
  async def _list_deployments_for_env(
1255
1566
  self,
@@ -1257,6 +1568,8 @@ class GitHubDeployment(DeploymentCapability):
1257
1568
  environment: str,
1258
1569
  page_size: int,
1259
1570
  run_cache: dict[str, tuple[str, str] | None],
1571
+ release_lookups: dict[str, asyncio.Task[RemoteRelease | None]],
1572
+ mainline: frozenset[str],
1260
1573
  ) -> list[RemoteDeployment]:
1261
1574
  try:
1262
1575
  resp = await client.get(
@@ -1289,7 +1602,12 @@ class GitHubDeployment(DeploymentCapability):
1289
1602
  observed: list[RemoteDeployment] = []
1290
1603
  for deployment in deployments:
1291
1604
  run = await self._observe_deployment(
1292
- client, environment, deployment, run_cache
1605
+ client,
1606
+ environment,
1607
+ deployment,
1608
+ run_cache,
1609
+ release_lookups,
1610
+ mainline,
1293
1611
  )
1294
1612
  if run is not None:
1295
1613
  observed.append(run)
@@ -1301,6 +1619,8 @@ class GitHubDeployment(DeploymentCapability):
1301
1619
  environment: str,
1302
1620
  deployment: dict[str, typing.Any],
1303
1621
  run_cache: dict[str, tuple[str, str] | None],
1622
+ release_lookups: dict[str, asyncio.Task[RemoteRelease | None]],
1623
+ mainline: frozenset[str],
1304
1624
  ) -> RemoteDeployment | None:
1305
1625
  deployment_id = deployment.get('id')
1306
1626
  sha = deployment.get('sha')
@@ -1318,7 +1638,9 @@ class GitHubDeployment(DeploymentCapability):
1318
1638
  ref_value = deployment.get('ref')
1319
1639
  description = deployment.get('description')
1320
1640
  release_notes = (
1321
- await self._release_notes_for_ref(client, str(ref_value))
1641
+ await self._release_notes_for_ref(
1642
+ client, str(ref_value), mainline, release_lookups
1643
+ )
1322
1644
  if ref_value
1323
1645
  else None
1324
1646
  )
@@ -1411,18 +1733,53 @@ class GitHubDeployment(DeploymentCapability):
1411
1733
  return result
1412
1734
 
1413
1735
  async def _release_for_ref(
1414
- self, client: httpx.AsyncClient, ref: str
1736
+ self,
1737
+ client: httpx.AsyncClient,
1738
+ ref: str,
1739
+ mainline: frozenset[str],
1740
+ release_lookups: dict[str, asyncio.Task[RemoteRelease | None]]
1741
+ | None = None,
1415
1742
  ) -> RemoteRelease | None:
1416
1743
  """Return the GitHub release for ``ref``, metadata included.
1417
1744
 
1418
- The single release lookup every caller degrades through: a 404 /
1419
- non-200 / parse failure yields ``None``, and a ``403`` (token lacks
1420
- scope to read releases) is cached process-wide so a forbidden token
1421
- short-circuits instead of re-issuing the request for every
1745
+ Refs in ``mainline`` (the resolved ``mainline_branches`` option)
1746
+ never name a release, so they never reach the API.
1747
+
1748
+ ``release_lookups`` memoises one lookup per ref for the duration of
1749
+ a sweep. It holds the in-flight task rather than its result, so
1750
+ the parallel per-env fan-out in
1751
+ :meth:`list_recent_deployments` coalesces onto a single request
1752
+ when several environments deployed the same ref -- checking a
1753
+ result-only cache would let every env past the check before the
1754
+ first response landed. Like ``run_cache`` it memoises every
1755
+ outcome, failures included, so one sweep never retries a ref that
1756
+ just errored; it is scoped the same way (one sweep, one repo, one
1757
+ token), and omitting it disables the memo for one-off host-facing
1758
+ lookups.
1759
+ """
1760
+ if ref in mainline:
1761
+ return None
1762
+ if release_lookups is None:
1763
+ return await self._fetch_release(client, ref)
1764
+ task = release_lookups.get(ref)
1765
+ if task is None:
1766
+ task = asyncio.ensure_future(self._fetch_release(client, ref))
1767
+ release_lookups[ref] = task
1768
+ return await task
1769
+
1770
+ async def _fetch_release(
1771
+ self, client: httpx.AsyncClient, ref: str
1772
+ ) -> RemoteRelease | None:
1773
+ """Issue the release lookup for ``ref`` and shape the response.
1774
+
1775
+ The single release request every caller degrades through: a 404 /
1776
+ 410 / non-200 / parse failure yields ``None``, and a ``403`` (token
1777
+ lacks scope to read releases) is cached process-wide so a forbidden
1778
+ token short-circuits instead of re-issuing the request for every
1422
1779
  deployment on every resync sweep. ``author`` is the login GitHub
1423
- credits with the release and ``author_subject`` its numeric user id,
1424
- which the host resolves to an Imbi user through the identity plugins
1425
- on the same service.
1780
+ credits with the release and ``author_subject`` its numeric user
1781
+ id, which the host resolves to an Imbi user through the identity
1782
+ plugins on the same service.
1426
1783
  """
1427
1784
  if _releases_forbidden(client):
1428
1785
  return None
@@ -1464,11 +1821,17 @@ class GitHubDeployment(DeploymentCapability):
1464
1821
  tag: str,
1465
1822
  ) -> RemoteRelease | None:
1466
1823
  """Return the GitHub release for ``tag`` with its author."""
1824
+ mainline = _mainline_branches(ctx.integration_options)
1467
1825
  async with self._client(ctx, credentials) as client:
1468
- return await self._release_for_ref(client, tag)
1826
+ return await self._release_for_ref(client, tag, mainline)
1469
1827
 
1470
1828
  async def _release_notes_for_ref(
1471
- self, client: httpx.AsyncClient, ref: str
1829
+ self,
1830
+ client: httpx.AsyncClient,
1831
+ ref: str,
1832
+ mainline: frozenset[str],
1833
+ release_lookups: dict[str, asyncio.Task[RemoteRelease | None]]
1834
+ | None = None,
1472
1835
  ) -> str | None:
1473
1836
  """Return the GitHub release notes body for a deployed ref.
1474
1837
 
@@ -1479,7 +1842,9 @@ class GitHubDeployment(DeploymentCapability):
1479
1842
  ``None`` -- resync is never blocked by a missing or unreadable
1480
1843
  release.
1481
1844
  """
1482
- release = await self._release_for_ref(client, ref)
1845
+ release = await self._release_for_ref(
1846
+ client, ref, mainline, release_lookups
1847
+ )
1483
1848
  return release.body_markdown if release else None
1484
1849
 
1485
1850
  async def _latest_status(
@@ -81,6 +81,22 @@ _OPTIONS: list[PluginOption] = [
81
81
  ),
82
82
  type='string',
83
83
  ),
84
+ PluginOption(
85
+ name='mainline_branches',
86
+ label='Mainline branches (space-separated)',
87
+ description=(
88
+ 'Branch names your repos deploy from, which therefore never '
89
+ 'name a GitHub Release. Deployment resync skips the release '
90
+ 'lookup for a deployment whose ref is one of these instead of '
91
+ 'issuing a request that is certain to 404 -- a repo deploying '
92
+ 'off its default branch would otherwise pay for one doomed '
93
+ 'lookup per deployment row. Commas or spaces both separate; '
94
+ 'leave blank for the ``main master`` default.'
95
+ ),
96
+ type='string',
97
+ required=False,
98
+ default='main master',
99
+ ),
84
100
  ]
85
101
 
86
102
  # The single credential store for the Integration. Every capability of