imbi-plugin-github 2.21.0__tar.gz → 2.21.1__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.21.0 → imbi_plugin_github-2.21.1}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/pyproject.toml +2 -2
  3. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/deployment.py +52 -13
  4. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/tests/test_deployment.py +60 -0
  5. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/.gitignore +0 -0
  6. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/README.md +0 -0
  7. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/moon.yml +0 -0
  8. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/README.md +0 -0
  9. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/__init__.py +0 -0
  10. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/_app_auth.py +0 -0
  11. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/_hosts.py +0 -0
  12. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/_repos.py +0 -0
  13. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/commits.py +0 -0
  14. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/doctor.py +0 -0
  15. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/identity.py +0 -0
  16. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/lifecycle.py +0 -0
  17. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/plugin.py +0 -0
  18. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/pull_requests.py +0 -0
  19. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/py.typed +0 -0
  20. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/tests/__init__.py +0 -0
  21. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/tests/test_commits.py +0 -0
  22. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/tests/test_doctor.py +0 -0
  23. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/tests/test_hosts.py +0 -0
  24. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/tests/test_identity.py +0 -0
  25. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/tests/test_lifecycle.py +0 -0
  26. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/tests/test_pull_requests.py +0 -0
  27. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.21.1}/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.21.0
3
+ Version: 2.21.1
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.21.0
15
+ Requires-Dist: imbi-common[databases]==2.21.1
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.21.0"
3
+ version = "2.21.1"
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.0",
21
+ "imbi-common[databases]==2.21.1",
22
22
  "pydantic>=2",
23
23
  "pyjwt[crypto]>=2.8",
24
24
  ]
@@ -46,6 +46,7 @@ from imbi.common.plugins.base import (
46
46
  RefInfo,
47
47
  ReleaseInfo,
48
48
  RemoteDeployment,
49
+ RemoteRelease,
49
50
  WorkflowFile,
50
51
  )
51
52
  from imbi.common.plugins.errors import PluginAuthenticationFailed
@@ -1196,19 +1197,19 @@ class GitHubDeployment(DeploymentCapability):
1196
1197
  run_cache[run_id] = result
1197
1198
  return result
1198
1199
 
