imbi-plugin-github 2.17.0__tar.gz → 2.18.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.17.0 → imbi_plugin_github-2.18.0}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/pyproject.toml +2 -2
  3. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/deployment.py +102 -2
  4. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/tests/test_deployment.py +226 -0
  5. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/.gitignore +0 -0
  6. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/README.md +0 -0
  7. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/moon.yml +0 -0
  8. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/README.md +0 -0
  9. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/__init__.py +0 -0
  10. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/_app_auth.py +0 -0
  11. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/_hosts.py +0 -0
  12. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/_repos.py +0 -0
  13. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/commits.py +0 -0
  14. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/doctor.py +0 -0
  15. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/identity.py +0 -0
  16. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/lifecycle.py +0 -0
  17. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/plugin.py +0 -0
  18. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/pull_requests.py +0 -0
  19. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/py.typed +0 -0
  20. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/tests/__init__.py +0 -0
  21. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/tests/test_commits.py +0 -0
  22. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/tests/test_doctor.py +0 -0
  23. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/tests/test_hosts.py +0 -0
  24. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/tests/test_identity.py +0 -0
  25. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/tests/test_lifecycle.py +0 -0
  26. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/tests/test_pull_requests.py +0 -0
  27. {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/tests/test_repos.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: imbi-plugin-github
3
- Version: 2.17.0
3
+ Version: 2.18.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.17.0
15
+ Requires-Dist: imbi-common[databases]==2.18.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.17.0"
3
+ version = "2.18.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.17.0",
21
+ "imbi-common[databases]==2.18.0",
22
22
  "pydantic>=2",
23
23
  "pyjwt[crypto]>=2.8",
24
24
  ]
@@ -26,6 +26,7 @@ import contextlib
26
26
  import datetime
27
27
  import hashlib
28
28
  import logging
29
+ import re
29
30
  import time
30
31
  import typing
31
32
  import urllib.parse
@@ -1002,10 +1003,16 @@ class GitHubDeployment(DeploymentCapability):
1002
1003
  with explicit pagination if it ever needs to.
