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.
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/PKG-INFO +3 -3
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/pyproject.toml +2 -2
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/deployment.py +383 -18
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/plugin.py +16 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/tests/test_deployment.py +719 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/.gitignore +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/README.md +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/moon.yml +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/README.md +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/__init__.py +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/_app_auth.py +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/_hosts.py +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/_repos.py +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/commits.py +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/doctor.py +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/identity.py +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/lifecycle.py +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/pull_requests.py +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/py.typed +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/tests/test_commits.py +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/tests/test_doctor.py +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/tests/test_pull_requests.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: imbi-plugin-github
|
|
3
|
-
Version: 2.
|
|
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.
|
|
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.
|
|
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.
|
|
21
|
+
"imbi-common[databases]==2.26.0",
|
|
22
22
|
"pydantic>=2",
|
|
23
23
|
"pyjwt[crypto]>=2.8",
|
|
24
24
|
]
|
{imbi_plugin_github-2.24.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/deployment.py
RENAMED
|
@@ -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
|
-
|
|
443
|
+
subject, _, body = raw_message.partition('\n')
|
|
381
444
|
return Commit(
|
|
382
445
|
sha=sha,
|
|
383
446
|
short_sha=_short_sha(sha),
|
|
384
|
-
message=
|
|
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,
|
|
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,
|
|
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(
|
|
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,
|
|
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
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
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
|
|
1424
|
-
which the host resolves to an Imbi user through the identity
|
|
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,
|
|
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(
|
|
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
|