1199
- async def _release_notes_for_ref(
1200
+ async def _release_for_ref(
1200
1201
  self, client: httpx.AsyncClient, ref: str
1201
- ) -> str | None:
1202
- """Return the GitHub release notes body for a deployed ref.
1203
-
1204
- A deployment created against a semver tag has a matching GitHub
1205
- release whose ``body`` is the "What's Changed" markdown the host
1206
- persists on the ``Release`` node. Refs that aren't a release tag
1207
- (branches, raw SHAs) 404 here and yield ``None`` -- resync is
1208
- never blocked by a missing or unreadable release. A ``403``
1209
- (token lacks scope to read releases) is cached process-wide so a
1210
- forbidden token short-circuits instead of re-issuing the request
1211
- for every deployment on every resync sweep.
1202
+ ) -> RemoteRelease | None:
1203
+ """Return the GitHub release for ``ref``, metadata included.
1204
+
1205
+ The single release lookup every caller degrades through: a 404 /
1206
+ non-200 / parse failure yields ``None``, and a ``403`` (token lacks
1207
+ scope to read releases) is cached process-wide so a forbidden token
1208
+ short-circuits instead of re-issuing the request for every
1209
+ deployment on every resync sweep. ``author`` is the login GitHub
1210
+ credits with the release and ``author_subject`` its numeric user id,
1211
+ which the host resolves to an Imbi user through the identity plugins
1212
+ on the same service.
1212
1213
  """
1213
1214
  if _releases_forbidden(client):
1214
1215
  return None
@@ -1227,8 +1228,46 @@ class GitHubDeployment(DeploymentCapability):
1227
1228
  data = typing.cast(dict[str, typing.Any], resp.json())
1228
1229
  except ValueError:
1229
1230
  return None
1231
+ author: dict[str, typing.Any] = data.get('author') or {}
1232
+ login = author.get('login')
1233
+ subject = author.get('id')
1230
1234
  body = data.get('body')
1231
- return str(body) if body else None
1235
+ name = data.get('name')
1236
+ html_url = data.get('html_url')
1237
+ return RemoteRelease(
1238
+ tag=ref,
1239
+ name=str(name) if name else None,
1240
+ body_markdown=str(body) if body else None,
1241
+ author=str(login) if login else None,
1242
+ author_subject=str(subject) if subject is not None else None,
1243
+ html_url=str(html_url) if html_url else None,
1244
+ published_at=_parse_iso(data.get('published_at')),
1245
+ )
1246
+
1247
+ async def get_release(
1248
+ self,
1249
+ ctx: PluginContext,
1250
+ credentials: dict[str, str],
1251
+ tag: str,
1252
+ ) -> RemoteRelease | None:
1253
+ """Return the GitHub release for ``tag`` with its author."""
1254
+ async with self._client(ctx, credentials) as client:
1255
+ return await self._release_for_ref(client, tag)
1256
+
1257
+ async def _release_notes_for_ref(
1258
+ self, client: httpx.AsyncClient, ref: str
1259
+ ) -> str | None:
1260
+ """Return the GitHub release notes body for a deployed ref.
1261
+
1262
+ A deployment created against a semver tag has a matching GitHub
1263
+ release whose ``body`` is the "What's Changed" markdown the host
1264
+ persists on the ``Release`` node. Refs that aren't a release tag
1265
+ (branches, raw SHAs) 404 in :meth:`_release_for_ref` and yield
1266
+ ``None`` -- resync is never blocked by a missing or unreadable
1267
+ release.
1268
+ """
1269
+ release = await self._release_for_ref(client, ref)
1270
+ return release.body_markdown if release else None
1232
1271
 
1233
1272
  async def _latest_status(
1234
1273
  self, client: httpx.AsyncClient, deployment_id: str
@@ -1555,6 +1555,66 @@ class GetReleaseNotesTestCase(unittest.IsolatedAsyncioTestCase):
1555
1555
  notes = await plugin.get_release_notes(_ctx(), _CREDS, '9.9.9')
1556
1556
  self.assertIsNone(notes)
1557
1557
 
1558
+ @respx.mock
1559
+ async def test_get_release_carries_the_remote_author(self) -> None:
1560
+ # Attribution comes from GitHub: the login it credits with the
1561
+ # release, plus the numeric id the host resolves to an Imbi user.
1562
+ respx.get(
1563
+ 'https://api.github.com/repos/octo/demo/releases/tags/3.23.4'
1564
+ ).mock(
1565
+ return_value=httpx.Response(
1566
+ 200,
1567
+ json={
1568
+ 'tag_name': '3.23.4',
1569
+ 'name': 'Release 3.23.4',
1570
+ 'body': '## Fixed\n- the thing',
1571
+ 'html_url': 'https://github.com/octo/demo/releases/3.23.4',
1572
+ 'published_at': '2026-07-29T12:00:00Z',
1573
+ 'author': {'login': 'octocat', 'id': 583231},
1574
+ },
1575
+ )
1576
+ )
1577
+ plugin = GitHubDeployment()
1578
+ release = await plugin.get_release(_ctx(), _CREDS, '3.23.4')
1579
+ assert release is not None
1580
+ self.assertEqual('octocat', release.author)
1581
+ self.assertEqual('583231', release.author_subject)
1582
+ self.assertEqual('Release 3.23.4', release.name)
1583
+ self.assertEqual('## Fixed\n- the thing', release.body_markdown)
1584
+ self.assertEqual(
1585
+ 'https://github.com/octo/demo/releases/3.23.4', release.html_url
1586
+ )
1587
+ self.assertEqual(
1588
+ datetime.datetime(2026, 7, 29, 12, 0, tzinfo=datetime.UTC),
1589
+ release.published_at,
1590
+ )
1591
+
1592
+ @respx.mock
1593
+ async def test_get_release_returns_none_when_no_release(self) -> None:
1594
+ respx.get(
1595
+ 'https://api.github.com/repos/octo/demo/releases/tags/9.9.9'
1596
+ ).mock(return_value=httpx.Response(404, json={'message': 'Not Found'}))
1597
+ plugin = GitHubDeployment()
1598
+ self.assertIsNone(await plugin.get_release(_ctx(), _CREDS, '9.9.9'))
1599
+
1600
+ @respx.mock
1601
+ async def test_get_release_tolerates_a_missing_author(self) -> None:
1602
+ # A release with no author (a tag-only release, or a deleted
1603
+ # account) still yields its body rather than failing the lookup.
1604
+ respx.get(
1605
+ 'https://api.github.com/repos/octo/demo/releases/tags/1.0.0'
1606
+ ).mock(
1607
+ return_value=httpx.Response(
1608
+ 200, json={'tag_name': '1.0.0', 'body': 'notes'}
1609
+ )
1610
+ )
1611
+ plugin = GitHubDeployment()
1612
+ release = await plugin.get_release(_ctx(), _CREDS, '1.0.0')
1613
+ assert release is not None
1614
+ self.assertIsNone(release.author)
1615
+ self.assertIsNone(release.author_subject)
1616
+ self.assertEqual('notes', release.body_markdown)
1617
+
1558
1618
 
1559
1619
  class CheckStatusTestCase(unittest.IsolatedAsyncioTestCase):
1560
1620
  @respx.mock