1003
1004
  """
1004
1005
  page_size = max(1, min(limit, 100))
1006
+ # One run commonly backs several deployments in a sweep; cache
1007
+ # the triggering-actor lookup by run id so we resolve each run
1008
+ # at most once. Shared across the parallel per-env fan-out.
1009
+ run_cache: dict[str, tuple[str, str] | None] = {}
1005
1010
  async with self._client(ctx, credentials) as client:
1006
1011
  per_env = await asyncio.gather(
1007
1012
  *(
1008
- self._list_deployments_for_env(client, env, page_size)
1013
+ self._list_deployments_for_env(
1014
+ client, env, page_size, run_cache
1015
+ )
1009
1016
  for env in environments
1010
1017
  )
1011
1018
  )
@@ -1035,6 +1042,7 @@ class GitHubDeployment(DeploymentCapability):
1035
1042
  client: httpx.AsyncClient,
1036
1043
  environment: str,
1037
1044
  page_size: int,
1045
+ run_cache: dict[str, tuple[str, str] | None],
1038
1046
  ) -> list[RemoteDeployment]:
1039
1047
  try:
1040
1048
  resp = await client.get(
@@ -1067,7 +1075,7 @@ class GitHubDeployment(DeploymentCapability):
1067
1075
  observed: list[RemoteDeployment] = []
1068
1076
  for deployment in deployments:
1069
1077
  run = await self._observe_deployment(
1070
- client, environment, deployment
1078
+ client, environment, deployment, run_cache
1071
1079
  )
1072
1080
  if run is not None:
1073
1081
  observed.append(run)
@@ -1078,6 +1086,7 @@ class GitHubDeployment(DeploymentCapability):
1078
1086
  client: httpx.AsyncClient,
1079
1087
  environment: str,
1080
1088
  deployment: dict[str, typing.Any],
1089
+ run_cache: dict[str, tuple[str, str] | None],
1081
1090
  ) -> RemoteDeployment | None:
1082
1091
  deployment_id = deployment.get('id')
1083
1092
  sha = deployment.get('sha')
@@ -1113,6 +1122,18 @@ class GitHubDeployment(DeploymentCapability):
1113
1122
  creator_id = creator_dict.get('id')
1114
1123
  if isinstance(creator_id, int):
1115
1124
  creator_subject = str(creator_id)
1125
+ # Deployments made by an Actions workflow carry the app bot
1126
+ # as creator (``deployer[bot]``), never the human who
1127
+ # triggered the run. Re-attribute to the run's triggering
1128
+ # actor so the host can resolve the deploy to a real user.
1129
+ if _is_bot(creator_dict):
1130
+ run_id = _run_id_from_status_url(status_url)
1131
+ if run_id is not None:
1132
+ actor = await self._resolve_triggering_actor(
1133
+ client, run_id, run_cache
1134
+ )
1135
+ if actor is not None:
1136
+ creator_login, creator_subject = actor
1116
1137
  return RemoteDeployment(
1117
1138
  environment=environment,
1118
1139
  sha=str(sha),
@@ -1128,6 +1149,53 @@ class GitHubDeployment(DeploymentCapability):
1128
1149
  creator_subject=creator_subject,
1129
1150
  )
1130
1151
 
1152
+ async def _resolve_triggering_actor(
1153
+ self,
1154
+ client: httpx.AsyncClient,
1155
+ run_id: str,
1156
+ run_cache: dict[str, tuple[str, str] | None],
1157
+ ) -> tuple[str, str] | None:
1158
+ """Resolve a workflow run's human trigger to ``(login, subject)``.
1159
+
1160
+ Attributes a bot-created deployment to the person who started
1161
+ the Actions run via ``GET /actions/runs/{run_id}``, preferring
1162
+ ``triggering_actor`` (the account that re-ran or dispatched the
1163
+ run) and falling back to ``actor``. Best-effort: a fetch/parse
1164
+ error, a missing actor, or an actor that is itself a bot yields
1165
+ ``None`` so the caller keeps the bot creator -- attribution must
1166
+ never fail the resync. Results (including ``None``) are cached
1167
+ per sweep because one run backs several deployments.
1168
+ """
1169
+ if run_id in run_cache:
1170
+ return run_cache[run_id]
1171
+ result: tuple[str, str] | None = None
1172
+ try:
1173
+ resp = await client.get(f'/actions/runs/{run_id}')
1174
+ resp.raise_for_status()
1175
+ run = typing.cast(dict[str, typing.Any], resp.json())
1176
+ except (httpx.HTTPError, PluginAuthenticationFailed, ValueError):
1177
+ LOGGER.warning(
1178
+ 'Failed to fetch workflow run %s for deploy attribution',
1179
+ run_id,
1180
+ exc_info=True,
1181
+ )
1182
+ run_cache[run_id] = None
1183
+ return None
1184
+ actor_raw = run.get('triggering_actor') or run.get('actor')
1185
+ if isinstance(actor_raw, dict):
1186
+ actor = typing.cast(dict[str, typing.Any], actor_raw)
1187
+ login = actor.get('login')
1188
+ actor_id = actor.get('id')
1189
+ if (
1190
+ isinstance(login, str)
1191
+ and login
1192
+ and isinstance(actor_id, int)
1193
+ and not _is_bot(actor)
1194
+ ):
1195
+ result = (login, str(actor_id))
1196
+ run_cache[run_id] = result
1197
+ return result
1198
+
1131
1199
  async def _release_notes_for_ref(
1132
1200
  self, client: httpx.AsyncClient, ref: str
1133
1201
  ) -> str | None:
@@ -1195,6 +1263,38 @@ class GitHubDeployment(DeploymentCapability):
1195
1263
  return _to_event_status(state), str(log_url) if log_url else None
1196
1264
 
1197
1265
 
1266
+ _RUN_ID_RE = re.compile(r'/actions/runs/(\d+)')
1267
+
1268
+
1269
+ def _is_bot(user: dict[str, typing.Any]) -> bool:
1270
+ """Return ``True`` when a GitHub user object denotes an app/bot.
1271
+
1272
+ GitHub marks app identities with ``type == 'Bot'`` on the user
1273
+ object and gives them a ``login`` suffixed ``[bot]``
1274
+ (``github-actions[bot]``, ``deployer[bot]``). Either signal alone is
1275
+ sufficient; both comparisons are case-insensitive.
1276
+ """
1277
+ if str(user.get('type') or '').lower() == 'bot':
1278
+ return True
1279
+ login = user.get('login')
1280
+ return isinstance(login, str) and login.lower().endswith('[bot]')
1281
+
1282
+
1283
+ def _run_id_from_status_url(url: str | None) -> str | None:
1284
+ """Extract the Actions run id from a deployment status URL.
1285
+
1286
+ A GitHub Actions deploy posts its status ``log_url``/``target_url``
1287
+ pointing at the workflow run
1288
+ (``.../actions/runs/{run_id}`` optionally followed by
1289
+ ``/job/{job_id}``). Any URL that is not an Actions run link yields
1290
+ ``None``.
1291
+ """
1292
+ if not url:
1293
+ return None
1294
+ match = _RUN_ID_RE.search(url)
1295
+ return match.group(1) if match else None
1296
+
1297
+
1198
1298
  def _to_event_status(github_state: str) -> DeploymentEventStatus:
1199
1299
  """Map a GitHub deployment-status ``state`` to the host vocabulary.
