imbi-plugin-github 2.23.0__tar.gz → 2.25.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.23.0 → imbi_plugin_github-2.25.0}/PKG-INFO +2 -2
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/pyproject.toml +2 -2
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/deployment.py +122 -16
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/plugin.py +16 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/tests/test_deployment.py +295 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/.gitignore +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/README.md +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/moon.yml +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/README.md +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/__init__.py +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/_app_auth.py +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/_hosts.py +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/_repos.py +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/commits.py +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/doctor.py +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/identity.py +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/lifecycle.py +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/pull_requests.py +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/py.typed +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/tests/test_commits.py +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/tests/test_doctor.py +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/tests/test_pull_requests.py +0 -0
- {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.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.25.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.25.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.25.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.25.0",
|
|
22
22
|
"pydantic>=2",
|
|
23
23
|
"pyjwt[crypto]>=2.8",
|
|
24
24
|
]
|
{imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/deployment.py
RENAMED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
:class:`GitHubDeployment` resolves its host from the Integration's
|
|
4
4
|
``flavor`` + ``host`` options on ``ctx.integration_options`` (github.com,
|
|
5
|
-
a ``*.ghe.com`` GHEC tenant, or an operator-managed GHES appliance)
|
|
5
|
+
a ``*.ghe.com`` GHEC tenant, or an operator-managed GHES appliance), and
|
|
6
|
+
reads ``mainline_branches`` from the same map to know which deployment
|
|
7
|
+
refs are branches rather than release tags (see
|
|
8
|
+
:func:`_mainline_branches`).
|
|
6
9
|
|
|
7
10
|
*Deploying* drives the GitHub Deployments API
|
|
8
11
|
(``POST /repos/{owner}/{repo}/deployments``) rather than
|
|
@@ -371,6 +374,40 @@ def _record_releases_forbidden(client: httpx.AsyncClient) -> None:
|
|
|
371
374
|
_RELEASES_FORBIDDEN_TOKENS[_releases_cache_key(client)] = now
|
|
372
375
|
|
|
373
376
|
|
|
377
|
+
# Fallback for the ``mainline_branches`` integration option: deployment
|
|
378
|
+
# refs that are branch names rather than release tags. A repo that deploys
|
|
379
|
+
# off its default branch reports ``ref == 'main'`` on every deployment, so
|
|
380
|
+
# ``GET /releases/tags/main`` is a guaranteed 404 — skip the request
|
|
381
|
+
# outright instead of paying for it once per deployment row.
|
|
382
|
+
_DEFAULT_MAINLINE_BRANCHES = frozenset({'main', 'master'})
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _mainline_branches(
|
|
386
|
+
integration_options: dict[str, typing.Any],
|
|
387
|
+
) -> frozenset[str]:
|
|
388
|
+
"""Resolve the ``mainline_branches`` integration option to a set.
|
|
389
|
+
|
|
390
|
+
Declared integration-level (beside ``flavor`` / ``host``) because
|
|
391
|
+
mainline branch naming is a property of the org's repos rather than of
|
|
392
|
+
any one capability, so every capability can read the same value. The
|
|
393
|
+
manifest's ``default`` is a form pre-fill only -- the host does not
|
|
394
|
+
substitute it -- so an absent or blank value resolves to
|
|
395
|
+
:data:`_DEFAULT_MAINLINE_BRANCHES` here, mirroring how
|
|
396
|
+
``artifact_version_input`` re-applies its own default. Consequently
|
|
397
|
+
the guard can be *retargeted* but not switched off; a repo that cuts
|
|
398
|
+
releases tagged with a branch name is not a case worth supporting.
|
|
399
|
+
|
|
400
|
+
Operator-entered, so tolerate commas as well as the advertised spaces
|
|
401
|
+
and discard surrounding whitespace -- a stray separator would
|
|
402
|
+
otherwise register as a branch named ``''``.
|
|
403
|
+
"""
|
|
404
|
+
raw = integration_options.get('mainline_branches')
|
|
405
|
+
if not isinstance(raw, str):
|
|
406
|
+
return _DEFAULT_MAINLINE_BRANCHES
|
|
407
|
+
configured = frozenset(raw.replace(',', ' ').split())
|
|
408
|
+
return configured or _DEFAULT_MAINLINE_BRANCHES
|
|
409
|
+
|
|
410
|
+
|
|
374
411
|
def _commit_from_payload(payload: dict[str, typing.Any]) -> Commit:
|
|
375
412
|
"""Convert a GitHub commit list/object payload into a :class:`Commit`."""
|
|
376
413
|
sha = str(payload.get('sha', ''))
|
|
@@ -1221,11 +1258,25 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1221
1258
|
# the triggering-actor lookup by run id so we resolve each run
|
|
1222
1259
|
# at most once. Shared across the parallel per-env fan-out.
|
|
1223
1260
|
run_cache: dict[str, tuple[str, str] | None] = {}
|
|
1261
|
+
# Likewise, deployments in a sweep share a handful of refs; the
|
|
1262
|
+
# lookup for each is memoised here as a task so it happens once
|
|
1263
|
+
# per ref instead of once per deployment. Holding the task (not
|
|
1264
|
+
# its result) also coalesces the per-env fan-out below: envs that
|
|
1265
|
+
# deployed the same ref await one in-flight request rather than
|
|
1266
|
+
# each issuing their own, which is the common shape at the host's
|
|
1267
|
+
# default limit=1 where no single env repeats a ref.
|
|
1268
|
+
release_lookups: dict[str, asyncio.Task[RemoteRelease | None]] = {}
|
|
1269
|
+
mainline = _mainline_branches(ctx.integration_options)
|
|
1224
1270
|
async with self._client(ctx, credentials) as client:
|
|
1225
1271
|
per_env = await asyncio.gather(
|
|
1226
1272
|
*(
|
|
1227
1273
|
self._list_deployments_for_env(
|
|
1228
|
-
client,
|
|
1274
|
+
client,
|
|
1275
|
+
env,
|
|
1276
|
+
page_size,
|
|
1277
|
+
run_cache,
|
|
1278
|
+
release_lookups,
|
|
1279
|
+
mainline,
|
|
1229
1280
|
)
|
|
1230
1281
|
for env in environments
|
|
1231
1282
|
)
|
|
@@ -1248,8 +1299,9 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1248
1299
|
which 404s to ``None`` for tags without a release and caches 403s
|
|
1249
1300
|
so a scope-limited token short-circuits.
|
|
1250
1301
|
"""
|
|
1302
|
+
mainline = _mainline_branches(ctx.integration_options)
|
|
1251
1303
|
async with self._client(ctx, credentials) as client:
|
|
1252
|
-
return await self._release_notes_for_ref(client, tag)
|
|
1304
|
+
return await self._release_notes_for_ref(client, tag, mainline)
|
|
1253
1305
|
|
|
1254
1306
|
async def _list_deployments_for_env(
|
|
1255
1307
|
self,
|
|
@@ -1257,6 +1309,8 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1257
1309
|
environment: str,
|
|
1258
1310
|
page_size: int,
|
|
1259
1311
|
run_cache: dict[str, tuple[str, str] | None],
|
|
1312
|
+
release_lookups: dict[str, asyncio.Task[RemoteRelease | None]],
|
|
1313
|
+
mainline: frozenset[str],
|
|
1260
1314
|
) -> list[RemoteDeployment]:
|
|
1261
1315
|
try:
|
|
1262
1316
|
resp = await client.get(
|
|
@@ -1289,7 +1343,12 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1289
1343
|
observed: list[RemoteDeployment] = []
|
|
1290
1344
|
for deployment in deployments:
|
|
1291
1345
|
run = await self._observe_deployment(
|
|
1292
|
-
client,
|
|
1346
|
+
client,
|
|
1347
|
+
environment,
|
|
1348
|
+
deployment,
|
|
1349
|
+
run_cache,
|
|
1350
|
+
release_lookups,
|
|
1351
|
+
mainline,
|
|
1293
1352
|
)
|
|
1294
1353
|
if run is not None:
|
|
1295
1354
|
observed.append(run)
|
|
@@ -1301,6 +1360,8 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1301
1360
|
environment: str,
|
|
1302
1361
|
deployment: dict[str, typing.Any],
|
|
1303
1362
|
run_cache: dict[str, tuple[str, str] | None],
|
|
1363
|
+
release_lookups: dict[str, asyncio.Task[RemoteRelease | None]],
|
|
1364
|
+
mainline: frozenset[str],
|
|
1304
1365
|
) -> RemoteDeployment | None:
|
|
1305
1366
|
deployment_id = deployment.get('id')
|
|
1306
1367
|
sha = deployment.get('sha')
|
|
@@ -1318,7 +1379,9 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1318
1379
|
ref_value = deployment.get('ref')
|
|
1319
1380
|
description = deployment.get('description')
|
|
1320
1381
|
release_notes = (
|
|
1321
|
-
await self._release_notes_for_ref(
|
|
1382
|
+
await self._release_notes_for_ref(
|
|
1383
|
+
client, str(ref_value), mainline, release_lookups
|
|
1384
|
+
)
|
|
1322
1385
|
if ref_value
|
|
1323
1386
|
else None
|
|
1324
1387
|
)
|
|
@@ -1411,18 +1474,53 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1411
1474
|
return result
|
|
1412
1475
|
|
|
1413
1476
|
async def _release_for_ref(
|
|
1414
|
-
self,
|
|
1477
|
+
self,
|
|
1478
|
+
client: httpx.AsyncClient,
|
|
1479
|
+
ref: str,
|
|
1480
|
+
mainline: frozenset[str],
|
|
1481
|
+
release_lookups: dict[str, asyncio.Task[RemoteRelease | None]]
|
|
1482
|
+
| None = None,
|
|
1415
1483
|
) -> RemoteRelease | None:
|
|
1416
1484
|
"""Return the GitHub release for ``ref``, metadata included.
|
|
1417
1485
|
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1486
|
+
Refs in ``mainline`` (the resolved ``mainline_branches`` option)
|
|
1487
|
+
never name a release, so they never reach the API.
|
|
1488
|
+
|
|
1489
|
+
``release_lookups`` memoises one lookup per ref for the duration of
|
|
1490
|
+
a sweep. It holds the in-flight task rather than its result, so
|
|
1491
|
+
the parallel per-env fan-out in
|
|
1492
|
+
:meth:`list_recent_deployments` coalesces onto a single request
|
|
1493
|
+
when several environments deployed the same ref -- checking a
|
|
1494
|
+
result-only cache would let every env past the check before the
|
|
1495
|
+
first response landed. Like ``run_cache`` it memoises every
|
|
1496
|
+
outcome, failures included, so one sweep never retries a ref that
|
|
1497
|
+
just errored; it is scoped the same way (one sweep, one repo, one
|
|
1498
|
+
token), and omitting it disables the memo for one-off host-facing
|
|
1499
|
+
lookups.
|
|
1500
|
+
"""
|
|
1501
|
+
if ref in mainline:
|
|
1502
|
+
return None
|
|
1503
|
+
if release_lookups is None:
|
|
1504
|
+
return await self._fetch_release(client, ref)
|
|
1505
|
+
task = release_lookups.get(ref)
|
|
1506
|
+
if task is None:
|
|
1507
|
+
task = asyncio.ensure_future(self._fetch_release(client, ref))
|
|
1508
|
+
release_lookups[ref] = task
|
|
1509
|
+
return await task
|
|
1510
|
+
|
|
1511
|
+
async def _fetch_release(
|
|
1512
|
+
self, client: httpx.AsyncClient, ref: str
|
|
1513
|
+
) -> RemoteRelease | None:
|
|
1514
|
+
"""Issue the release lookup for ``ref`` and shape the response.
|
|
1515
|
+
|
|
1516
|
+
The single release request every caller degrades through: a 404 /
|
|
1517
|
+
410 / non-200 / parse failure yields ``None``, and a ``403`` (token
|
|
1518
|
+
lacks scope to read releases) is cached process-wide so a forbidden
|
|
1519
|
+
token short-circuits instead of re-issuing the request for every
|
|
1422
1520
|
deployment on every resync sweep. ``author`` is the login GitHub
|
|
1423
|
-
credits with the release and ``author_subject`` its numeric user
|
|
1424
|
-
which the host resolves to an Imbi user through the identity
|
|
1425
|
-
on the same service.
|
|
1521
|
+
credits with the release and ``author_subject`` its numeric user
|
|
1522
|
+
id, which the host resolves to an Imbi user through the identity
|
|
1523
|
+
plugins on the same service.
|
|
1426
1524
|
"""
|
|
1427
1525
|
if _releases_forbidden(client):
|
|
1428
1526
|
return None
|
|
@@ -1464,11 +1562,17 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1464
1562
|
tag: str,
|
|
1465
1563
|
) -> RemoteRelease | None:
|
|
1466
1564
|
"""Return the GitHub release for ``tag`` with its author."""
|
|
1565
|
+
mainline = _mainline_branches(ctx.integration_options)
|
|
1467
1566
|
async with self._client(ctx, credentials) as client:
|
|
1468
|
-
return await self._release_for_ref(client, tag)
|
|
1567
|
+
return await self._release_for_ref(client, tag, mainline)
|
|
1469
1568
|
|
|
1470
1569
|
async def _release_notes_for_ref(
|
|
1471
|
-
self,
|
|
1570
|
+
self,
|
|
1571
|
+
client: httpx.AsyncClient,
|
|
1572
|
+
ref: str,
|
|
1573
|
+
mainline: frozenset[str],
|
|
1574
|
+
release_lookups: dict[str, asyncio.Task[RemoteRelease | None]]
|
|
1575
|
+
| None = None,
|
|
1472
1576
|
) -> str | None:
|
|
1473
1577
|
"""Return the GitHub release notes body for a deployed ref.
|
|
1474
1578
|
|
|
@@ -1479,7 +1583,9 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1479
1583
|
``None`` -- resync is never blocked by a missing or unreadable
|
|
1480
1584
|
release.
|
|
1481
1585
|
"""
|
|
1482
|
-
release = await self._release_for_ref(
|
|
1586
|
+
release = await self._release_for_ref(
|
|
1587
|
+
client, ref, mainline, release_lookups
|
|
1588
|
+
)
|
|
1483
1589
|
return release.body_markdown if release else None
|
|
1484
1590
|
|
|
1485
1591
|
async def _latest_status(
|
|
@@ -81,6 +81,22 @@ _OPTIONS: list[PluginOption] = [
|
|
|
81
81
|
),
|
|
82
82
|
type='string',
|
|
83
83
|
),
|
|
84
|
+
PluginOption(
|
|
85
|
+
name='mainline_branches',
|
|
86
|
+
label='Mainline branches (space-separated)',
|
|
87
|
+
description=(
|
|
88
|
+
'Branch names your repos deploy from, which therefore never '
|
|
89
|
+
'name a GitHub Release. Deployment resync skips the release '
|
|
90
|
+
'lookup for a deployment whose ref is one of these instead of '
|
|
91
|
+
'issuing a request that is certain to 404 -- a repo deploying '
|
|
92
|
+
'off its default branch would otherwise pay for one doomed '
|
|
93
|
+
'lookup per deployment row. Commas or spaces both separate; '
|
|
94
|
+
'leave blank for the ``main master`` default.'
|
|
95
|
+
),
|
|
96
|
+
type='string',
|
|
97
|
+
required=False,
|
|
98
|
+
default='main master',
|
|
99
|
+
),
|
|
84
100
|
]
|
|
85
101
|
|
|
86
102
|
# The single credential store for the Integration. Every capability of
|
|
@@ -17,6 +17,7 @@ from imbi.common.plugins.errors import PluginAuthenticationFailed
|
|
|
17
17
|
from imbi.plugins.github.deployment import (
|
|
18
18
|
GitHubDeployment,
|
|
19
19
|
_artifact_status,
|
|
20
|
+
_mainline_branches,
|
|
20
21
|
_repo_root_from_redirect,
|
|
21
22
|
)
|
|
22
23
|
from imbi.plugins.github.plugin import GitHubPlugin
|
|
@@ -91,6 +92,21 @@ class ManifestTestCase(unittest.TestCase):
|
|
|
91
92
|
self.assertFalse(options['artifact_workflow'].required)
|
|
92
93
|
self.assertEqual(options['artifact_version_input'].default, 'version')
|
|
93
94
|
|
|
95
|
+
def test_mainline_branches_declared_integration_level(self) -> None:
|
|
96
|
+
# Mainline branch naming is a property of the org's repos, not of
|
|
97
|
+
# one capability, and ``capability_options`` only ever reach their
|
|
98
|
+
# own capability -- so it has to sit beside flavor/host where every
|
|
99
|
+
# capability can read it off ``ctx.integration_options``.
|
|
100
|
+
options = {opt.name: opt for opt in GitHubPlugin.manifest.options}
|
|
101
|
+
self.assertIn('mainline_branches', options)
|
|
102
|
+
self.assertEqual(options['mainline_branches'].default, 'main master')
|
|
103
|
+
self.assertFalse(options['mainline_branches'].required)
|
|
104
|
+
cap = GitHubPlugin.manifest.get_capability('deployment')
|
|
105
|
+
assert cap is not None
|
|
106
|
+
self.assertNotIn(
|
|
107
|
+
'mainline_branches', {opt.name for opt in cap.options}
|
|
108
|
+
)
|
|
109
|
+
|
|
94
110
|
def test_no_legacy_deploys_via_edge_declared(self) -> None:
|
|
95
111
|
# Promote behaviour is inferred from the ref shape and per-env
|
|
96
112
|
# payloads ride on the USES edge (``env_payloads``). No plugin
|
|
@@ -1309,6 +1325,238 @@ class ListRecentDeploymentsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
1309
1325
|
self.assertTrue(all(e.release_notes is None for e in events))
|
|
1310
1326
|
self.assertEqual(first.call_count, 1)
|
|
1311
1327
|
|
|
1328
|
+
@respx.mock
|
|
1329
|
+
async def test_branch_refs_never_reach_the_releases_api(self) -> None:
|
|
1330
|
+
# A repo that deploys off its default branch reports ``ref ==
|
|
1331
|
+
# 'main'``/``'master'``, which can never name a release. Neither
|
|
1332
|
+
# release route is mocked: if the lookup were attempted at all,
|
|
1333
|
+
# respx would raise on the unmatched request.
|
|
1334
|
+
respx.get('https://api.github.com/repos/octo/demo/deployments').mock(
|
|
1335
|
+
return_value=httpx.Response(
|
|
1336
|
+
200,
|
|
1337
|
+
json=[
|
|
1338
|
+
{
|
|
1339
|
+
'id': 61,
|
|
1340
|
+
'sha': 'sha1',
|
|
1341
|
+
'ref': 'main',
|
|
1342
|
+
'created_at': '2026-05-13T14:00:00Z',
|
|
1343
|
+
},
|
|
1344
|
+
{
|
|
1345
|
+
'id': 62,
|
|
1346
|
+
'sha': 'sha2',
|
|
1347
|
+
'ref': 'master',
|
|
1348
|
+
'created_at': '2026-05-13T14:05:00Z',
|
|
1349
|
+
},
|
|
1350
|
+
],
|
|
1351
|
+
)
|
|
1352
|
+
)
|
|
1353
|
+
respx.get(
|
|
1354
|
+
'https://api.github.com/repos/octo/demo/deployments/61/statuses'
|
|
1355
|
+
).mock(return_value=httpx.Response(200, json=[]))
|
|
1356
|
+
respx.get(
|
|
1357
|
+
'https://api.github.com/repos/octo/demo/deployments/62/statuses'
|
|
1358
|
+
).mock(return_value=httpx.Response(200, json=[]))
|
|
1359
|
+
plugin = GitHubDeployment()
|
|
1360
|
+
events = await plugin.list_recent_deployments(
|
|
1361
|
+
_ctx(), _CREDS, ['production'], limit=2
|
|
1362
|
+
)
|
|
1363
|
+
self.assertEqual(len(events), 2)
|
|
1364
|
+
self.assertTrue(all(e.release_notes is None for e in events))
|
|
1365
|
+
|
|
1366
|
+
@respx.mock
|
|
1367
|
+
async def test_configured_mainline_branches_retarget_the_guard(
|
|
1368
|
+
self,
|
|
1369
|
+
) -> None:
|
|
1370
|
+
# An org that deploys off ``develop`` configures it, which both
|
|
1371
|
+
# suppresses the doomed ``develop`` lookup and re-enables the
|
|
1372
|
+
# ``main`` one: the option replaces the default set, it doesn't
|
|
1373
|
+
# extend it. Only the ``main`` route is mocked, so respx raises
|
|
1374
|
+
# if ``develop`` is looked up after all.
|
|
1375
|
+
respx.get('https://api.github.com/repos/octo/demo/deployments').mock(
|
|
1376
|
+
return_value=httpx.Response(
|
|
1377
|
+
200,
|
|
1378
|
+
json=[
|
|
1379
|
+
{
|
|
1380
|
+
'id': 91,
|
|
1381
|
+
'sha': 'sha1',
|
|
1382
|
+
'ref': 'develop',
|
|
1383
|
+
'created_at': '2026-05-13T14:00:00Z',
|
|
1384
|
+
},
|
|
1385
|
+
{
|
|
1386
|
+
'id': 92,
|
|
1387
|
+
'sha': 'sha2',
|
|
1388
|
+
'ref': 'main',
|
|
1389
|
+
'created_at': '2026-05-13T14:05:00Z',
|
|
1390
|
+
},
|
|
1391
|
+
],
|
|
1392
|
+
)
|
|
1393
|
+
)
|
|
1394
|
+
respx.get(
|
|
1395
|
+
'https://api.github.com/repos/octo/demo/deployments/91/statuses'
|
|
1396
|
+
).mock(return_value=httpx.Response(200, json=[]))
|
|
1397
|
+
respx.get(
|
|
1398
|
+
'https://api.github.com/repos/octo/demo/deployments/92/statuses'
|
|
1399
|
+
).mock(return_value=httpx.Response(200, json=[]))
|
|
1400
|
+
on_main = respx.get(
|
|
1401
|
+
'https://api.github.com/repos/octo/demo/releases/tags/main'
|
|
1402
|
+
).mock(
|
|
1403
|
+
return_value=httpx.Response(
|
|
1404
|
+
200, json={'tag_name': 'main', 'body': 'notes from main'}
|
|
1405
|
+
)
|
|
1406
|
+
)
|
|
1407
|
+
plugin = GitHubDeployment()
|
|
1408
|
+
events = await plugin.list_recent_deployments(
|
|
1409
|
+
_ctx(
|
|
1410
|
+
connection={
|
|
1411
|
+
'flavor': 'github',
|
|
1412
|
+
'mainline_branches': 'develop',
|
|
1413
|
+
}
|
|
1414
|
+
),
|
|
1415
|
+
_CREDS,
|
|
1416
|
+
['production'],
|
|
1417
|
+
limit=2,
|
|
1418
|
+
)
|
|
1419
|
+
by_ref = {e.ref: e for e in events}
|
|
1420
|
+
self.assertIsNone(by_ref['develop'].release_notes)
|
|
1421
|
+
self.assertEqual(by_ref['main'].release_notes, 'notes from main')
|
|
1422
|
+
self.assertEqual(on_main.call_count, 1)
|
|
1423
|
+
|
|
1424
|
+
@respx.mock
|
|
1425
|
+
async def test_release_404_looked_up_once_per_sweep(self) -> None:
|
|
1426
|
+
# Deployments sharing a tagless ref must pay for the miss once,
|
|
1427
|
+
# not once per row -- and the memo is scoped to a single sweep,
|
|
1428
|
+
# so a second call re-checks (a release may have been cut since).
|
|
1429
|
+
respx.get('https://api.github.com/repos/octo/demo/deployments').mock(
|
|
1430
|
+
return_value=httpx.Response(
|
|
1431
|
+
200,
|
|
1432
|
+
json=[
|
|
1433
|
+
{
|
|
1434
|
+
'id': 71,
|
|
1435
|
+
'sha': 'sha1',
|
|
1436
|
+
'ref': 'release-candidate',
|
|
1437
|
+
'created_at': '2026-05-13T14:00:00Z',
|
|
1438
|
+
},
|
|
1439
|
+
{
|
|
1440
|
+
'id': 72,
|
|
1441
|
+
'sha': 'sha2',
|
|
1442
|
+
'ref': 'release-candidate',
|
|
1443
|
+
'created_at': '2026-05-13T14:05:00Z',
|
|
1444
|
+
},
|
|
1445
|
+
],
|
|
1446
|
+
)
|
|
1447
|
+
)
|
|
1448
|
+
respx.get(
|
|
1449
|
+
'https://api.github.com/repos/octo/demo/deployments/71/statuses'
|
|
1450
|
+
).mock(return_value=httpx.Response(200, json=[]))
|
|
1451
|
+
respx.get(
|
|
1452
|
+
'https://api.github.com/repos/octo/demo/deployments/72/statuses'
|
|
1453
|
+
).mock(return_value=httpx.Response(200, json=[]))
|
|
1454
|
+
lookup = respx.get(
|
|
1455
|
+
'https://api.github.com/repos/octo/demo/releases/'
|
|
1456
|
+
'tags/release-candidate'
|
|
1457
|
+
).mock(return_value=httpx.Response(404, json={'message': 'Not Found'}))
|
|
1458
|
+
plugin = GitHubDeployment()
|
|
1459
|
+
events = await plugin.list_recent_deployments(
|
|
1460
|
+
_ctx(), _CREDS, ['production'], limit=2
|
|
1461
|
+
)
|
|
1462
|
+
self.assertEqual(len(events), 2)
|
|
1463
|
+
self.assertTrue(all(e.release_notes is None for e in events))
|
|
1464
|
+
self.assertEqual(lookup.call_count, 1)
|
|
1465
|
+
await plugin.list_recent_deployments(
|
|
1466
|
+
_ctx(), _CREDS, ['production'], limit=2
|
|
1467
|
+
)
|
|
1468
|
+
self.assertEqual(lookup.call_count, 2)
|
|
1469
|
+
|
|
1470
|
+
@respx.mock
|
|
1471
|
+
async def test_release_410_cached_like_404(self) -> None:
|
|
1472
|
+
# GitHub answers 410 Gone for a resource that's been disabled;
|
|
1473
|
+
# treat it as a miss and suppress the repeat like a 404.
|
|
1474
|
+
respx.get('https://api.github.com/repos/octo/demo/deployments').mock(
|
|
1475
|
+
return_value=httpx.Response(
|
|
1476
|
+
200,
|
|
1477
|
+
json=[
|
|
1478
|
+
{
|
|
1479
|
+
'id': 81,
|
|
1480
|
+
'sha': 'sha1',
|
|
1481
|
+
'ref': 'v3.0.0',
|
|
1482
|
+
'created_at': '2026-05-13T14:00:00Z',
|
|
1483
|
+
},
|
|
1484
|
+
{
|
|
1485
|
+
'id': 82,
|
|
1486
|
+
'sha': 'sha2',
|
|
1487
|
+
'ref': 'v3.0.0',
|
|
1488
|
+
'created_at': '2026-05-13T14:05:00Z',
|
|
1489
|
+
},
|
|
1490
|
+
],
|
|
1491
|
+
)
|
|
1492
|
+
)
|
|
1493
|
+
respx.get(
|
|
1494
|
+
'https://api.github.com/repos/octo/demo/deployments/81/statuses'
|
|
1495
|
+
).mock(return_value=httpx.Response(200, json=[]))
|
|
1496
|
+
respx.get(
|
|
1497
|
+
'https://api.github.com/repos/octo/demo/deployments/82/statuses'
|
|
1498
|
+
).mock(return_value=httpx.Response(200, json=[]))
|
|
1499
|
+
lookup = respx.get(
|
|
1500
|
+
'https://api.github.com/repos/octo/demo/releases/tags/v3.0.0'
|
|
1501
|
+
).mock(return_value=httpx.Response(410, json={'message': 'Gone'}))
|
|
1502
|
+
plugin = GitHubDeployment()
|
|
1503
|
+
events = await plugin.list_recent_deployments(
|
|
1504
|
+
_ctx(), _CREDS, ['production'], limit=2
|
|
1505
|
+
)
|
|
1506
|
+
self.assertTrue(all(e.release_notes is None for e in events))
|
|
1507
|
+
self.assertEqual(lookup.call_count, 1)
|
|
1508
|
+
|
|
1509
|
+
@respx.mock
|
|
1510
|
+
async def test_release_404_coalesced_across_environments(self) -> None:
|
|
1511
|
+
# The per-env fan-out is concurrent, so a memo of *results* would
|
|
1512
|
+
# let every env past the check before the first response landed --
|
|
1513
|
+
# exactly the shape at the host's default limit=1, where no single
|
|
1514
|
+
# env repeats a ref and cross-env sharing is the only sharing
|
|
1515
|
+
# there is. Two envs on one tagless ref must cost one lookup.
|
|
1516
|
+
def _deployments(request: httpx.Request) -> httpx.Response:
|
|
1517
|
+
deployment_id = {'staging': 61, 'production': 62}[
|
|
1518
|
+
request.url.params['environment']
|
|
1519
|
+
]
|
|
1520
|
+
return httpx.Response(
|
|
1521
|
+
200,
|
|
1522
|
+
json=[
|
|
1523
|
+
{
|
|
1524
|
+
'id': deployment_id,
|
|
1525
|
+
'sha': f'sha{deployment_id}',
|
|
1526
|
+
'ref': 'release-candidate',
|
|
1527
|
+
'created_at': '2026-05-13T14:00:00Z',
|
|
1528
|
+
}
|
|
1529
|
+
],
|
|
1530
|
+
)
|
|
1531
|
+
|
|
1532
|
+
respx.get('https://api.github.com/repos/octo/demo/deployments').mock(
|
|
1533
|
+
side_effect=_deployments
|
|
1534
|
+
)
|
|
1535
|
+
respx.get(
|
|
1536
|
+
'https://api.github.com/repos/octo/demo/deployments/61/statuses'
|
|
1537
|
+
).mock(return_value=httpx.Response(200, json=[]))
|
|
1538
|
+
respx.get(
|
|
1539
|
+
'https://api.github.com/repos/octo/demo/deployments/62/statuses'
|
|
1540
|
+
).mock(return_value=httpx.Response(200, json=[]))
|
|
1541
|
+
|
|
1542
|
+
async def _missing_release(request: httpx.Request) -> httpx.Response:
|
|
1543
|
+
# Yield the way a real request does, so the second env gets a
|
|
1544
|
+
# turn while the first one is still in flight.
|
|
1545
|
+
await asyncio.sleep(0)
|
|
1546
|
+
return httpx.Response(404, json={'message': 'Not Found'})
|
|
1547
|
+
|
|
1548
|
+
lookup = respx.get(
|
|
1549
|
+
'https://api.github.com/repos/octo/demo/releases/'
|
|
1550
|
+
'tags/release-candidate'
|
|
1551
|
+
).mock(side_effect=_missing_release)
|
|
1552
|
+
plugin = GitHubDeployment()
|
|
1553
|
+
events = await plugin.list_recent_deployments(
|
|
1554
|
+
_ctx(), _CREDS, ['staging', 'production'], limit=1
|
|
1555
|
+
)
|
|
1556
|
+
self.assertEqual(len(events), 2)
|
|
1557
|
+
self.assertTrue(all(e.release_notes is None for e in events))
|
|
1558
|
+
self.assertEqual(lookup.call_count, 1)
|
|
1559
|
+
|
|
1312
1560
|
@respx.mock
|
|
1313
1561
|
async def test_bot_creator_attributed_to_triggering_actor(self) -> None:
|
|
1314
1562
|
# A workflow-created deployment lists the app bot as creator; the
|
|
@@ -2185,6 +2433,53 @@ class RepoRootFromRedirectTestCase(unittest.TestCase):
|
|
|
2185
2433
|
)
|
|
2186
2434
|
|
|
2187
2435
|
|
|
2436
|
+
class MainlineBranchesTestCase(unittest.TestCase):
|
|
2437
|
+
def test_unset_falls_back_to_default(self) -> None:
|
|
2438
|
+
self.assertEqual(
|
|
2439
|
+
_mainline_branches({'flavor': 'github'}),
|
|
2440
|
+
frozenset({'main', 'master'}),
|
|
2441
|
+
)
|
|
2442
|
+
|
|
2443
|
+
def test_space_separated(self) -> None:
|
|
2444
|
+
self.assertEqual(
|
|
2445
|
+
_mainline_branches({'mainline_branches': 'develop trunk'}),
|
|
2446
|
+
frozenset({'develop', 'trunk'}),
|
|
2447
|
+
)
|
|
2448
|
+
|
|
2449
|
+
def test_commas_tolerated(self) -> None:
|
|
2450
|
+
# The label advertises spaces; operators type commas anyway.
|
|
2451
|
+
self.assertEqual(
|
|
2452
|
+
_mainline_branches({'mainline_branches': 'main, develop,trunk'}),
|
|
2453
|
+
frozenset({'main', 'develop', 'trunk'}),
|
|
2454
|
+
)
|
|
2455
|
+
|
|
2456
|
+
def test_stray_separators_do_not_yield_empty_branch(self) -> None:
|
|
2457
|
+
# An empty-string member would match a deployment with ``ref: ''``
|
|
2458
|
+
# and, worse, read as a configured value.
|
|
2459
|
+
self.assertEqual(
|
|
2460
|
+
_mainline_branches({'mainline_branches': ' main,, master, '}),
|
|
2461
|
+
frozenset({'main', 'master'}),
|
|
2462
|
+
)
|
|
2463
|
+
|
|
2464
|
+
def test_blank_falls_back_to_default(self) -> None:
|
|
2465
|
+
# The manifest default is a form pre-fill the host does not
|
|
2466
|
+
# substitute, so blank means "unconfigured", not "no exclusions".
|
|
2467
|
+
self.assertEqual(
|
|
2468
|
+
_mainline_branches({'mainline_branches': ' '}),
|
|
2469
|
+
frozenset({'main', 'master'}),
|
|
2470
|
+
)
|
|
2471
|
+
|
|
2472
|
+
def test_non_string_falls_back_to_default(self) -> None:
|
|
2473
|
+
self.assertEqual(
|
|
2474
|
+
_mainline_branches({'mainline_branches': None}),
|
|
2475
|
+
frozenset({'main', 'master'}),
|
|
2476
|
+
)
|
|
2477
|
+
self.assertEqual(
|
|
2478
|
+
_mainline_branches({'mainline_branches': 7}),
|
|
2479
|
+
frozenset({'main', 'master'}),
|
|
2480
|
+
)
|
|
2481
|
+
|
|
2482
|
+
|
|
2188
2483
|
class RepoRenameRelocationTestCase(unittest.IsolatedAsyncioTestCase):
|
|
2189
2484
|
"""A repo renamed outside Imbi: GitHub 301s the stale path to the
|
|
2190
2485
|
by-id form. The client follows it (request succeeds) and reports the
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.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.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/lifecycle.py
RENAMED
|
File without changes
|
{imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.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
|