imbi-plugin-github 2.37.0__tar.gz → 2.38.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 (28) hide show
  1. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/pyproject.toml +2 -2
  3. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/_app_auth.py +14 -1
  4. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/deployment.py +21 -0
  5. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/doctor.py +32 -0
  6. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/test_app_auth_scopes.py +12 -0
  7. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/test_deployment.py +24 -0
  8. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/test_doctor.py +40 -1
  9. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/.gitignore +0 -0
  10. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/README.md +0 -0
  11. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/moon.yml +0 -0
  12. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/README.md +0 -0
  13. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/__init__.py +0 -0
  14. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/_hosts.py +0 -0
  15. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/_repos.py +0 -0
  16. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/commits.py +0 -0
  17. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/identity.py +0 -0
  18. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/lifecycle.py +0 -0
  19. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/plugin.py +0 -0
  20. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/pull_requests.py +0 -0
  21. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/py.typed +0 -0
  22. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/__init__.py +0 -0
  23. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/test_commits.py +0 -0
  24. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/test_hosts.py +0 -0
  25. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/test_identity.py +0 -0
  26. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/test_lifecycle.py +0 -0
  27. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/test_pull_requests.py +0 -0
  28. {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/test_repos.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: imbi-plugin-github
3
- Version: 2.37.0
3
+ Version: 2.38.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.37.0
15
+ Requires-Dist: imbi-common[databases]==2.38.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.37.0"
3
+ version = "2.38.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.37.0",
21
+ "imbi-common[databases]==2.38.0",
22
22
  "pydantic>=2",
23
23
  "pyjwt[crypto]>=2.8",
24
24
  ]