1200
1300
 
@@ -1297,6 +1297,232 @@ class ListRecentDeploymentsTestCase(unittest.IsolatedAsyncioTestCase):
1297
1297
  self.assertTrue(all(e.release_notes is None for e in events))
1298
1298
  self.assertEqual(first.call_count, 1)
1299
1299
 
1300
+ @respx.mock
1301
+ async def test_bot_creator_attributed_to_triggering_actor(self) -> None:
1302
+ # A workflow-created deployment lists the app bot as creator; the
1303
+ # status URL points at the Actions run, so attribution follows to
1304
+ # the run's triggering actor (the human who started it).
1305
+ respx.get('https://api.github.com/repos/octo/demo/deployments').mock(
1306
+ return_value=httpx.Response(
1307
+ 200,
1308
+ json=[
1309
+ {
1310
+ 'id': 123,
1311
+ 'sha': 'botsha',
1312
+ 'ref': 'main',
1313
+ 'created_at': '2026-05-13T14:00:00Z',
1314
+ 'creator': {
1315
+ 'login': 'deployer[bot]',
1316
+ 'id': 111,
1317
+ 'type': 'Bot',
1318
+ },
1319
+ }
1320
+ ],
1321
+ )
1322
+ )
1323
+ respx.get(
1324
+ 'https://api.github.com/repos/octo/demo/deployments/123/statuses'
1325
+ ).mock(
1326
+ return_value=httpx.Response(
1327
+ 200,
1328
+ json=[
1329
+ {
1330
+ 'state': 'success',
1331
+ 'log_url': (
1332
+ 'https://github.com/octo/demo'
1333
+ '/actions/runs/9001/job/55'
1334
+ ),
1335
+ 'created_at': '2026-05-13T14:01:00Z',
1336
+ }
1337
+ ],
1338
+ )
1339
+ )
1340
+ run = respx.get(
1341
+ 'https://api.github.com/repos/octo/demo/actions/runs/9001'
1342
+ ).mock(
1343
+ return_value=httpx.Response(
1344
+ 200,
1345
+ json={
1346
+ 'triggering_actor': {'login': 'octocat', 'id': 583231},
1347
+ 'actor': {'login': 'someone-else', 'id': 42},
1348
+ },
1349
+ )
1350
+ )
1351
+ respx.get(
1352
+ 'https://api.github.com/repos/octo/demo/releases/tags/main'
1353
+ ).mock(return_value=httpx.Response(404, json={'message': 'Not Found'}))
1354
+ plugin = GitHubDeployment()
1355
+ events = await plugin.list_recent_deployments(
1356
+ _ctx(), _CREDS, ['production']
1357
+ )
1358
+ self.assertEqual(len(events), 1)
1359
+ self.assertTrue(run.called)
1360
+ self.assertEqual(events[0].creator, 'octocat')
1361
+ self.assertEqual(events[0].creator_subject, '583231')
1362
+
1363
+ @respx.mock
1364
+ async def test_bot_detection_is_case_insensitive(self) -> None:
1365
+ # Bot detection must not depend on GitHub's casing: a creator
1366
+ # with type 'bot' and a mixed-case '[Bot]' login suffix is still
1367
+ # re-attributed to the run's triggering actor.
1368
+ respx.get('https://api.github.com/repos/octo/demo/deployments').mock(
1369
+ return_value=httpx.Response(
1370
+ 200,
1371
+ json=[
1372
+ {
1373
+ 'id': 123,
1374
+ 'sha': 'botsha',
1375
+ 'ref': 'main',
1376
+ 'created_at': '2026-05-13T14:00:00Z',
1377
+ 'creator': {
1378
+ 'login': 'Deployer[Bot]',
1379
+ 'id': 111,
1380
+ 'type': 'bot',
1381
+ },
1382
+ }
1383
+ ],
1384
+ )
1385
+ )
1386
+ respx.get(
1387
+ 'https://api.github.com/repos/octo/demo/deployments/123/statuses'
1388
+ ).mock(
1389
+ return_value=httpx.Response(
1390
+ 200,
1391
+ json=[
1392
+ {
1393
+ 'state': 'success',
1394
+ 'log_url': (
1395
+ 'https://github.com/octo/demo'
1396
+ '/actions/runs/9001/job/55'
1397
+ ),
1398
+ 'created_at': '2026-05-13T14:01:00Z',
1399
+ }
1400
+ ],
1401
+ )
1402
+ )
1403
+ run = respx.get(
1404
+ 'https://api.github.com/repos/octo/demo/actions/runs/9001'
1405
+ ).mock(
1406
+ return_value=httpx.Response(
1407
+ 200,
1408
+ json={
1409
+ 'triggering_actor': {'login': 'octocat', 'id': 583231},
1410
+ },
1411
+ )
1412
+ )
1413
+ respx.get(
1414
+ 'https://api.github.com/repos/octo/demo/releases/tags/main'
1415
+ ).mock(return_value=httpx.Response(404, json={'message': 'Not Found'}))
1416
+ plugin = GitHubDeployment()
1417
+ events = await plugin.list_recent_deployments(
1418
+ _ctx(), _CREDS, ['production']
1419
+ )
1420
+ self.assertEqual(len(events), 1)
1421
+ self.assertTrue(run.called)
1422
+ self.assertEqual(events[0].creator, 'octocat')
1423
+ self.assertEqual(events[0].creator_subject, '583231')
1424
+
1425
+ @respx.mock
1426
+ async def test_bot_creator_kept_when_run_fetch_fails(self) -> None:
1427
+ # The run lookup 500s; attribution degrades gracefully to the bot
1428
+ # creator rather than raising and failing the resync.
1429
+ respx.get('https://api.github.com/repos/octo/demo/deployments').mock(
1430
+ return_value=httpx.Response(
1431
+ 200,
1432
+ json=[
1433
+ {
1434
+ 'id': 123,
1435
+ 'sha': 'botsha',
1436
+ 'ref': 'main',
1437
+ 'created_at': '2026-05-13T14:00:00Z',
1438
+ 'creator': {
1439
+ 'login': 'github-actions[bot]',
1440
+ 'id': 111,
1441
+ },
1442
+ }
1443
+ ],
1444
+ )
1445
+ )
1446
+ respx.get(
1447
+ 'https://api.github.com/repos/octo/demo/deployments/123/statuses'
1448
+ ).mock(
1449
+ return_value=httpx.Response(
1450
+ 200,
1451
+ json=[
1452
+ {
1453
+ 'state': 'success',
1454
+ 'target_url': (
1455
+ 'https://github.com/octo/demo/actions/runs/9001'
1456
+ ),
1457
+ 'created_at': '2026-05-13T14:01:00Z',
1458
+ }
1459
+ ],
1460
+ )
1461
+ )
1462
+ run = respx.get(
1463
+ 'https://api.github.com/repos/octo/demo/actions/runs/9001'
1464
+ ).mock(return_value=httpx.Response(500, json={'message': 'oops'}))
1465
+ respx.get(
1466
+ 'https://api.github.com/repos/octo/demo/releases/tags/main'
1467
+ ).mock(return_value=httpx.Response(404, json={'message': 'Not Found'}))
1468
+ plugin = GitHubDeployment()
1469
+ events = await plugin.list_recent_deployments(
1470
+ _ctx(), _CREDS, ['production']
1471
+ )
1472
+ self.assertEqual(len(events), 1)
1473
+ self.assertTrue(run.called)
1474
+ self.assertEqual(events[0].creator, 'github-actions[bot]')
1475
+ self.assertEqual(events[0].creator_subject, '111')
1476
+
1477
+ @respx.mock
1478
+ async def test_human_creator_skips_run_lookup(self) -> None:
1479
+ # A human-created deployment must never issue the extra run
1480
+ # fetch; the creator is used as-is.
1481
+ respx.get('https://api.github.com/repos/octo/demo/deployments').mock(
1482
+ return_value=httpx.Response(
1483
+ 200,
1484
+ json=[
1485
+ {
1486
+ 'id': 123,
1487
+ 'sha': 'humansha',
1488
+ 'ref': 'main',
1489
+ 'created_at': '2026-05-13T14:00:00Z',
1490
+ 'creator': {'login': 'octocat', 'id': 583231},
1491
+ }
1492
+ ],
1493
+ )
1494
+ )
1495
+ respx.get(
1496
+ 'https://api.github.com/repos/octo/demo/deployments/123/statuses'
1497
+ ).mock(
1498
+ return_value=httpx.Response(
1499
+ 200,
1500
+ json=[
1501
+ {
1502
+ 'state': 'success',
1503
+ 'log_url': (
1504
+ 'https://github.com/octo/demo/actions/runs/9001'
1505
+ ),
1506
+ 'created_at': '2026-05-13T14:01:00Z',
1507
+ }
1508
+ ],
1509
+ )
1510
+ )
1511
+ run = respx.get(
1512
+ 'https://api.github.com/repos/octo/demo/actions/runs/9001'
1513
+ ).mock(return_value=httpx.Response(200, json={}))
1514
+ respx.get(
1515
+ 'https://api.github.com/repos/octo/demo/releases/tags/main'
1516
+ ).mock(return_value=httpx.Response(404, json={'message': 'Not Found'}))
1517
+ plugin = GitHubDeployment()
1518
+ events = await plugin.list_recent_deployments(
1519
+ _ctx(), _CREDS, ['production']
1520
+ )
1521
+ self.assertEqual(len(events), 1)
1522
+ self.assertFalse(run.called)
1523
+ self.assertEqual(events[0].creator, 'octocat')
1524
+ self.assertEqual(events[0].creator_subject, '583231')
1525
+
1300
1526
 
1301
1527
  class GetReleaseNotesTestCase(unittest.IsolatedAsyncioTestCase):
1302
1528
  @respx.mock