imbi-plugin-github 2.26.2__tar.gz → 2.28.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.26.2 → imbi_plugin_github-2.28.0}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/pyproject.toml +2 -2
  3. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/deployment.py +85 -16
  4. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/tests/test_deployment.py +102 -5
  5. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/.gitignore +0 -0
  6. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/README.md +0 -0
  7. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/moon.yml +0 -0
  8. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/README.md +0 -0
  9. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/__init__.py +0 -0
  10. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/_app_auth.py +0 -0
  11. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/_hosts.py +0 -0
  12. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/_repos.py +0 -0
  13. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/commits.py +0 -0
  14. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/doctor.py +0 -0
  15. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/identity.py +0 -0
  16. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/lifecycle.py +0 -0
  17. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/plugin.py +0 -0
  18. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/pull_requests.py +0 -0
  19. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/py.typed +0 -0
  20. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/tests/__init__.py +0 -0
  21. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/tests/test_commits.py +0 -0
  22. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/tests/test_doctor.py +0 -0
  23. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/tests/test_hosts.py +0 -0
  24. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/tests/test_identity.py +0 -0
  25. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/tests/test_lifecycle.py +0 -0
  26. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/tests/test_pull_requests.py +0 -0
  27. {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.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.26.2
3
+ Version: 2.28.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.26.2
15
+ Requires-Dist: imbi-common[databases]==2.28.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.26.2"
3
+ version = "2.28.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.26.2",
21
+ "imbi-common[databases]==2.28.0",
22
22
  "pydantic>=2",
23
23
  "pyjwt[crypto]>=2.8",
24
24
  ]
@@ -1486,23 +1486,68 @@ class GitHubDeployment(DeploymentCapability):
1486
1486
 
1487
1487
  ``run_id`` is the GitHub deployment id returned by
1488
1488
  :meth:`trigger_deployment`. GitHub returns status updates
1489
- newest-first; the latest entry wins. An empty list means the
1490
- deploy workflow hasn't posted anything yet, which Imbi surfaces
1491
- as ``'queued'``.
1492
-
1493
- ``log_url`` (and the legacy ``target_url``) on the latest status
1494
- is what the deploy workflow set to point at its own logs (e.g.
1495
- the Actions run URL). We carry that as ``run_url`` so the UI
1496
- can deep-link without having to walk back to the workflow run
1489
+ newest-first; the newest entry that describes this deployment's
1490
+ own lifecycle wins. An empty list means the deploy workflow
1491
+ hasn't posted anything yet, which Imbi surfaces as ``'queued'``.
1492
+
1493
+ ``inactive`` entries are skipped rather than read as the answer.
1494
+ Every other state is something this deployment's own run
1495
+ reported about itself; ``inactive`` is written by GitHub *on
1496
+ behalf of a later deployment* when that one supersedes this one.
1497
+ Treating it as the outcome is wrong twice over: it relabels a
1498
+ rollout that succeeded (the success is still in the list, one
1499
+ entry further down), and its ``updated_at`` is the moment the
1500
+ successor went live, so a caller stamping the close-out with
1501
+ ``completed_at`` would date this deployment *after* the one that
1502
+ replaced it. That is what taught a production environment it
1503
+ was running a release eleven days stale.
1504
+
1505
+ Note that ``inactive`` reaches Imbi only by polling -- here and
1506
+ in :meth:`_latest_status`. GitHub creates the status but emits no
1507
+ ``deployment_status`` webhook for it, which its own docs state
1508
+ outright: "A webhook event is not fired for deployment statuses
1509
+ with an inactive state." Confirmed 2026-08-21 against a repo
1510
+ hook subscribed to ``*``, which saw the successor's ``success``
1511
+ and nothing for the auto-inactive written in the same second.
1512
+ So a poll is the only place the state can be handled correctly,
1513
+ and there is no upstream fix to wait for.
1514
+
1515
+ Suppressing the state at source is not the alternative it looks
1516
+ like: ``auto_inactive`` is documented as affecting only
1517
+ "non-transient, non-production" deployments, yet the deployments
1518
+ observed here carry ``production_environment=true`` and were
1519
+ auto-inactivated anyway. The documented carve-out does not
1520
+ describe them, so setting ``auto_inactive=false`` would need an
1521
+ experiment rather than a reading.
1522
+
1523
+ A deployment superseded while still in flight has no terminal
1524
+ entry left once ``inactive`` is skipped, so it reads as whatever
1525
+ it last genuinely reported and the sweeper expires it on age --
1526
+ honest, because it never did finish.
1527
+
1528
+ ``log_url`` (and the legacy ``target_url``) on the selected
1529
+ status is what the deploy workflow set to point at its own logs
1530
+ (e.g. the Actions run URL). We carry that as ``run_url`` so the
1531
+ UI can deep-link without having to walk back to the workflow run
1497
1532
  through a check-suite join.
