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.
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/PKG-INFO +2 -2
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/pyproject.toml +2 -2
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/deployment.py +102 -2
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/tests/test_deployment.py +226 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/.gitignore +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/README.md +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/moon.yml +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/README.md +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/__init__.py +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/_app_auth.py +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/_hosts.py +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/_repos.py +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/commits.py +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/doctor.py +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/identity.py +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/lifecycle.py +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/plugin.py +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/pull_requests.py +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/py.typed +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/tests/test_commits.py +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/tests/test_doctor.py +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/tests/test_pull_requests.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
21
|
+
"imbi-common[databases]==2.18.0",
|
|
22
22
|
"pydantic>=2",
|
|
23
23
|
"pyjwt[crypto]>=2.8",
|
|
24
24
|
]
|
{imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/deployment.py
RENAMED
|
@@ -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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.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.17.0 → imbi_plugin_github-2.18.0}/src/imbi/plugins/github/lifecycle.py
RENAMED
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.17.0 → imbi_plugin_github-2.18.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
|