@@ -255,7 +255,11 @@ async def _discover_installation_id(
255
255
 
256
256
 
257
257
  async def _mint(
258
- client: httpx.AsyncClient, installation_id: str, scope: FrozenScope
258
+ client: httpx.AsyncClient,
259
+ installation_id: str,
260
+ scope: FrozenScope,
261
+ repositories: collections.abc.Sequence[str] | None = None,
262
+ repository_ids: collections.abc.Sequence[int] | None = None,
259
263
  ) -> tuple[str, object]:
260
264
  """Exchange the App JWT for an installation access token.
261
265
 
@@ -265,6 +269,11 @@ async def _mint(
265
269
  installation does not hold is a ``422``, which is the right failure:
266
270
  a capability asking for authority the App was never given should say
267
271
  so rather than act with less.
272
+
273
+ ``repositories`` (names without the owner) limits the token to those
274
+ repositories of the installation. ``repository_ids`` does the same
275
+ with the numeric repository ids; GitHub refuses an id that is not in
276
+ the installation (``422``).
268
277
  """
269
278
  # ``is not None`` rather than truthiness: ``freeze_scope({})`` is an
270
279
  # empty tuple, and asking GitHub for *no* permissions must send
@@ -274,6 +283,10 @@ async def _mint(
274
283
  body: dict[str, typing.Any] | None = (
275
284
  {'permissions': dict(scope)} if scope is not None else None
276
285
  )
286
+ if repositories is not None:
287
+ body = {**(body or {}), 'repositories': list(repositories)}
288
+ if repository_ids is not None:
289
+ body = {**(body or {}), 'repository_ids': list(repository_ids)}
277
290
  resp = await client.post(
278
291
  f'/app/installations/{installation_id}/access_tokens', json=body
279
292
  )
@@ -1171,6 +1171,27 @@ class GitHubDeployment(DeploymentCapability):
1171
1171
  return None
1172
1172
  return await self._blob_text(client, blob_sha)
1173
1173
 
1174
+ async def get_file(
1175
+ self,
1176
+ ctx: PluginContext,
1177
+ credentials: dict[str, str],
1178
+ path: str,
1179
+ ref: str,
1180
+ ) -> str | None:
1181
+ """Read ``path`` at ``ref`` through the contents API, raw."""
1182
+ async with self._client(
1183
+ ctx, credentials, SCOPE_CONTENTS_READ
1184
+ ) as client:
1185
+ response = await client.get(
1186
+ f'/contents/{urllib.parse.quote(path)}',
1187
+ params={'ref': ref},
1188
+ headers={'Accept': 'application/vnd.github.raw+json'},
1189
+ )
1190
+ if response.status_code == 404:
1191
+ return None
1192
+ response.raise_for_status()
1193
+ return response.text
1194
+
1174
1195
  async def list_commit_notes(
1175
1196
  self,
1176
1197
  ctx: PluginContext,
@@ -411,6 +411,7 @@ class GitHubDoctor(AnalysisCapability):
411
411
  f'the GitHub API id {api_id!r}.',
412
412
  )
413
413
  )
414
+ results.append(_verified_item(connection, api_id))
414
415
  else:
415
416
  results.append(
416
417
  _item(
@@ -681,6 +682,7 @@ class GitHubDoctor(AnalysisCapability):
681
682
  if connection is not None:
682
683
  already_correct = (
683
684
  connection.identifier == str(api_id)
685
+ and connection.verified_identifier == str(api_id)
684
686
  and connection.canonical_url == desired_canonical
685
687
  and ctx.project_links.get(slug) == html_url
686
688
  )
@@ -704,6 +706,36 @@ class GitHubDoctor(AnalysisCapability):
704
706
  )
705
707
 
706
708
 
709
+ def _verified_item(
710
+ connection: ServiceConnection, api_id: str
711
+ ) -> AnalysisResultItem:
712
+ """Check that this plugin wrote the identifier from GitHub.
713
+
714
+ The ``github_app`` secret provider issues tokens only for a verified
715
+ identifier. An edge that a user created or edited by hand is not
716
+ verified until the repair writes it again from GitHub.
717
+ """
718
+ if connection.verified_identifier == api_id:
719
+ return _item(
720
+ 'identifier-verified',
721
+ 'Identifier verified',
722
+ 'pass',
723
+ f'GitHub verified the EXISTS_IN identifier {api_id!r}.',
724
+ )
725
+ return _item(
726
+ 'identifier-verified',
727
+ 'Identifier verified',
728
+ 'warn',
729
+ f'GitHub has not verified the EXISTS_IN identifier {api_id!r}. '
730
+ 'GitHub App tokens are not issued for this project until you use '
731
+ 'the Fix action.',
732
+ RemediationOffer(
733
+ id=_REPAIR_EDGE,
734
+ label='Verify the EXISTS_IN edge from GitHub',
735
+ ),
736
+ )
737
+
738
+
707
739
  def _body_unavailable_items() -> list[AnalysisResultItem]:
708
740
  """Return warn items for body-dependent checks when the fetch fails."""
709
741
  return [
@@ -140,6 +140,18 @@ class ScopedMintTestCase(unittest.IsolatedAsyncioTestCase):
140
140
  httpx.Response(200, content=route.calls[0].request.content).json(),
141
141
  )
142
142
 
143
+ @respx.mock
144
+ async def test_repositories_reach_the_mint_body(self) -> None:
145
+ route = _mock_token()
146
+ async with httpx.AsyncClient(base_url=_BASE) as client:
147
+ await _app_auth._mint( # pyright: ignore[reportPrivateUsage]
148
+ client, '42', _app_auth.freeze_scope({}), ['demo']
149
+ )
150
+ self.assertEqual(
151
+ {'permissions': {}, 'repositories': ['demo']},
152
+ httpx.Response(200, content=route.calls[0].request.content).json(),
153
+ )
154
+
143
155
  @respx.mock
144
156
  async def test_same_scope_is_served_from_cache(self) -> None:
145
157
  route = _mock_token()
@@ -3597,6 +3597,30 @@ class GitNotesTestCase(unittest.IsolatedAsyncioTestCase):
3597
3597
  )
3598
3598
  self.assertIsNone(note)
3599
3599
 
3600
+ @respx.mock
3601
+ async def test_get_file_reads_raw_content_at_ref(self) -> None:
3602
+ route = respx.get(
3603
+ f'{self.REPO}/contents/.claude-plugin/marketplace.json'
3604
+ ).mock(return_value=httpx.Response(200, text='{"name": "x"}'))
3605
+ text = await self.handler.get_file(
3606
+ _ctx(), _CREDS, '.claude-plugin/marketplace.json', self.FULL_SHA
3607
+ )
3608
+ self.assertEqual('{"name": "x"}', text)
3609
+ request = route.calls.last.request
3610
+ self.assertEqual(self.FULL_SHA, request.url.params['ref'])
3611
+ self.assertEqual(
3612
+ 'application/vnd.github.raw+json', request.headers['Accept']
3613
+ )
3614
+
3615
+ @respx.mock
3616
+ async def test_get_file_missing_is_none(self) -> None:
3617
+ respx.get(f'{self.REPO}/contents/missing.json').mock(
3618
+ return_value=httpx.Response(404, json={'message': 'Not Found'})
3619
+ )
3620
+ self.assertIsNone(
3621
+ await self.handler.get_file(_ctx(), _CREDS, 'missing.json', 'x')
3622
+ )
3623
+
3600
3624
  @respx.mock
3601
3625
  async def test_get_commit_note_no_entry_is_none(self) -> None:
3602
3626
  self._mock_tree([{'type': 'blob', 'path': 'e' * 40, 'sha': 'b1'}])
@@ -60,6 +60,7 @@ def _ctx(
60
60
  integration_slug=_TPS_SLUG,
61
61
  identifier='134741',
62
62
  canonical_url=_CANONICAL,
63
+ verified_identifier='134741',
63
64
  )
64
65
  ]