1498
1533
  """
1499
1534
  async with self._client(ctx, credentials) as client:
1500
1535
  resp = await client.get(f'/deployments/{run_id}/statuses')
1501
1536
  resp.raise_for_status()
1502
1537
  statuses = typing.cast(list[dict[str, typing.Any]], resp.json())
1503
- if not statuses:
1538
+ latest = next(
1539
+ (
1540
+ entry
1541
+ for entry in statuses
1542
+ if str(entry.get('state') or '').lower() != 'inactive'
1543
+ ),
1544
+ None,
1545
+ )
1546
+ if latest is None:
1547
+ # Either nothing posted yet, or every entry is an
1548
+ # auto-inactive -- both mean this deployment has told us
1549
+ # nothing about itself.
1504
1550
  return DeploymentRun(run_id=str(run_id), status='queued')
1505
- latest = statuses[0]
1506
1551
  state = str(latest.get('state') or '').lower()
1507
1552
  status: typing.Literal[
1508
1553
  'queued',
@@ -1520,10 +1565,6 @@ class GitHubDeployment(DeploymentCapability):
1520
1565
  status = 'success'
1521
1566
  elif state in {'failure', 'error'}:
1522
1567
  status = 'failure'
1523
- elif state == 'inactive':
1524
- # Deployment was superseded by a newer one for the same
1525
- # env — Imbi treats that as cancelled rather than failed.
1526
- status = 'cancelled'
1527
1568
  else:
1528
1569
  status = 'unknown'
1529
1570
  log_url = latest.get('log_url') or latest.get('target_url')
@@ -1906,11 +1947,32 @@ class GitHubDeployment(DeploymentCapability):
1906
1947
  not started, and ``pending`` is the host's vocabulary for
1907
1948
  both. Network / parse errors degrade the same way so resync
1908
1949
  is never blocked by a single noisy row.
1950
+
1951
+ An ``inactive`` entry is looked *past* when something sits
1952
+ beneath it. GitHub writes one on a deployment when a later one
1953
+ supersedes it, so it reports the successor's arrival rather than
1954
+ this deployment's outcome -- and every deployment except an
1955
+ environment's newest carries one. Reading it verbatim therefore
1956
+ relabelled whole deployment histories as ``rolled_back``, each
1957
+ node's ``history`` showing the ``success`` it overwrote; ~14k
1958
+ nodes in the production graph were in that state before this
1959
+ skip existed.
1960
+
1961
+ An ``inactive`` with nothing beneath it still reports
1962
+ ``rolled_back``: that deployment was superseded without ever
1963
+ reporting on itself, and ``rolled_back`` is the accurate
1964
+ terminal for it. The distinction is the whole point -- skip the
1965
+ retirement notice when it is hiding a real outcome, keep it when
1966
+ it is the only thing we know.
1967
+
1968
+ This is why the page size is not 1. A single-status window
1969
+ cannot see past an ``inactive`` to the ``success`` underneath,
1970
+ which is what made the misread unavoidable.
1909
1971
  """
1910
1972
  try:
1911
1973
  resp = await client.get(
1912
1974
  f'/deployments/{deployment_id}/statuses',
1913
- params={'per_page': '1'},
1975
+ params={'per_page': '10'},
1914
1976
  )
1915
1977
  except httpx.HTTPError:
1916
1978
  return 'pending', None
@@ -1922,7 +1984,14 @@ class GitHubDeployment(DeploymentCapability):
1922
1984
  return 'pending', None
1923
1985
  if not statuses:
1924
1986
  return 'pending', None
1925
- latest = statuses[0]
1987
+ latest = next(
1988
+ (
1989
+ entry
1990
+ for entry in statuses
1991
+ if str(entry.get('state') or '').lower() != 'inactive'
1992
+ ),
1993
+ statuses[0],
1994
+ )
1926
1995
  state = str(latest.get('state') or '').lower()
1927
1996
  log_url = latest.get('log_url') or latest.get('target_url')
1928
1997
  return _to_event_status(state), str(log_url) if log_url else None
@@ -905,20 +905,60 @@ class GetDeploymentStatusTestCase(unittest.IsolatedAsyncioTestCase):
905
905
  self.assertEqual(run.status, 'failure')
906
906
 
907
907
  @respx.mock
908
- async def test_status_inactive_maps_to_cancelled(self) -> None:
909
- # ``inactive`` means a newer deployment for the same env
910
- # superseded this one — Imbi treats it as cancelled, not failed.
908
+ async def test_status_skips_inactive_to_the_real_outcome(self) -> None:
909
+ """``inactive`` hides the success it was written on top of.
910
+
911
+ GitHub stamps ``inactive`` on a deployment when a *later* one
912
+ supersedes it, so it is newest-first but describes the wrong
913
+ rollout. Reading it as the answer relabelled a succeeded
914
+ deployment as cancelled -> failed, and -- worse -- carried the
915
+ successor's ``updated_at`` as this deployment's completion, so
916
+ the close-out sorted after the release that replaced it.
917
+ """
911
918
  respx.get(
912
919
  'https://api.github.com/repos/octo/demo/deployments/42/statuses'
913
920
  ).mock(
914
921
  return_value=httpx.Response(
915
922
  200,
916
- json=[{'state': 'inactive'}],
923
+ json=[
924
+ {
925
+ 'state': 'inactive',
926
+ 'created_at': '2026-08-14T19:51:26Z',
927
+ 'updated_at': '2026-08-14T19:51:26Z',
928
+ 'log_url': 'https://gh/runs/stale',
929
+ },
930
+ {
931
+ 'state': 'success',
932
+ 'created_at': '2026-08-03T19:03:32Z',
933
+ 'updated_at': '2026-08-03T19:03:32Z',
934
+ 'log_url': 'https://gh/runs/real',
935
+ },
936
+ {'state': 'in_progress'},
937
+ ],
917
938
  )
918
939
  )
