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.
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/PKG-INFO +2 -2
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/pyproject.toml +2 -2
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/_app_auth.py +14 -1
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/deployment.py +21 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/doctor.py +32 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/test_app_auth_scopes.py +12 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/test_deployment.py +24 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/test_doctor.py +40 -1
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/.gitignore +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/README.md +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/moon.yml +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/README.md +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/__init__.py +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/_hosts.py +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/_repos.py +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/commits.py +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/identity.py +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/lifecycle.py +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/plugin.py +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/pull_requests.py +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/py.typed +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/test_commits.py +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/tests/test_pull_requests.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
21
|
+
"imbi-common[databases]==2.38.0",
|
|
22
22
|
"pydantic>=2",
|
|
23
23
|
"pyjwt[crypto]>=2.8",
|
|
24
24
|
]
|
{imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/_app_auth.py
RENAMED
|
@@ -255,7 +255,11 @@ async def _discover_installation_id(
|
|
|
255
255
|
|
|
256
256
|
|
|
257
257
|
async def _mint(
|
|
258
|
-
client: httpx.AsyncClient,
|
|
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
|
)
|
{imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/deployment.py
RENAMED
|
@@ -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),
|
|
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(
|
|
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
|
{imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.0}/src/imbi/plugins/github/lifecycle.py
RENAMED
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.37.0 → imbi_plugin_github-2.38.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
|