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.
Files changed (27) hide show
  1. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/pyproject.toml +2 -2
  3. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/deployment.py +354 -19
  4. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/plugin.py +18 -0
  5. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/tests/test_deployment.py +572 -0
  6. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/.gitignore +0 -0
  7. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/README.md +0 -0
  8. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/moon.yml +0 -0
  9. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/README.md +0 -0
  10. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/__init__.py +0 -0
  11. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/_app_auth.py +0 -0
  12. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/_hosts.py +0 -0
  13. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/_repos.py +0 -0
  14. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/commits.py +0 -0
  15. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/doctor.py +0 -0
  16. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/identity.py +0 -0
  17. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/lifecycle.py +0 -0
  18. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/pull_requests.py +0 -0
  19. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/py.typed +0 -0
  20. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/tests/__init__.py +0 -0
  21. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/tests/test_commits.py +0 -0
  22. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/tests/test_doctor.py +0 -0
  23. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/tests/test_hosts.py +0 -0
  24. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/tests/test_identity.py +0 -0
  25. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/tests/test_lifecycle.py +0 -0
  26. {imbi_plugin_github-2.28.0 → imbi_plugin_github-2.29.0}/tests/test_pull_requests.py +0 -0
  27. {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.28.0
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.28.0
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.28.0"
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.28.0",
21
+ "imbi-common[databases]==2.29.0",
22
22
  "pydantic>=2",
23
23
  "pyjwt[crypto]>=2.8",
24
24
  ]
@@ -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 an empty, complete listing.
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, client: httpx.AsyncClient, commit_sha: str
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
- items = list(notes.items())
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(notes))
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
- status, status_url = await self._latest_status(
1725
- client, str(deployment_id)
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
- ) -> tuple[DeploymentEventStatus, str | None]:
1942
- """Return the canonical event status + workflow log URL.
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 'pending', None
2288
+ return _StatusRead(
2289
+ 'pending', None, superseded=False, unreadable=True
2290
+ )
1979
2291
  if resp.status_code != 200:
1980
- return 'pending', None
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 'pending', None
2307
+ return _StatusRead(
2308
+ 'pending', None, superseded=False, unreadable=True
2309
+ )
1985
2310
  if not statuses:
1986
- return 'pending', None
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 _to_event_status(state), str(log_url) if log_url else None
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