919
940
  plugin = GitHubDeployment()
920
941
  run = await plugin.get_deployment_status(_ctx(), _CREDS, '42')
921
- self.assertEqual(run.status, 'cancelled')
942
+ self.assertEqual(run.status, 'success')
943
+ self.assertEqual(run.run_url, 'https://gh/runs/real')
944
+ assert run.completed_at is not None
945
+ self.assertEqual(
946
+ datetime.datetime(2026, 8, 3, 19, 3, 32, tzinfo=datetime.UTC),
947
+ run.completed_at,
948
+ )
949
+
950
+ @respx.mock
951
+ async def test_status_only_inactive_reads_as_queued(self) -> None:
952
+ # Superseded without ever reporting on itself: there is no
953
+ # outcome to read, so it stays unresolved and the sweeper
954
+ # expires it on age rather than inventing a terminal status.
955
+ respx.get(
956
+ 'https://api.github.com/repos/octo/demo/deployments/42/statuses'
957
+ ).mock(return_value=httpx.Response(200, json=[{'state': 'inactive'}]))
958
+ plugin = GitHubDeployment()
959
+ run = await plugin.get_deployment_status(_ctx(), _CREDS, '42')
960
+ self.assertEqual(run.status, 'queued')
961
+ self.assertIsNone(run.completed_at)
922
962
 
923
963
 
924
964
  class ListRecentDeploymentsTestCase(unittest.IsolatedAsyncioTestCase):
@@ -975,6 +1015,63 @@ class ListRecentDeploymentsTestCase(unittest.IsolatedAsyncioTestCase):
975
1015
  self.assertEqual(len(events), 1)
976
1016
  self.assertEqual(events[0].sha, 'appsha')
977
1017
 
1018
+ @respx.mock
1019
+ async def test_resync_skips_inactive_to_the_real_outcome(self) -> None:
1020
+ """Resync must not relabel deployment history as rolled_back.
1021
+
1022
+ Every deployment except an environment's newest carries an
1023
+ ``inactive`` status, written when its successor went live.
1024
+ Reading the newest entry verbatim turned each of them into
1025
+ ``rolled_back``, overwriting the ``success`` directly beneath
1026
+ it -- ~14k nodes in the production graph, whose ``history``
1027
+ records the success-then-rolled_back flip.
1028
+ """
1029
+ respx.get(
1030
+ 'https://api.github.com/repos/octo/demo/deployments',
1031
+ params={'environment': 'production', 'per_page': '1'},
1032
+ ).mock(
1033
+ return_value=httpx.Response(
1034
+ 200,
1035
+ json=[
1036
+ {
1037
+ 'id': 55,
1038
+ 'sha': 'deadbeef',
1039
+ 'ref': 'main',
1040
+ 'created_at': '2026-05-13T14:00:00Z',
1041
+ }
1042
+ ],
1043
+ )
1044
+ )
1045
+ respx.get(
1046
+ 'https://api.github.com/repos/octo/demo/deployments/55/statuses'
1047
+ ).mock(
1048
+ return_value=httpx.Response(
1049
+ 200,
1050
+ json=[
1051
+ {
1052
+ 'state': 'inactive',
1053
+ 'created_at': '2026-05-20T09:00:00Z',
1054
+ 'log_url': 'https://gh/runs/successor',
1055
+ },
1056
+ {
1057
+ 'state': 'success',
1058
+ 'created_at': '2026-05-13T14:01:00Z',
1059
+ 'log_url': 'https://gh/runs/real',
1060
+ },
1061
+ ],
1062
+ )
1063
+ )
1064
+ respx.get(
1065
+ 'https://api.github.com/repos/octo/demo/releases/tags/main'
1066
+ ).mock(return_value=httpx.Response(404, json={'message': 'Not Found'}))
1067
+ plugin = GitHubDeployment()
1068
+ events = await plugin.list_recent_deployments(
1069
+ _ctx(), _CREDS, ['production']
1070
+ )
1071
+ self.assertEqual(len(events), 1)
1072
+ self.assertEqual(events[0].status, 'success')
1073
+ self.assertEqual(events[0].run_url, 'https://gh/runs/real')
1074
+
978
1075
  @respx.mock
979
1076
  async def test_one_env_one_deployment_success(self) -> None:
980
1077
  respx.get(