65
66
  default_links: dict[str, str] = {
@@ -142,8 +143,9 @@ class AnalyzeTestCase(unittest.IsolatedAsyncioTestCase):
142
143
  plugin = GitHubDoctor()
143
144
  results = await plugin.analyze(_ctx(), _CREDS)
144
145
  by = _by_slug(results)
145
- self.assertEqual(len(results), 7)
146
+ self.assertEqual(len(results), 8)
146
147
  self.assertEqual(by['exists-in'].status, 'pass')
148
+ self.assertEqual(by['identifier-verified'].status, 'pass')
147
149
  self.assertEqual(by['canonical-url'].status, 'pass')
148
150
  self.assertEqual(by['identifier-type'].status, 'pass')
149
151
  self.assertEqual(by['identifier-match'].status, 'pass')
@@ -448,6 +450,24 @@ class RemediationOfferTestCase(unittest.IsolatedAsyncioTestCase):
448
450
  self.assertIsNotNone(offer)
449
451
  self.assertEqual(offer.id, _REPAIR_EDGE)
450
452
 
453
+ @respx.mock
454
+ async def test_unverified_identifier_offers_the_repair(self) -> None:
455
+ respx.get(_CANONICAL).mock(
456
+ return_value=httpx.Response(200, json=_REPO_PAYLOAD)
457
+ )
458
+ connection = ServiceConnection(
459
+ integration_slug=_TPS_SLUG,
460
+ identifier='134741',
461
+ canonical_url=_CANONICAL,
462
+ )
463
+ results = await GitHubDoctor().analyze(
464
+ _ctx(connections=[connection]), _CREDS
465
+ )
466
+ item = _by_slug(results)['identifier-verified']
467
+ self.assertEqual(item.status, 'warn')
468
+ assert item.remediation is not None
469
+ self.assertEqual(item.remediation.id, _REPAIR_EDGE)
470
+
451
471
  @respx.mock
452
472
  async def test_passing_findings_have_no_offer(self) -> None:
453
473
  respx.get(_CANONICAL).mock(
@@ -546,6 +566,25 @@ class RemediateTestCase(unittest.IsolatedAsyncioTestCase):
546
566
  self.assertEqual(result.status, 'noop')
547
567
  self.assertIsNone(ctx.service_writeback)
548
568
 
569
+ @respx.mock
570
+ async def test_edge_repair_verifies_an_unverified_edge(self) -> None:
571
+ respx.get(_CANONICAL).mock(
572
+ return_value=httpx.Response(200, json=_REPO_PAYLOAD)
573
+ )
574
+ ctx = _ctx(
575
+ connections=[
576
+ ServiceConnection(
577
+ integration_slug=_TPS_SLUG,
578
+ identifier='134741',
579
+ canonical_url=_CANONICAL,
580
+ )
581
+ ]
582
+ )
583
+ result = await GitHubDoctor().remediate(ctx, _CREDS, _REPAIR_EDGE)
584
+ self.assertEqual(result.status, 'fixed')
585
+ assert ctx.service_writeback is not None
586
+ self.assertEqual(ctx.service_writeback.identifier, '134741')
587
+
549
588
  @respx.mock
550
589
  async def test_github_link_repair_emits_link_writeback(self) -> None:
551
590
  respx.get(_CANONICAL).mock(