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.
Files changed (27) hide show
  1. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/pyproject.toml +2 -2
  3. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/deployment.py +122 -16
  4. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/plugin.py +16 -0
  5. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/tests/test_deployment.py +295 -0
  6. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/.gitignore +0 -0
  7. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/README.md +0 -0
  8. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/moon.yml +0 -0
  9. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/README.md +0 -0
  10. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/__init__.py +0 -0
  11. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/_app_auth.py +0 -0
  12. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/_hosts.py +0 -0
  13. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/_repos.py +0 -0
  14. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/commits.py +0 -0
  15. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/doctor.py +0 -0
  16. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/identity.py +0 -0
  17. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/lifecycle.py +0 -0
  18. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/pull_requests.py +0 -0
  19. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/src/imbi/plugins/github/py.typed +0 -0
  20. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/tests/__init__.py +0 -0
  21. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/tests/test_commits.py +0 -0
  22. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/tests/test_doctor.py +0 -0
  23. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/tests/test_hosts.py +0 -0
  24. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/tests/test_identity.py +0 -0
  25. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/tests/test_lifecycle.py +0 -0
  26. {imbi_plugin_github-2.23.0 → imbi_plugin_github-2.25.0}/tests/test_pull_requests.py +0 -0
  27. {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.23.0
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.23.0
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.23.0"
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.23.0",
21
+ "imbi-common[databases]==2.25.0",
22
22
  "pydantic>=2",
23
23
  "pyjwt[crypto]>=2.8",
24
24
  ]
@@ -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, env, page_size, run_cache
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, environment, deployment, run_cache
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(client, str(ref_value))
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, client: httpx.AsyncClient, ref: str
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
- The single release lookup every caller degrades through: a 404 /
1419
- non-200 / parse failure yields ``None``, and a ``403`` (token lacks
1420
- scope to read releases) is cached process-wide so a forbidden token
1421
- short-circuits instead of re-issuing the request for every
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 id,
1424
- which the host resolves to an Imbi user through the identity plugins
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, client: httpx.AsyncClient, ref: str
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(client, 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