imbi-plugin-github 2.28.0__tar.gz → 2.29.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.28.0 → imbi_plugin_github-2.29.0}/PKG-INFO +2 -2
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/pyproject.toml +2 -2
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/deployment.py +354 -19
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/plugin.py +18 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/tests/test_deployment.py +572 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/.gitignore +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/README.md +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/moon.yml +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/README.md +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/__init__.py +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/_app_auth.py +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/_hosts.py +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/_repos.py +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/commits.py +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/doctor.py +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/identity.py +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/lifecycle.py +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/pull_requests.py +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/py.typed +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/tests/test_commits.py +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/tests/test_doctor.py +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/tests/test_pull_requests.py +0 -0
- {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/tests/test_repos.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: imbi-plugin-github
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.29.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.29.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.29.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.29.0",
|
|
22
22
|
"pydantic>=2",
|
|
23
23
|
"pyjwt[crypto]>=2.8",
|
|
24
24
|
]
|
{imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/deployment.py
RENAMED
|
@@ -53,6 +53,7 @@ from imbi.common.plugins.base import (
|
|
|
53
53
|
DeploymentCapability,
|
|
54
54
|
DeploymentEventStatus,
|
|
55
55
|
DeploymentRun,
|
|
56
|
+
EnvironmentDeploymentState,
|
|
56
57
|
LinkWriteback,
|
|
57
58
|
NotesListing,
|
|
58
59
|
PluginContext,
|
|
@@ -152,6 +153,21 @@ _NOTE_BLOB_CONCURRENCY = 10
|
|
|
152
153
|
_FULL_SHA_PATTERN = re.compile(r'^[0-9a-f]{40}$')
|
|
153
154
|
|
|
154
155
|
|
|
156
|
+
class _StatusRead(typing.NamedTuple):
|
|
157
|
+
"""One deployment's status history, as far as it could be read."""
|
|
158
|
+
|
|
159
|
+
status: DeploymentEventStatus
|
|
160
|
+
log_url: str | None
|
|
161
|
+
#: The newest entry is GitHub's ``inactive`` notice, so this
|
|
162
|
+
#: deployment has been retired whatever ``status`` says it did.
|
|
163
|
+
superseded: bool
|
|
164
|
+
#: Nothing was read -- ``status`` is the ``pending`` fallback rather
|
|
165
|
+
#: than an observation. Resync wants that fallback (a noisy row must
|
|
166
|
+
#: not fail a whole project); anything deciding what an environment
|
|
167
|
+
#: serves has to know the difference.
|
|
168
|
+
unreadable: bool
|
|
169
|
+
|
|
170
|
+
|
|
155
171
|
def _note_sha(path: object) -> str | None:
|
|
156
172
|
"""Annotated commit SHA for a notes-tree path, or ``None``.
|
|
157
173
|
|
|
@@ -435,6 +451,43 @@ def _mainline_branches(
|
|
|
435
451
|
return configured or _DEFAULT_MAINLINE_BRANCHES
|
|
436
452
|
|
|
437
453
|
|
|
454
|
+
# How many deployments per environment ``get_environment_state`` walks
|
|
455
|
+
# before it stops looking for the active one. GitHub returns
|
|
456
|
+
# deployments newest-first, and the active deployment is normally the
|
|
457
|
+
# first or second row; a deeper walk only pays off on an environment
|
|
458
|
+
# whose recent history is a run of failures. Each row costs one status
|
|
459
|
+
# request, so the cap bounds the request count per environment rather
|
|
460
|
+
# than the wall time of one call.
|
|
461
|
+
_DEFAULT_ACTIVE_SCAN_LIMIT = 10
|
|
462
|
+
# One page holds the whole scan -- GitHub caps ``per_page`` at 100.
|
|
463
|
+
_MAX_ACTIVE_SCAN_LIMIT = 100
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _active_scan_limit(
|
|
467
|
+
integration_options: dict[str, typing.Any],
|
|
468
|
+
) -> int:
|
|
469
|
+
"""Resolve the ``active_scan_limit`` integration option.
|
|
470
|
+
|
|
471
|
+
Declared integration-level beside ``mainline_branches`` because how
|
|
472
|
+
deep the scan has to go is a property of the org's deploy habits, not
|
|
473
|
+
of one capability. Operator-entered values arrive as strings from the
|
|
474
|
+
admin form as often as integers, so both are accepted; anything absent,
|
|
475
|
+
unparseable, or below 1 resolves to
|
|
476
|
+
:data:`_DEFAULT_ACTIVE_SCAN_LIMIT`, and the value is clamped to what a
|
|
477
|
+
single GitHub page can carry.
|
|
478
|
+
"""
|
|
479
|
+
raw = integration_options.get('active_scan_limit')
|
|
480
|
+
if isinstance(raw, bool) or not isinstance(raw, (int, str)):
|
|
481
|
+
return _DEFAULT_ACTIVE_SCAN_LIMIT
|
|
482
|
+
try:
|
|
483
|
+
limit = int(raw)
|
|
484
|
+
except ValueError:
|
|
485
|
+
return _DEFAULT_ACTIVE_SCAN_LIMIT
|
|
486
|
+
if limit < 1:
|
|
487
|
+
return _DEFAULT_ACTIVE_SCAN_LIMIT
|
|
488
|
+
return min(limit, _MAX_ACTIVE_SCAN_LIMIT)
|
|
489
|
+
|
|
490
|
+
|
|
438
491
|
def _commit_from_payload(payload: dict[str, typing.Any]) -> Commit:
|
|
439
492
|
"""Convert a GitHub commit list/object payload into a :class:`Commit`."""
|
|
440
493
|
sha = str(payload.get('sha', ''))
|
|
@@ -912,23 +965,26 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
912
965
|
ctx: PluginContext,
|
|
913
966
|
credentials: dict[str, str],
|
|
914
967
|
namespace: str,
|
|
968
|
+
skip_shas: collections.abc.Collection[str] = (),
|
|
915
969
|
) -> NotesListing:
|
|
916
970
|
"""Every note on ``refs/notes/<namespace>`` at its current tip.
|
|
917
971
|
|
|
918
972
|
Two Git Data calls to reach the tree, then one blob read per
|
|
919
|
-
note. A missing ref answers
|
|
973
|
+
note the caller did not ask us to skip. A missing ref answers
|
|
974
|
+
an empty, complete listing.
|
|
920
975
|
|
|
921
976
|
``complete`` compares what the tree holds against what came
|
|
922
977
|
back: :meth:`_all_notes` drops a note whose blob it cannot read
|
|
923
978
|
(logging why), and a truncated tree listing hides notes before
|
|
924
979
|
that. Either way the caller must not treat the result as the
|
|
925
|
-
whole ref.
|
|
980
|
+
whole ref. A note skipped on request does not make the listing
|
|
981
|
+
incomplete -- the caller already has that answer.
|
|
926
982
|
"""
|
|
927
983
|
async with self._client(ctx, credentials) as client:
|
|
928
984
|
tip = await self._notes_ref_tip(client, namespace)
|
|
929
985
|
if tip is None:
|
|
930
986
|
return NotesListing({}, True)
|
|
931
|
-
return await self._all_notes(client, tip)
|
|
987
|
+
return await self._all_notes(client, tip, skip_shas)
|
|
932
988
|
|
|
933
989
|
async def diff_commit_notes(
|
|
934
990
|
self,
|
|
@@ -1080,17 +1136,22 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1080
1136
|
return out, not truncated
|
|
1081
1137
|
|
|
1082
1138
|
async def _all_notes(
|
|
1083
|
-
self,
|
|
1139
|
+
self,
|
|
1140
|
+
client: httpx.AsyncClient,
|
|
1141
|
+
commit_sha: str,
|
|
1142
|
+
skip_shas: collections.abc.Collection[str] = (),
|
|
1084
1143
|
) -> NotesListing:
|
|
1085
1144
|
"""Every note at one notes-ref commit, bodies included.
|
|
1086
1145
|
|
|
1087
1146
|
Blob reads run a few at a time (one request per note) and an
|
|
1088
1147
|
unreadable note is skipped rather than failing the batch or
|
|
1089
|
-
recording a false "removed".
|
|
1148
|
+
recording a false "removed". A note whose annotated commit is
|
|
1149
|
+
in ``skip_shas`` costs no request at all.
|
|
1090
1150
|
|
|
1091
1151
|
``complete`` combines the two ways this can fall short: a
|
|
1092
1152
|
truncated tree listing, and a blob that would not read. Either
|
|
1093
|
-
means the map is not the whole ref.
|
|
1153
|
+
means the map is not the whole ref. Notes skipped on request do
|
|
1154
|
+
not count against it.
|
|
1094
1155
|
"""
|
|
1095
1156
|
notes, tree_complete = await self._tree_notes(client, commit_sha)
|
|
1096
1157
|
gate = asyncio.Semaphore(_NOTE_BLOB_CONCURRENCY)
|
|
@@ -1102,7 +1163,12 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1102
1163
|
except httpx.HTTPError as exc:
|
|
1103
1164
|
return exc
|
|
1104
1165
|
|
|
1105
|
-
|
|
1166
|
+
skip = {sha.lower() for sha in skip_shas}
|
|
1167
|
+
items = [
|
|
1168
|
+
(annotated, blob_sha)
|
|
1169
|
+
for annotated, blob_sha in notes.items()
|
|
1170
|
+
if annotated.lower() not in skip
|
|
1171
|
+
]
|
|
1106
1172
|
bodies = await asyncio.gather(
|
|
1107
1173
|
*(_read(blob_sha) for _, blob_sha in items)
|
|
1108
1174
|
)
|
|
@@ -1120,7 +1186,7 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1120
1186
|
# a ``None`` here would read as "note removed".
|
|
1121
1187
|
continue
|
|
1122
1188
|
out[annotated] = body
|
|
1123
|
-
return NotesListing(out, tree_complete and len(out) == len(
|
|
1189
|
+
return NotesListing(out, tree_complete and len(out) == len(items))
|
|
1124
1190
|
|
|
1125
1191
|
@staticmethod
|
|
1126
1192
|
async def _blob_text(
|
|
@@ -1631,6 +1697,235 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1631
1697
|
)
|
|
1632
1698
|
return [observed for group in per_env for observed in group]
|
|
1633
1699
|
|
|
1700
|
+
async def get_environment_state(
|
|
1701
|
+
self,
|
|
1702
|
+
ctx: PluginContext,
|
|
1703
|
+
credentials: dict[str, str],
|
|
1704
|
+
environments: list[str],
|
|
1705
|
+
) -> list[EnvironmentDeploymentState]:
|
|
1706
|
+
"""Report the active deployment per environment.
|
|
1707
|
+
|
|
1708
|
+
Fans out one ``GET /deployments?environment={env}`` call per
|
|
1709
|
+
environment (newest-first, as GitHub orders them) and walks the
|
|
1710
|
+
page fetching each deployment's statuses until one both maps to
|
|
1711
|
+
exactly ``success`` -- not ``pending``, not ``in_progress`` -- and
|
|
1712
|
+
carries no ``inactive`` notice on top. That deployment is the
|
|
1713
|
+
active one.
|
|
1714
|
+
|
|
1715
|
+
Policy note: GitHub can leave several deployments active at once
|
|
1716
|
+
when automatic inactivation is disabled, so "active" cannot be
|
|
1717
|
+
read off the provider's own flag. Imbi's policy is *active = the
|
|
1718
|
+
newest deployment whose latest provider status is success*, which
|
|
1719
|
+
is well-defined either way.
|
|
1720
|
+
|
|
1721
|
+
The walk is bounded by the ``active_scan_limit`` option (see
|
|
1722
|
+
:func:`_active_scan_limit`). Reaching the cap without a success
|
|
1723
|
+
resolves ``unknown``, never ``none``: an older active deployment
|
|
1724
|
+
may sit just past the cap, and reporting ``none`` would have the
|
|
1725
|
+
host clear a pointer that is right.
|
|
1726
|
+
|
|
1727
|
+
Two degraded reads resolve ``error`` for the same reason. A row
|
|
1728
|
+
whose status history would not read (``status_unknown``), or one
|
|
1729
|
+
too malformed to identify at all, leaves the walk unable to say
|
|
1730
|
+
what that deployment did -- throttling blinds every row at once
|
|
1731
|
+
-- and a 404 on the listing itself means the repo moved or the
|
|
1732
|
+
token lost access, not that the environment is empty. An *empty*
|
|
1733
|
+
listing resolves ``unknown``: GitHub says ``[]`` both for an
|
|
1734
|
+
environment never deployed to and for a name it does not
|
|
1735
|
+
recognise, and local slugs reach it unmapped.
|
|
1736
|
+
|
|
1737
|
+
An unreadable row outranks a success found *below* it, so it wins
|
|
1738
|
+
over ``found`` rather than being noted alongside it. The walk
|
|
1739
|
+
stops at the first clean success, which means every row it could
|
|
1740
|
+
not read is newer than that success and may be the deployment
|
|
1741
|
+
actually serving the environment; calling the older one active
|
|
1742
|
+
would have the host write a stale pointer.
|
|
1743
|
+
|
|
1744
|
+
So ``none`` requires positive evidence -- rows read, none of them
|
|
1745
|
+
serving. Everything else the host must read as "keep what you
|
|
1746
|
+
have".
|
|
1747
|
+
"""
|
|
1748
|
+
scan_limit = _active_scan_limit(ctx.integration_options)
|
|
1749
|
+
# Same memoisation as the resync sweep: one triggering-actor
|
|
1750
|
+
# lookup per run and one release lookup per ref, shared across the
|
|
1751
|
+
# parallel per-env fan-out.
|
|
1752
|
+
run_cache: dict[str, tuple[str, str] | None] = {}
|
|
1753
|
+
release_lookups: dict[str, asyncio.Task[RemoteRelease | None]] = {}
|
|
1754
|
+
mainline = _mainline_branches(ctx.integration_options)
|
|
1755
|
+
async with self._client(ctx, credentials) as client:
|
|
1756
|
+
return list(
|
|
1757
|
+
await asyncio.gather(
|
|
1758
|
+
*(
|
|
1759
|
+
self._environment_state(
|
|
1760
|
+
client,
|
|
1761
|
+
env,
|
|
1762
|
+
scan_limit,
|
|
1763
|
+
run_cache,
|
|
1764
|
+
release_lookups,
|
|
1765
|
+
mainline,
|
|
1766
|
+
)
|
|
1767
|
+
for env in environments
|
|
1768
|
+
)
|
|
1769
|
+
)
|
|
1770
|
+
)
|
|
1771
|
+
|
|
1772
|
+
async def _environment_state(
|
|
1773
|
+
self,
|
|
1774
|
+
client: httpx.AsyncClient,
|
|
1775
|
+
environment: str,
|
|
1776
|
+
scan_limit: int,
|
|
1777
|
+
run_cache: dict[str, tuple[str, str] | None],
|
|
1778
|
+
release_lookups: dict[str, asyncio.Task[RemoteRelease | None]],
|
|
1779
|
+
mainline: frozenset[str],
|
|
1780
|
+
) -> EnvironmentDeploymentState:
|
|
1781
|
+
"""Resolve one environment's active deployment."""
|
|
1782
|
+
try:
|
|
1783
|
+
resp = await client.get(
|
|
1784
|
+
'/deployments',
|
|
1785
|
+
params={
|
|
1786
|
+
'environment': environment,
|
|
1787
|
+
'per_page': str(scan_limit),
|
|
1788
|
+
},
|
|
1789
|
+
)
|
|
1790
|
+
if resp.status_code == 404:
|
|
1791
|
+
# NOT ``none``, though the listing path treats a 404 as
|
|
1792
|
+
# "nothing to import". Here ``none`` authorizes the host
|
|
1793
|
+
# to clear the environment's current-release pointer, and
|
|
1794
|
+
# GitHub answers 404 for a repo that was renamed or
|
|
1795
|
+
# transferred, or one this installation lost access to --
|
|
1796
|
+
# the very conditions link writeback exists to self-heal.
|
|
1797
|
+
# An unknown *environment* is a 200 with an empty list, so
|
|
1798
|
+
# nothing legitimate is lost by refusing to read a 404 as
|
|
1799
|
+
# an answer.
|
|
1800
|
+
LOGGER.warning(
|
|
1801
|
+
'Active deployment scan got 404 for env=%s; treating '
|
|
1802
|
+
'it as unreadable rather than as "nothing deployed"',
|
|
1803
|
+
environment,
|
|
1804
|
+
)
|
|
1805
|
+
return EnvironmentDeploymentState(
|
|
1806
|
+
environment=environment, active_resolution='error'
|
|
1807
|
+
)
|
|
1808
|
+
resp.raise_for_status()
|
|
1809
|
+
deployments = typing.cast(list[dict[str, typing.Any]], resp.json())
|
|
1810
|
+
except (httpx.HTTPError, ValueError):
|
|
1811
|
+
LOGGER.warning(
|
|
1812
|
+
'Failed to resolve active deployment for env=%s',
|
|
1813
|
+
environment,
|
|
1814
|
+
exc_info=True,
|
|
1815
|
+
)
|
|
1816
|
+
return EnvironmentDeploymentState(
|
|
1817
|
+
environment=environment, active_resolution='error'
|
|
1818
|
+
)
|
|
1819
|
+
active: RemoteDeployment | None = None
|
|
1820
|
+
latest: RemoteDeployment | None = None
|
|
1821
|
+
scanned = 0
|
|
1822
|
+
# Set by any row above the walk's stopping point that we could
|
|
1823
|
+
# not read: a malformed listing entry, or one whose status
|
|
1824
|
+
# history would not load. Because the walk stops at the first
|
|
1825
|
+
# clean success, every such row is *newer* than whatever success
|
|
1826
|
+
# we go on to find, so it may itself be the deployment actually
|
|
1827
|
+
# serving the environment.
|
|
1828
|
+
unresolved = False
|
|
1829
|
+
for deployment in deployments:
|
|
1830
|
+
scanned += 1
|
|
1831
|
+
observed = await self._observe_deployment(
|
|
1832
|
+
client,
|
|
1833
|
+
environment,
|
|
1834
|
+
deployment,
|
|
1835
|
+
run_cache,
|
|
1836
|
+
release_lookups,
|
|
1837
|
+
mainline,
|
|
1838
|
+
)
|
|
1839
|
+
if observed is None:
|
|
1840
|
+
# A row we could not even identify. It is still a row
|
|
1841
|
+
# newer than any success below it, so it has to count as
|
|
1842
|
+
# uncertainty rather than be skipped silently.
|
|
1843
|
+
LOGGER.warning(
|
|
1844
|
+
'Active deployment scan could not identify a row for '
|
|
1845
|
+
'env=%s (id=%r sha=%r); resolving it as unreadable',
|
|
1846
|
+
environment,
|
|
1847
|
+
deployment.get('id'),
|
|
1848
|
+
deployment.get('sha'),
|
|
1849
|
+
)
|
|
1850
|
+
unresolved = True
|
|
1851
|
+
continue
|
|
1852
|
+
if latest is None:
|
|
1853
|
+
latest = observed
|
|
1854
|
+
if observed.status_unknown:
|
|
1855
|
+
# Its status read failed, so this row's ``pending`` is a
|
|
1856
|
+
# fallback. Keep walking -- an older row may still
|
|
1857
|
+
# answer -- but remember that the walk passed something
|
|
1858
|
+
# it could not see.
|
|
1859
|
+
unresolved = True
|
|
1860
|
+
# ``status`` looks past GitHub's ``inactive`` notice on
|
|
1861
|
+
# purpose, so a superseded rollout still reads as the
|
|
1862
|
+
# ``success`` it was. For "what is serving now" that notice
|
|
1863
|
+
# is the answer, not noise: without the ``superseded`` test
|
|
1864
|
+
# a deactivated environment reports its last success as
|
|
1865
|
+
# active forever. Walking on is safe -- an ``inactive``
|
|
1866
|
+
# written because a later deployment took over has that
|
|
1867
|
+
# deployment above it in this same newest-first page.
|
|
1868
|
+
if observed.status == 'success' and not observed.superseded:
|
|
1869
|
+
if unresolved:
|
|
1870
|
+
# A newer row we could not read sits above this
|
|
1871
|
+
# success, so we cannot claim this one is serving.
|
|
1872
|
+
# Stop here and report the uncertainty: the walk has
|
|
1873
|
+
# nothing older left to learn from.
|
|
1874
|
+
break
|
|
1875
|
+
active = observed
|
|
1876
|
+
break
|
|
1877
|
+
# The result set is exhausted only when the walk read every row
|
|
1878
|
+
# GitHub returned *and* GitHub returned fewer than we asked for
|
|
1879
|
+
# (a full page means there is more history past the cap). An
|
|
1880
|
+
# empty listing does not count: GitHub answers 200 with ``[]``
|
|
1881
|
+
# both for an environment that has never been deployed to and
|
|
1882
|
+
# for an environment *name it has never heard of*, and the host
|
|
1883
|
+
# passes local slugs through unmapped. A project whose local
|
|
1884
|
+
# slug is 'prod' against a remote 'production' would otherwise
|
|
1885
|
+
# resolve ``none`` and have its pointer cleared on every sweep.
|
|
1886
|
+
# Clearing needs positive evidence -- rows we read, none of them
|
|
1887
|
+
# serving -- which is the deactivated-environment case.
|
|
1888
|
+
exhausted = (
|
|
1889
|
+
bool(deployments)
|
|
1890
|
+
and scanned == len(deployments)
|
|
1891
|
+
and scanned < scan_limit
|
|
1892
|
+
)
|
|
1893
|
+
resolution: typing.Literal['found', 'none', 'unknown', 'error']
|
|
1894
|
+
if unresolved:
|
|
1895
|
+
# Tested BEFORE ``found`` on purpose. The walk stops at the
|
|
1896
|
+
# first clean success, so every row it could not read is
|
|
1897
|
+
# newer than that success -- and a 403 on the newest
|
|
1898
|
+
# deployment's status hides exactly the deployment most
|
|
1899
|
+
# likely to be serving. Reporting ``found`` here would name
|
|
1900
|
+
# an older release as current and have the host write that
|
|
1901
|
+
# stale pointer. ``error``, not ``none`` and not
|
|
1902
|
+
# ``unknown``: the scan was not capped, it was blinded --
|
|
1903
|
+
# most often by throttling, where every status read fails
|
|
1904
|
+
# and ``none`` would clear every pointer on the project.
|
|
1905
|
+
resolution = 'error'
|
|
1906
|
+
elif active is not None:
|
|
1907
|
+
resolution = 'found'
|
|
1908
|
+
elif exhausted:
|
|
1909
|
+
resolution = 'none'
|
|
1910
|
+
else:
|
|
1911
|
+
resolution = 'unknown'
|
|
1912
|
+
LOGGER.info(
|
|
1913
|
+
'Active deployment scan env=%s deployments_scanned=%d '
|
|
1914
|
+
'unresolved_above_success=%s scan_exhausted=%s '
|
|
1915
|
+
'active_resolution=%s',
|
|
1916
|
+
environment,
|
|
1917
|
+
scanned,
|
|
1918
|
+
unresolved,
|
|
1919
|
+
exhausted,
|
|
1920
|
+
resolution,
|
|
1921
|
+
)
|
|
1922
|
+
return EnvironmentDeploymentState(
|
|
1923
|
+
environment=environment,
|
|
1924
|
+
active=active,
|
|
1925
|
+
latest=latest,
|
|
1926
|
+
active_resolution=resolution,
|
|
1927
|
+
)
|
|
1928
|
+
|
|
1634
1929
|
async def get_release_notes(
|
|
1635
1930
|
self,
|
|
1636
1931
|
ctx: PluginContext,
|
|
@@ -1721,9 +2016,8 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1721
2016
|
created_at = _parse_iso(deployment.get('created_at')) or (
|
|
1722
2017
|
datetime.datetime.now(datetime.UTC)
|
|
1723
2018
|
)
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
)
|
|
2019
|
+
read = await self._latest_status(client, str(deployment_id))
|
|
2020
|
+
status, status_url = read.status, read.log_url
|
|
1727
2021
|
ref_value = deployment.get('ref')
|
|
1728
2022
|
description = deployment.get('description')
|
|
1729
2023
|
release_notes = (
|
|
@@ -1772,6 +2066,8 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1772
2066
|
release_notes=release_notes,
|
|
1773
2067
|
creator=creator_login,
|
|
1774
2068
|
creator_subject=creator_subject,
|
|
2069
|
+
superseded=read.superseded,
|
|
2070
|
+
status_unknown=read.unreadable,
|
|
1775
2071
|
)
|
|
1776
2072
|
|
|
1777
2073
|
async def _resolve_triggering_actor(
|
|
@@ -1869,6 +2165,13 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1869
2165
|
credits with the release and ``author_subject`` its numeric user
|
|
1870
2166
|
id, which the host resolves to an Imbi user through the identity
|
|
1871
2167
|
plugins on the same service.
|
|
2168
|
+
|
|
2169
|
+
A 401 degrades here too, like every other failure, rather than
|
|
2170
|
+
propagating: release notes are *enrichment*, so losing them must
|
|
2171
|
+
never fail the caller -- the same rule
|
|
2172
|
+
:meth:`_resolve_triggering_actor` applies to attribution. The
|
|
2173
|
+
status read is deliberately not treated this way, because there
|
|
2174
|
+
the answer itself is what a 401 hides.
|
|
1872
2175
|
"""
|
|
1873
2176
|
if _releases_forbidden(client):
|
|
1874
2177
|
return None
|
|
@@ -1876,7 +2179,7 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1876
2179
|
resp = await client.get(
|
|
1877
2180
|
f'/releases/tags/{urllib.parse.quote(ref, safe="")}'
|
|
1878
2181
|
)
|
|
1879
|
-
except httpx.HTTPError:
|
|
2182
|
+
except (httpx.HTTPError, PluginAuthenticationFailed):
|
|
1880
2183
|
return None
|
|
1881
2184
|
if resp.status_code == 403:
|
|
1882
2185
|
_record_releases_forbidden(client)
|
|
@@ -1938,8 +2241,15 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1938
2241
|
|
|
1939
2242
|
async def _latest_status(
|
|
1940
2243
|
self, client: httpx.AsyncClient, deployment_id: str
|
|
1941
|
-
) ->
|
|
1942
|
-
"""Return the canonical event status
|
|
2244
|
+
) -> _StatusRead:
|
|
2245
|
+
"""Return the canonical event status, log URL, and retirement.
|
|
2246
|
+
|
|
2247
|
+
The third element is ``True`` when the newest status entry is
|
|
2248
|
+
``inactive``: the deployment's own outcome (the first element)
|
|
2249
|
+
looks past that notice, so this is the only place the caller can
|
|
2250
|
+
learn that GitHub has since retired it. Both readings are
|
|
2251
|
+
needed and neither substitutes for the other -- "what did this
|
|
2252
|
+
rollout do" is a different question from "is it serving now".
|
|
1943
2253
|
|
|
1944
2254
|
Falls back to ``'pending'`` whenever the deploy workflow has
|
|
1945
2255
|
not yet posted a status: a freshly-created deployment with no
|
|
@@ -1975,15 +2285,35 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1975
2285
|
params={'per_page': '10'},
|
|
1976
2286
|
)
|
|
1977
2287
|
except httpx.HTTPError:
|
|
1978
|
-
return
|
|
2288
|
+
return _StatusRead(
|
|
2289
|
+
'pending', None, superseded=False, unreadable=True
|
|
2290
|
+
)
|
|
1979
2291
|
if resp.status_code != 200:
|
|
1980
|
-
|
|
2292
|
+
# 403/429 land here, which is how a throttled scan used to
|
|
2293
|
+
# read every row as ``pending`` and conclude that nothing was
|
|
2294
|
+
# deployed. The status is still ``pending`` for resync; the
|
|
2295
|
+
# flag is what stops a currency decision resting on it.
|
|
2296
|
+
LOGGER.warning(
|
|
2297
|
+
'Deployment %s statuses answered %d; status unread',
|
|
2298
|
+
deployment_id,
|
|
2299
|
+
resp.status_code,
|
|
2300
|
+
)
|
|
2301
|
+
return _StatusRead(
|
|
2302
|
+
'pending', None, superseded=False, unreadable=True
|
|
2303
|
+
)
|
|
1981
2304
|
try:
|
|
1982
2305
|
statuses = typing.cast(list[dict[str, typing.Any]], resp.json())
|
|
1983
2306
|
except ValueError:
|
|
1984
|
-
return
|
|
2307
|
+
return _StatusRead(
|
|
2308
|
+
'pending', None, superseded=False, unreadable=True
|
|
2309
|
+
)
|
|
1985
2310
|
if not statuses:
|
|
1986
|
-
|
|
2311
|
+
# Read fine and there is genuinely nothing: a deployment
|
|
2312
|
+
# whose workflow has not posted yet. Not unreadable.
|
|
2313
|
+
return _StatusRead(
|
|
2314
|
+
'pending', None, superseded=False, unreadable=False
|
|
2315
|
+
)
|
|
2316
|
+
superseded = str(statuses[0].get('state') or '').lower() == 'inactive'
|
|
1987
2317
|
latest = next(
|
|
1988
2318
|
(
|
|
1989
2319
|
entry
|
|
@@ -1994,7 +2324,12 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1994
2324
|
)
|
|
1995
2325
|
state = str(latest.get('state') or '').lower()
|
|
1996
2326
|
log_url = latest.get('log_url') or latest.get('target_url')
|
|
1997
|
-
return
|
|
2327
|
+
return _StatusRead(
|
|
2328
|
+
_to_event_status(state),
|
|
2329
|
+
str(log_url) if log_url else None,
|
|
2330
|
+
superseded=superseded,
|
|
2331
|
+
unreadable=False,
|
|
2332
|
+
)
|
|
1998
2333
|
|
|
1999
2334
|
|
|
2000
2335
|
_RUN_ID_RE = re.compile(r'/actions/runs/(\d+)')
|
|
@@ -97,6 +97,24 @@ _OPTIONS: list[PluginOption] = [
|
|
|
97
97
|
required=False,
|
|
98
98
|
default='main master',
|
|
99
99
|
),
|
|
100
|
+
PluginOption(
|
|
101
|
+
name='active_scan_limit',
|
|
102
|
+
label='Active deployment scan limit',
|
|
103
|
+
description=(
|
|
104
|
+
'How many deployments per environment the active-state scan '
|
|
105
|
+
'walks, newest-first, looking for the newest one whose latest '
|
|
106
|
+
'status is success. Each row costs one status request. Raise '
|
|
107
|
+
'it for repos whose environments see long runs of failed or '
|
|
108
|
+
'superseded deploys, where the live deployment sits deeper in '
|
|
109
|
+
'the history; hitting the limit reports the active deployment '
|
|
110
|
+
'as unknown rather than as absent. Values above 100 are '
|
|
111
|
+
'clamped to 100, the most a single GitHub page can carry. '
|
|
112
|
+
'Leave blank for the default of 10.'
|
|
113
|
+
),
|
|
114
|
+
type='integer',
|
|
115
|
+
required=False,
|
|
116
|
+
default=10,
|
|
117
|
+
),
|
|
100
118
|
]
|
|
101
119
|
|
|
102
120
|
# The single credential store for the Integration. Every capability of
|
|
@@ -17,6 +17,7 @@ from imbi.common.plugins.base import (
|
|
|
17
17
|
from imbi.common.plugins.errors import PluginAuthenticationFailed
|
|
18
18
|
from imbi.plugins.github.deployment import (
|
|
19
19
|
GitHubDeployment,
|
|
20
|
+
_active_scan_limit,
|
|
20
21
|
_artifact_status,
|
|
21
22
|
_mainline_branches,
|
|
22
23
|
_repo_root_from_redirect,
|
|
@@ -57,6 +58,25 @@ def _ctx(
|
|
|
57
58
|
_CREDS = {'access_token': 'gho_test'}
|
|
58
59
|
|
|
59
60
|
|
|
61
|
+
def _deployment(deployment_id: int, created_at: str) -> dict[str, object]:
|
|
62
|
+
"""A minimal ``GET /deployments`` row deploying a mainline branch."""
|
|
63
|
+
return {
|
|
64
|
+
'id': deployment_id,
|
|
65
|
+
'sha': f'sha{deployment_id}',
|
|
66
|
+
'ref': 'main',
|
|
67
|
+
'created_at': created_at,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _statuses(*states: str) -> None:
|
|
72
|
+
"""Mock ``/deployments/{id}/statuses`` for ids 1..len(states)."""
|
|
73
|
+
for offset, state in enumerate(states, start=1):
|
|
74
|
+
respx.get(
|
|
75
|
+
'https://api.github.com/repos/octo/demo/deployments/'
|
|
76
|
+
f'{offset}/statuses'
|
|
77
|
+
).mock(return_value=httpx.Response(200, json=[{'state': state}]))
|
|
78
|
+
|
|
79
|
+
|
|
60
80
|
class ManifestTestCase(unittest.TestCase):
|
|
61
81
|
def test_manifest_slug(self) -> None:
|
|
62
82
|
self.assertEqual(GitHubPlugin.manifest.slug, 'github')
|
|
@@ -108,6 +128,14 @@ class ManifestTestCase(unittest.TestCase):
|
|
|
108
128
|
'mainline_branches', {opt.name for opt in cap.options}
|
|
109
129
|
)
|
|
110
130
|
|
|
131
|
+
def test_active_scan_limit_declared_integration_level(self) -> None:
|
|
132
|
+
# Scan depth follows the org's deploy habits, like mainline
|
|
133
|
+
# branch naming, so it sits beside flavor/host on the Integration.
|
|
134
|
+
options = {opt.name: opt for opt in GitHubPlugin.manifest.options}
|
|
135
|
+
self.assertIn('active_scan_limit', options)
|
|
136
|
+
self.assertEqual(options['active_scan_limit'].default, 10)
|
|
137
|
+
self.assertFalse(options['active_scan_limit'].required)
|
|
138
|
+
|
|
111
139
|
def test_no_legacy_deploys_via_edge_declared(self) -> None:
|
|
112
140
|
# Promote behaviour is inferred from the ref shape and per-env
|
|
113
141
|
# payloads ride on the USES edge (``env_payloads``). No plugin
|
|
@@ -1920,6 +1948,460 @@ class ListRecentDeploymentsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
1920
1948
|
self.assertEqual(events[0].creator_subject, '583231')
|
|
1921
1949
|
|
|
1922
1950
|
|
|
1951
|
+
class GetEnvironmentStateTestCase(unittest.IsolatedAsyncioTestCase):
|
|
1952
|
+
@respx.mock
|
|
1953
|
+
async def test_newest_success_is_active(self) -> None:
|
|
1954
|
+
respx.get(
|
|
1955
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
1956
|
+
params={'environment': 'production', 'per_page': '10'},
|
|
1957
|
+
).mock(
|
|
1958
|
+
return_value=httpx.Response(
|
|
1959
|
+
200, json=[_deployment(1, '2026-05-13T14:00:00Z')]
|
|
1960
|
+
)
|
|
1961
|
+
)
|
|
1962
|
+
_statuses('success')
|
|
1963
|
+
plugin = GitHubDeployment()
|
|
1964
|
+
states = await plugin.get_environment_state(
|
|
1965
|
+
_ctx(), _CREDS, ['production']
|
|
1966
|
+
)
|
|
1967
|
+
self.assertEqual(len(states), 1)
|
|
1968
|
+
state = states[0]
|
|
1969
|
+
self.assertEqual(state.active_resolution, 'found')
|
|
1970
|
+
assert state.active is not None
|
|
1971
|
+
assert state.latest is not None
|
|
1972
|
+
self.assertEqual(state.active.external_run_id, '1')
|
|
1973
|
+
# The newest attempt is also the active one here, and both carry
|
|
1974
|
+
# the same ``RemoteDeployment`` shape resync records.
|
|
1975
|
+
self.assertEqual(state.latest.external_run_id, '1')
|
|
1976
|
+
self.assertEqual(state.active.status, 'success')
|
|
1977
|
+
|
|
1978
|
+
@respx.mock
|
|
1979
|
+
async def test_newest_failed_older_success_stays_active(self) -> None:
|
|
1980
|
+
# The original bug: the newest attempt failed, so the deployment
|
|
1981
|
+
# actually serving the environment is the older success.
|
|
1982
|
+
respx.get(
|
|
1983
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
1984
|
+
params={'environment': 'production', 'per_page': '10'},
|
|
1985
|
+
).mock(
|
|
1986
|
+
return_value=httpx.Response(
|
|
1987
|
+
200,
|
|
1988
|
+
json=[
|
|
1989
|
+
_deployment(1, '2026-05-13T15:00:00Z'),
|
|
1990
|
+
_deployment(2, '2026-05-13T14:00:00Z'),
|
|
1991
|
+
],
|
|
1992
|
+
)
|
|
1993
|
+
)
|
|
1994
|
+
_statuses('failure', 'success')
|
|
1995
|
+
plugin = GitHubDeployment()
|
|
1996
|
+
state = (
|
|
1997
|
+
await plugin.get_environment_state(_ctx(), _CREDS, ['production'])
|
|
1998
|
+
)[0]
|
|
1999
|
+
self.assertEqual(state.active_resolution, 'found')
|
|
2000
|
+
assert state.active is not None
|
|
2001
|
+
assert state.latest is not None
|
|
2002
|
+
self.assertEqual(state.active.external_run_id, '2')
|
|
2003
|
+
self.assertEqual(state.latest.external_run_id, '1')
|
|
2004
|
+
self.assertEqual(state.latest.status, 'failed')
|
|
2005
|
+
|
|
2006
|
+
@respx.mock
|
|
2007
|
+
async def test_newest_pending_older_success_stays_active(self) -> None:
|
|
2008
|
+
# An in-flight deploy is activity, not currency: the older
|
|
2009
|
+
# success is still what serves traffic.
|
|
2010
|
+
respx.get(
|
|
2011
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
2012
|
+
params={'environment': 'production', 'per_page': '10'},
|
|
2013
|
+
).mock(
|
|
2014
|
+
return_value=httpx.Response(
|
|
2015
|
+
200,
|
|
2016
|
+
json=[
|
|
2017
|
+
_deployment(1, '2026-05-13T15:00:00Z'),
|
|
2018
|
+
_deployment(2, '2026-05-13T14:00:00Z'),
|
|
2019
|
+
],
|
|
2020
|
+
)
|
|
2021
|
+
)
|
|
2022
|
+
_statuses('in_progress', 'success')
|
|
2023
|
+
plugin = GitHubDeployment()
|
|
2024
|
+
state = (
|
|
2025
|
+
await plugin.get_environment_state(_ctx(), _CREDS, ['production'])
|
|
2026
|
+
)[0]
|
|
2027
|
+
self.assertEqual(state.active_resolution, 'found')
|
|
2028
|
+
assert state.active is not None
|
|
2029
|
+
assert state.latest is not None
|
|
2030
|
+
self.assertEqual(state.active.external_run_id, '2')
|
|
2031
|
+
self.assertEqual(state.latest.status, 'in_progress')
|
|
2032
|
+
|
|
2033
|
+
@respx.mock
|
|
2034
|
+
async def test_retired_success_is_not_active(self) -> None:
|
|
2035
|
+
# GitHub wrote ``inactive`` on top of the success, so nothing is
|
|
2036
|
+
# serving this environment any more. ``status`` still reads
|
|
2037
|
+
# ``success`` -- the rollout did succeed -- which is exactly why
|
|
2038
|
+
# the scan cannot answer from ``status`` alone.
|
|
2039
|
+
respx.get(
|
|
2040
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
2041
|
+
params={'environment': 'production', 'per_page': '10'},
|
|
2042
|
+
).mock(
|
|
2043
|
+
return_value=httpx.Response(
|
|
2044
|
+
200, json=[_deployment(1, '2026-05-13T14:00:00Z')]
|
|
2045
|
+
)
|
|
2046
|
+
)
|
|
2047
|
+
respx.get(
|
|
2048
|
+
'https://api.github.com/repos/octo/demo/deployments/1/statuses'
|
|
2049
|
+
).mock(
|
|
2050
|
+
return_value=httpx.Response(
|
|
2051
|
+
200, json=[{'state': 'inactive'}, {'state': 'success'}]
|
|
2052
|
+
)
|
|
2053
|
+
)
|
|
2054
|
+
plugin = GitHubDeployment()
|
|
2055
|
+
state = (
|
|
2056
|
+
await plugin.get_environment_state(_ctx(), _CREDS, ['production'])
|
|
2057
|
+
)[0]
|
|
2058
|
+
self.assertEqual(state.active_resolution, 'none')
|
|
2059
|
+
self.assertIsNone(state.active)
|
|
2060
|
+
assert state.latest is not None
|
|
2061
|
+
self.assertEqual(state.latest.status, 'success')
|
|
2062
|
+
self.assertTrue(state.latest.superseded)
|
|
2063
|
+
|
|
2064
|
+
@respx.mock
|
|
2065
|
+
async def test_scan_walks_past_a_retired_success(self) -> None:
|
|
2066
|
+
# The newest deployment was retired; the one below it is what
|
|
2067
|
+
# the environment is actually serving.
|
|
2068
|
+
respx.get(
|
|
2069
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
2070
|
+
params={'environment': 'production', 'per_page': '10'},
|
|
2071
|
+
).mock(
|
|
2072
|
+
return_value=httpx.Response(
|
|
2073
|
+
200,
|
|
2074
|
+
json=[
|
|
2075
|
+
_deployment(1, '2026-05-13T15:00:00Z'),
|
|
2076
|
+
_deployment(2, '2026-05-13T14:00:00Z'),
|
|
2077
|
+
],
|
|
2078
|
+
)
|
|
2079
|
+
)
|
|
2080
|
+
respx.get(
|
|
2081
|
+
'https://api.github.com/repos/octo/demo/deployments/1/statuses'
|
|
2082
|
+
).mock(
|
|
2083
|
+
return_value=httpx.Response(
|
|
2084
|
+
200, json=[{'state': 'inactive'}, {'state': 'success'}]
|
|
2085
|
+
)
|
|
2086
|
+
)
|
|
2087
|
+
respx.get(
|
|
2088
|
+
'https://api.github.com/repos/octo/demo/deployments/2/statuses'
|
|
2089
|
+
).mock(return_value=httpx.Response(200, json=[{'state': 'success'}]))
|
|
2090
|
+
plugin = GitHubDeployment()
|
|
2091
|
+
state = (
|
|
2092
|
+
await plugin.get_environment_state(_ctx(), _CREDS, ['production'])
|
|
2093
|
+
)[0]
|
|
2094
|
+
self.assertEqual(state.active_resolution, 'found')
|
|
2095
|
+
assert state.active is not None
|
|
2096
|
+
self.assertEqual(state.active.external_run_id, '2')
|
|
2097
|
+
self.assertFalse(state.active.superseded)
|
|
2098
|
+
assert state.latest is not None
|
|
2099
|
+
self.assertEqual(state.latest.external_run_id, '1')
|
|
2100
|
+
|
|
2101
|
+
@respx.mock
|
|
2102
|
+
async def test_scan_cap_reached_is_unknown_never_none(self) -> None:
|
|
2103
|
+
# A full page with no success means an older active deployment
|
|
2104
|
+
# may sit just past the cap — reporting 'none' would have the
|
|
2105
|
+
# host clear a pointer that is right.
|
|
2106
|
+
respx.get(
|
|
2107
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
2108
|
+
params={'environment': 'production', 'per_page': '2'},
|
|
2109
|
+
).mock(
|
|
2110
|
+
return_value=httpx.Response(
|
|
2111
|
+
200,
|
|
2112
|
+
json=[
|
|
2113
|
+
_deployment(1, '2026-05-13T15:00:00Z'),
|
|
2114
|
+
_deployment(2, '2026-05-13T14:00:00Z'),
|
|
2115
|
+
],
|
|
2116
|
+
)
|
|
2117
|
+
)
|
|
2118
|
+
_statuses('failure', 'failure')
|
|
2119
|
+
plugin = GitHubDeployment()
|
|
2120
|
+
ctx = _ctx(connection=_connection() | {'active_scan_limit': 2})
|
|
2121
|
+
state = (
|
|
2122
|
+
await plugin.get_environment_state(ctx, _CREDS, ['production'])
|
|
2123
|
+
)[0]
|
|
2124
|
+
self.assertEqual(state.active_resolution, 'unknown')
|
|
2125
|
+
self.assertIsNone(state.active)
|
|
2126
|
+
assert state.latest is not None
|
|
2127
|
+
self.assertEqual(state.latest.external_run_id, '1')
|
|
2128
|
+
|
|
2129
|
+
@respx.mock
|
|
2130
|
+
async def test_no_deployments_is_unknown_not_none(self) -> None:
|
|
2131
|
+
# GitHub answers 200 with ``[]`` both for an environment nothing
|
|
2132
|
+
# has deployed to and for an environment name it has never heard
|
|
2133
|
+
# of -- and local slugs reach it unmapped, so a project whose
|
|
2134
|
+
# slug is 'prod' against a remote 'production' looks identical to
|
|
2135
|
+
# an empty environment. ``none`` clears the pointer, so an empty
|
|
2136
|
+
# listing must not earn it.
|
|
2137
|
+
respx.get(
|
|
2138
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
2139
|
+
params={'environment': 'production', 'per_page': '10'},
|
|
2140
|
+
).mock(return_value=httpx.Response(200, json=[]))
|
|
2141
|
+
plugin = GitHubDeployment()
|
|
2142
|
+
state = (
|
|
2143
|
+
await plugin.get_environment_state(_ctx(), _CREDS, ['production'])
|
|
2144
|
+
)[0]
|
|
2145
|
+
self.assertEqual(state.active_resolution, 'unknown')
|
|
2146
|
+
self.assertIsNone(state.active)
|
|
2147
|
+
self.assertIsNone(state.latest)
|
|
2148
|
+
|
|
2149
|
+
@respx.mock
|
|
2150
|
+
async def test_short_page_without_success_is_none(self) -> None:
|
|
2151
|
+
# Fewer rows than the cap means the history is exhausted, so
|
|
2152
|
+
# "nothing is deployed" is a real answer.
|
|
2153
|
+
respx.get(
|
|
2154
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
2155
|
+
params={'environment': 'production', 'per_page': '10'},
|
|
2156
|
+
).mock(
|
|
2157
|
+
return_value=httpx.Response(
|
|
2158
|
+
200, json=[_deployment(1, '2026-05-13T15:00:00Z')]
|
|
2159
|
+
)
|
|
2160
|
+
)
|
|
2161
|
+
_statuses('failure')
|
|
2162
|
+
plugin = GitHubDeployment()
|
|
2163
|
+
state = (
|
|
2164
|
+
await plugin.get_environment_state(_ctx(), _CREDS, ['production'])
|
|
2165
|
+
)[0]
|
|
2166
|
+
self.assertEqual(state.active_resolution, 'none')
|
|
2167
|
+
self.assertIsNone(state.active)
|
|
2168
|
+
assert state.latest is not None
|
|
2169
|
+
self.assertEqual(state.latest.status, 'failed')
|
|
2170
|
+
|
|
2171
|
+
@respx.mock
|
|
2172
|
+
async def test_provider_failure_is_error_not_none(self) -> None:
|
|
2173
|
+
respx.get(
|
|
2174
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
2175
|
+
params={'environment': 'production', 'per_page': '10'},
|
|
2176
|
+
).mock(
|
|
2177
|
+
return_value=httpx.Response(503, json={'message': 'unavailable'})
|
|
2178
|
+
)
|
|
2179
|
+
plugin = GitHubDeployment()
|
|
2180
|
+
state = (
|
|
2181
|
+
await plugin.get_environment_state(_ctx(), _CREDS, ['production'])
|
|
2182
|
+
)[0]
|
|
2183
|
+
self.assertEqual(state.active_resolution, 'error')
|
|
2184
|
+
self.assertIsNone(state.active)
|
|
2185
|
+
self.assertIsNone(state.latest)
|
|
2186
|
+
|
|
2187
|
+
@respx.mock
|
|
2188
|
+
async def test_a_404_listing_is_error_not_none(self) -> None:
|
|
2189
|
+
# NOT ``none``, unlike ``list_recent_deployments``: here ``none``
|
|
2190
|
+
# authorizes clearing the pointer, and a 404 is what a renamed or
|
|
2191
|
+
# transferred repo -- or a token that lost access -- answers. An
|
|
2192
|
+
# unknown *environment* is a 200 with an empty list.
|
|
2193
|
+
respx.get(
|
|
2194
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
2195
|
+
params={'environment': 'production', 'per_page': '10'},
|
|
2196
|
+
).mock(
|
|
2197
|
+
return_value=httpx.Response(
|
|
2198
|
+
200, json=[_deployment(1, '2026-05-13T15:00:00Z')]
|
|
2199
|
+
)
|
|
2200
|
+
)
|
|
2201
|
+
respx.get(
|
|
2202
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
2203
|
+
params={'environment': 'never-deployed', 'per_page': '10'},
|
|
2204
|
+
).mock(return_value=httpx.Response(404, json={'message': 'Not Found'}))
|
|
2205
|
+
_statuses('success')
|
|
2206
|
+
plugin = GitHubDeployment()
|
|
2207
|
+
with self.assertLogs('imbi.plugins.github', level='WARNING'):
|
|
2208
|
+
states = await plugin.get_environment_state(
|
|
2209
|
+
_ctx(), _CREDS, ['production', 'never-deployed']
|
|
2210
|
+
)
|
|
2211
|
+
by_env = {s.environment: s for s in states}
|
|
2212
|
+
self.assertEqual(by_env['production'].active_resolution, 'found')
|
|
2213
|
+
missing = by_env['never-deployed']
|
|
2214
|
+
self.assertEqual(missing.active_resolution, 'error')
|
|
2215
|
+
self.assertIsNone(missing.active)
|
|
2216
|
+
self.assertIsNone(missing.latest)
|
|
2217
|
+
|
|
2218
|
+
@respx.mock
|
|
2219
|
+
async def test_an_unread_status_is_error_not_none(self) -> None:
|
|
2220
|
+
# A throttled scan reads every status as the ``pending``
|
|
2221
|
+
# fallback. Answering ``none`` there would clear a pointer that
|
|
2222
|
+
# is right, on every environment of the project at once.
|
|
2223
|
+
respx.get(
|
|
2224
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
2225
|
+
params={'environment': 'production', 'per_page': '10'},
|
|
2226
|
+
).mock(
|
|
2227
|
+
return_value=httpx.Response(
|
|
2228
|
+
200, json=[_deployment(1, '2026-05-13T15:00:00Z')]
|
|
2229
|
+
)
|
|
2230
|
+
)
|
|
2231
|
+
respx.get(
|
|
2232
|
+
'https://api.github.com/repos/octo/demo/deployments/1/statuses'
|
|
2233
|
+
).mock(
|
|
2234
|
+
return_value=httpx.Response(
|
|
2235
|
+
403, json={'message': 'API rate limit exceeded'}
|
|
2236
|
+
)
|
|
2237
|
+
)
|
|
2238
|
+
plugin = GitHubDeployment()
|
|
2239
|
+
with self.assertLogs('imbi.plugins.github', level='WARNING'):
|
|
2240
|
+
state = (
|
|
2241
|
+
await plugin.get_environment_state(
|
|
2242
|
+
_ctx(), _CREDS, ['production']
|
|
2243
|
+
)
|
|
2244
|
+
)[0]
|
|
2245
|
+
self.assertEqual(state.active_resolution, 'error')
|
|
2246
|
+
self.assertIsNone(state.active)
|
|
2247
|
+
assert state.latest is not None
|
|
2248
|
+
self.assertTrue(state.latest.status_unknown)
|
|
2249
|
+
|
|
2250
|
+
@respx.mock
|
|
2251
|
+
async def test_a_success_above_an_unread_status_still_answers(
|
|
2252
|
+
self,
|
|
2253
|
+
) -> None:
|
|
2254
|
+
# The walk stops at the first live success, so rows it never
|
|
2255
|
+
# reached cannot outrank it -- reading them is not required.
|
|
2256
|
+
respx.get(
|
|
2257
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
2258
|
+
params={'environment': 'production', 'per_page': '10'},
|
|
2259
|
+
).mock(
|
|
2260
|
+
return_value=httpx.Response(
|
|
2261
|
+
200,
|
|
2262
|
+
json=[
|
|
2263
|
+
_deployment(1, '2026-05-13T15:00:00Z'),
|
|
2264
|
+
_deployment(2, '2026-05-13T14:00:00Z'),
|
|
2265
|
+
],
|
|
2266
|
+
)
|
|
2267
|
+
)
|
|
2268
|
+
respx.get(
|
|
2269
|
+
'https://api.github.com/repos/octo/demo/deployments/1/statuses'
|
|
2270
|
+
).mock(return_value=httpx.Response(200, json=[{'state': 'success'}]))
|
|
2271
|
+
unread = respx.get(
|
|
2272
|
+
'https://api.github.com/repos/octo/demo/deployments/2/statuses'
|
|
2273
|
+
).mock(return_value=httpx.Response(500))
|
|
2274
|
+
plugin = GitHubDeployment()
|
|
2275
|
+
state = (
|
|
2276
|
+
await plugin.get_environment_state(_ctx(), _CREDS, ['production'])
|
|
2277
|
+
)[0]
|
|
2278
|
+
self.assertEqual(state.active_resolution, 'found')
|
|
2279
|
+
assert state.active is not None
|
|
2280
|
+
self.assertEqual(state.active.external_run_id, '1')
|
|
2281
|
+
self.assertFalse(unread.called)
|
|
2282
|
+
|
|
2283
|
+
@respx.mock
|
|
2284
|
+
async def test_an_unread_row_above_a_success_is_error(self) -> None:
|
|
2285
|
+
# The mirror of the test above, and the case that matters: the
|
|
2286
|
+
# unread row is NEWER than the success. The walk stops at the
|
|
2287
|
+
# first clean success, so anything it could not read sits above
|
|
2288
|
+
# that success and may be the deployment actually serving the
|
|
2289
|
+
# environment. Answering ``found`` here would name the older
|
|
2290
|
+
# release as current and have the host write a stale pointer.
|
|
2291
|
+
respx.get(
|
|
2292
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
2293
|
+
params={'environment': 'production', 'per_page': '10'},
|
|
2294
|
+
).mock(
|
|
2295
|
+
return_value=httpx.Response(
|
|
2296
|
+
200,
|
|
2297
|
+
json=[
|
|
2298
|
+
_deployment(1, '2026-05-13T15:00:00Z'),
|
|
2299
|
+
_deployment(2, '2026-05-13T14:00:00Z'),
|
|
2300
|
+
],
|
|
2301
|
+
)
|
|
2302
|
+
)
|
|
2303
|
+
respx.get(
|
|
2304
|
+
'https://api.github.com/repos/octo/demo/deployments/1/statuses'
|
|
2305
|
+
).mock(
|
|
2306
|
+
return_value=httpx.Response(
|
|
2307
|
+
403, json={'message': 'API rate limit exceeded'}
|
|
2308
|
+
)
|
|
2309
|
+
)
|
|
2310
|
+
respx.get(
|
|
2311
|
+
'https://api.github.com/repos/octo/demo/deployments/2/statuses'
|
|
2312
|
+
).mock(return_value=httpx.Response(200, json=[{'state': 'success'}]))
|
|
2313
|
+
plugin = GitHubDeployment()
|
|
2314
|
+
with self.assertLogs('imbi.plugins.github', level='WARNING'):
|
|
2315
|
+
state = (
|
|
2316
|
+
await plugin.get_environment_state(
|
|
2317
|
+
_ctx(), _CREDS, ['production']
|
|
2318
|
+
)
|
|
2319
|
+
)[0]
|
|
2320
|
+
self.assertEqual(state.active_resolution, 'error')
|
|
2321
|
+
self.assertIsNone(state.active)
|
|
2322
|
+
# ``latest`` still reports the newest attempt, unread status and
|
|
2323
|
+
# all -- it is activity, never currency.
|
|
2324
|
+
assert state.latest is not None
|
|
2325
|
+
self.assertEqual(state.latest.external_run_id, '1')
|
|
2326
|
+
self.assertTrue(state.latest.status_unknown)
|
|
2327
|
+
|
|
2328
|
+
@respx.mock
|
|
2329
|
+
async def test_a_malformed_row_above_a_success_is_error(self) -> None:
|
|
2330
|
+
# A row too malformed to identify is just as unreadable as one
|
|
2331
|
+
# whose status would not load, and it is newer than the success
|
|
2332
|
+
# below it. Skipping it silently would report that success as
|
|
2333
|
+
# active.
|
|
2334
|
+
respx.get(
|
|
2335
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
2336
|
+
params={'environment': 'production', 'per_page': '10'},
|
|
2337
|
+
).mock(
|
|
2338
|
+
return_value=httpx.Response(
|
|
2339
|
+
200,
|
|
2340
|
+
json=[
|
|
2341
|
+
{'id': None, 'sha': None, 'created_at': None},
|
|
2342
|
+
_deployment(2, '2026-05-13T14:00:00Z'),
|
|
2343
|
+
],
|
|
2344
|
+
)
|
|
2345
|
+
)
|
|
2346
|
+
respx.get(
|
|
2347
|
+
'https://api.github.com/repos/octo/demo/deployments/2/statuses'
|
|
2348
|
+
).mock(return_value=httpx.Response(200, json=[{'state': 'success'}]))
|
|
2349
|
+
plugin = GitHubDeployment()
|
|
2350
|
+
with self.assertLogs('imbi.plugins.github', level='WARNING'):
|
|
2351
|
+
state = (
|
|
2352
|
+
await plugin.get_environment_state(
|
|
2353
|
+
_ctx(), _CREDS, ['production']
|
|
2354
|
+
)
|
|
2355
|
+
)[0]
|
|
2356
|
+
self.assertEqual(state.active_resolution, 'error')
|
|
2357
|
+
self.assertIsNone(state.active)
|
|
2358
|
+
# The malformed row cannot be reported at all, so ``latest``
|
|
2359
|
+
# names the readable success below it even though nothing is
|
|
2360
|
+
# claimed active. That pairing is the surprising part of the
|
|
2361
|
+
# contract, so pin it.
|
|
2362
|
+
assert state.latest is not None
|
|
2363
|
+
self.assertEqual(state.latest.external_run_id, '2')
|
|
2364
|
+
|
|
2365
|
+
@respx.mock
|
|
2366
|
+
async def test_an_empty_status_list_is_not_unread(self) -> None:
|
|
2367
|
+
# Read fine, nothing posted yet: that is a real ``pending``, and
|
|
2368
|
+
# an exhausted page of them is a real "nothing deployed".
|
|
2369
|
+
respx.get(
|
|
2370
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
2371
|
+
params={'environment': 'production', 'per_page': '10'},
|
|
2372
|
+
).mock(
|
|
2373
|
+
return_value=httpx.Response(
|
|
2374
|
+
200, json=[_deployment(1, '2026-05-13T15:00:00Z')]
|
|
2375
|
+
)
|
|
2376
|
+
)
|
|
2377
|
+
respx.get(
|
|
2378
|
+
'https://api.github.com/repos/octo/demo/deployments/1/statuses'
|
|
2379
|
+
).mock(return_value=httpx.Response(200, json=[]))
|
|
2380
|
+
plugin = GitHubDeployment()
|
|
2381
|
+
state = (
|
|
2382
|
+
await plugin.get_environment_state(_ctx(), _CREDS, ['production'])
|
|
2383
|
+
)[0]
|
|
2384
|
+
self.assertEqual(state.active_resolution, 'none')
|
|
2385
|
+
assert state.latest is not None
|
|
2386
|
+
self.assertFalse(state.latest.status_unknown)
|
|
2387
|
+
|
|
2388
|
+
|
|
2389
|
+
class ActiveScanLimitTestCase(unittest.TestCase):
|
|
2390
|
+
def test_absent_option_uses_default(self) -> None:
|
|
2391
|
+
self.assertEqual(_active_scan_limit({}), 10)
|
|
2392
|
+
|
|
2393
|
+
def test_string_option_parsed(self) -> None:
|
|
2394
|
+
# The admin form hands scalar options back as strings.
|
|
2395
|
+
self.assertEqual(_active_scan_limit({'active_scan_limit': '25'}), 25)
|
|
2396
|
+
|
|
2397
|
+
def test_invalid_or_out_of_range_values_fall_back(self) -> None:
|
|
2398
|
+
self.assertEqual(_active_scan_limit({'active_scan_limit': 'ten'}), 10)
|
|
2399
|
+
self.assertEqual(_active_scan_limit({'active_scan_limit': 0}), 10)
|
|
2400
|
+
self.assertEqual(_active_scan_limit({'active_scan_limit': True}), 10)
|
|
2401
|
+
# One GitHub page is the ceiling.
|
|
2402
|
+
self.assertEqual(_active_scan_limit({'active_scan_limit': 500}), 100)
|
|
2403
|
+
|
|
2404
|
+
|
|
1923
2405
|
class GetReleaseNotesTestCase(unittest.IsolatedAsyncioTestCase):
|
|
1924
2406
|
@respx.mock
|
|
1925
2407
|
async def test_returns_release_body_for_tag(self) -> None:
|
|
@@ -2158,6 +2640,69 @@ class AuthenticationFailureTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
2158
2640
|
with self.assertRaises(PluginAuthenticationFailed):
|
|
2159
2641
|
await plugin.list_refs(_ctx(), _CREDS, kind='default')
|
|
2160
2642
|
|
|
2643
|
+
@respx.mock
|
|
2644
|
+
async def test_401_on_release_lookup_degrades(self) -> None:
|
|
2645
|
+
# Release notes are enrichment, so a 401 fetching them degrades
|
|
2646
|
+
# to "no notes" instead of propagating -- the same rule
|
|
2647
|
+
# attribution follows. The status read is deliberately NOT
|
|
2648
|
+
# treated this way; see the test below.
|
|
2649
|
+
respx.get(
|
|
2650
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
2651
|
+
params={'environment': 'production', 'per_page': '10'},
|
|
2652
|
+
).mock(
|
|
2653
|
+
return_value=httpx.Response(
|
|
2654
|
+
200,
|
|
2655
|
+
json=[
|
|
2656
|
+
{
|
|
2657
|
+
'id': 1,
|
|
2658
|
+
'sha': 'sha1',
|
|
2659
|
+
'ref': 'v1.2.3',
|
|
2660
|
+
'created_at': '2026-05-13T15:00:00Z',
|
|
2661
|
+
}
|
|
2662
|
+
],
|
|
2663
|
+
)
|
|
2664
|
+
)
|
|
2665
|
+
respx.get(
|
|
2666
|
+
'https://api.github.com/repos/octo/demo/deployments/1/statuses'
|
|
2667
|
+
).mock(return_value=httpx.Response(200, json=[{'state': 'success'}]))
|
|
2668
|
+
respx.get(
|
|
2669
|
+
'https://api.github.com/repos/octo/demo/releases/tags/v1.2.3'
|
|
2670
|
+
).mock(
|
|
2671
|
+
return_value=httpx.Response(401, json={'message': 'token expired'})
|
|
2672
|
+
)
|
|
2673
|
+
plugin = GitHubDeployment()
|
|
2674
|
+
state = (
|
|
2675
|
+
await plugin.get_environment_state(_ctx(), _CREDS, ['production'])
|
|
2676
|
+
)[0]
|
|
2677
|
+
self.assertEqual(state.active_resolution, 'found')
|
|
2678
|
+
assert state.active is not None
|
|
2679
|
+
self.assertEqual(state.active.external_run_id, '1')
|
|
2680
|
+
self.assertIsNone(state.active.release_notes)
|
|
2681
|
+
|
|
2682
|
+
@respx.mock
|
|
2683
|
+
async def test_401_on_status_read_still_propagates(self) -> None:
|
|
2684
|
+
# The counterpart guarantee: a 401 on the status read must reach
|
|
2685
|
+
# the host, because ``call_with_identity_retry`` is what refreshes
|
|
2686
|
+
# the identity and retries the scan. Swallowing it here would
|
|
2687
|
+
# leave every environment resolving ``error`` on an expired token
|
|
2688
|
+
# with no refresh ever attempted.
|
|
2689
|
+
respx.get(
|
|
2690
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
2691
|
+
params={'environment': 'production', 'per_page': '10'},
|
|
2692
|
+
).mock(
|
|
2693
|
+
return_value=httpx.Response(
|
|
2694
|
+
200, json=[_deployment(1, '2026-05-13T15:00:00Z')]
|
|
2695
|
+
)
|
|
2696
|
+
)
|
|
2697
|
+
respx.get(
|
|
2698
|
+
'https://api.github.com/repos/octo/demo/deployments/1/statuses'
|
|
2699
|
+
).mock(
|
|
2700
|
+
return_value=httpx.Response(401, json={'message': 'token expired'})
|
|
2701
|
+
)
|
|
2702
|
+
plugin = GitHubDeployment()
|
|
2703
|
+
with self.assertRaises(PluginAuthenticationFailed):
|
|
2704
|
+
await plugin.get_environment_state(_ctx(), _CREDS, ['production'])
|
|
2705
|
+
|
|
2161
2706
|
@respx.mock
|
|
2162
2707
|
async def test_401_on_deployment_raises_authentication_failed(
|
|
2163
2708
|
self,
|
|
@@ -3131,6 +3676,33 @@ class GitNotesTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
3131
3676
|
)
|
|
3132
3677
|
self.assertTrue(listing.complete)
|
|
3133
3678
|
|
|
3679
|
+
@respx.mock
|
|
3680
|
+
async def test_list_commit_notes_skips_answered_commits(self) -> None:
|
|
3681
|
+
# Enumerating the ref is a call or two; every body is a request
|
|
3682
|
+
# of its own, so a caller repairing a gap must be able to pay
|
|
3683
|
+
# only for what it is missing.
|
|
3684
|
+
other_sha = 'e' * 40
|
|
3685
|
+
self._mock_tree(
|
|
3686
|
+
[
|
|
3687
|
+
{'type': 'blob', 'path': self.FULL_SHA, 'sha': 'b1'},
|
|
3688
|
+
{'type': 'blob', 'path': other_sha, 'sha': 'b2'},
|
|
3689
|
+
]
|
|
3690
|
+
)
|
|
3691
|
+
skipped = respx.get(f'{self.REPO}/git/blobs/b1').mock(
|
|
3692
|
+
return_value=httpx.Response(500)
|
|
3693
|
+
)
|
|
3694
|
+
self._blob('b2', '{"drift_detected":false}')
|
|
3695
|
+
listing = await self.handler.list_commit_notes(
|
|
3696
|
+
_ctx(), _CREDS, 'imbi-drift', skip_shas=[self.FULL_SHA.upper()]
|
|
3697
|
+
)
|
|
3698
|
+
self.assertFalse(skipped.called)
|
|
3699
|
+
self.assertEqual(
|
|
3700
|
+
{other_sha: '{"drift_detected":false}'}, listing.notes
|
|
3701
|
+
)
|
|
3702
|
+
# A note skipped on request is not a note that could not be
|
|
3703
|
+
# read: the caller already holds that answer.
|
|
3704
|
+
self.assertTrue(listing.complete)
|
|
3705
|
+
|
|
3134
3706
|
@respx.mock
|
|
3135
3707
|
async def test_list_commit_notes_reports_an_unreadable_blob(self) -> None:
|
|
3136
3708
|
# The readable note still comes back, but the listing says it is
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/_app_auth.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/lifecycle.py
RENAMED
|
File without changes
|
{imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/pull_requests.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|