imbi-plugin-github 2.9.2__tar.gz → 2.9.3__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.9.2 → imbi_plugin_github-2.9.3}/PKG-INFO +1 -1
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/pyproject.toml +1 -1
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/commits.py +56 -10
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/tests/test_commits.py +126 -13
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/uv.lock +1 -1
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/.github/workflows/publish.yml +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/.github/workflows/test.yml +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/.gitignore +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/.pre-commit-config.yaml +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/CLAUDE.md +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/LICENSE +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/README.md +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/justfile +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/__init__.py +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/_app_auth.py +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/_hosts.py +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/_repos.py +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/deployment.py +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/identity.py +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/lifecycle.py +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/tests/test_deployment.py +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/tests/test_repos.py +0 -0
|
@@ -565,20 +565,46 @@ async def _annotated_tag(
|
|
|
565
565
|
return typing.cast('dict[str, typing.Any]', resp.json())
|
|
566
566
|
|
|
567
567
|
|
|
568
|
+
def _web_base(api_base: str) -> str:
|
|
569
|
+
"""Map a REST API base to the web host (inverse of the routing table).
|
|
570
|
+
|
|
571
|
+
``https://api.github.com`` -> ``https://github.com``;
|
|
572
|
+
``https://api.<tenant>.ghe.com`` -> ``https://<tenant>.ghe.com``;
|
|
573
|
+
GHES ``https://<host>/api/v3`` -> ``https://<host>``.
|
|
574
|
+
"""
|
|
575
|
+
if api_base.endswith('/api/v3'):
|
|
576
|
+
return api_base[: -len('/api/v3')]
|
|
577
|
+
return api_base.replace('://api.', '://', 1)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _tag_web_url(client: httpx.AsyncClient, name: str) -> str:
|
|
581
|
+
"""Build the web URL for *name* from the client's repo-scoped base."""
|
|
582
|
+
full = str(client.base_url).rstrip('/')
|
|
583
|
+
marker = '/repos/'
|
|
584
|
+
idx = full.find(marker)
|
|
585
|
+
if idx == -1:
|
|
586
|
+
return ''
|
|
587
|
+
web = _web_base(full[:idx])
|
|
588
|
+
owner_repo = full[idx + len(marker) :]
|
|
589
|
+
return f'{web}/{owner_repo}/releases/tag/{urllib.parse.quote(name)}'
|
|
590
|
+
|
|
591
|
+
|
|
568
592
|
def _tag_record(
|
|
569
593
|
*,
|
|
570
594
|
project_id: str,
|
|
571
595
|
name: str,
|
|
572
596
|
sha: str,
|
|
573
597
|
annotated: dict[str, typing.Any] | None = None,
|
|
598
|
+
url: str = '',
|
|
574
599
|
) -> TagRecord:
|
|
575
600
|
if annotated is None:
|
|
576
|
-
return TagRecord(project_id=project_id, name=name, sha=sha)
|
|
601
|
+
return TagRecord(project_id=project_id, name=name, sha=sha, url=url)
|
|
577
602
|
tagger: dict[str, typing.Any] = annotated.get('tagger') or {}
|
|
578
603
|
return TagRecord(
|
|
579
604
|
project_id=project_id,
|
|
580
605
|
name=name,
|
|
581
606
|
sha=sha,
|
|
607
|
+
url=url,
|
|
582
608
|
message=str(annotated.get('message') or ''),
|
|
583
609
|
tagger_name=str(tagger.get('name') or ''),
|
|
584
610
|
tagger_email=str(tagger.get('email') or ''),
|
|
@@ -589,22 +615,41 @@ def _tag_record(
|
|
|
589
615
|
async def _reconcile_tags(
|
|
590
616
|
client: httpx.AsyncClient, project_id: str
|
|
591
617
|
) -> list[TagRecord]:
|
|
592
|
-
"""Upsert the repo's full tag list
|
|
593
|
-
|
|
618
|
+
"""Upsert the repo's full tag list via the git-refs API.
|
|
619
|
+
|
|
620
|
+
``/git/matching-refs/tags`` yields each tag's object sha + type;
|
|
621
|
+
annotated tags (``type == 'tag'``) are enriched with tagger/message/
|
|
622
|
+
date from the tag object, lightweight tags carry name/sha/url only.
|
|
623
|
+
``ReplacingMergeTree`` dedupes against rows recorded from pushes.
|
|
624
|
+
"""
|
|
594
625
|
out: list[TagRecord] = []
|
|
626
|
+
prefix = 'refs/tags/'
|
|
595
627
|
params: dict[str, str] = {'per_page': '100'}
|
|
596
628
|
while True:
|
|
597
|
-
resp = await client.get('/tags', params=params)
|
|
629
|
+
resp = await client.get('/git/matching-refs/tags', params=params)
|
|
598
630
|
resp.raise_for_status()
|
|
599
631
|
rows = typing.cast('list[dict[str, typing.Any]]', resp.json())
|
|
600
632
|
for row in rows:
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
sha = str(
|
|
604
|
-
if
|
|
605
|
-
|
|
606
|
-
|
|
633
|
+
ref = str(row.get('ref') or '')
|
|
634
|
+
obj: dict[str, typing.Any] = row.get('object') or {}
|
|
635
|
+
sha = str(obj.get('sha') or '')
|
|
636
|
+
if not ref.startswith(prefix) or not sha:
|
|
637
|
+
continue
|
|
638
|
+
name = ref[len(prefix) :]
|
|
639
|
+
annotated = (
|
|
640
|
+
await _annotated_tag(client, sha)
|
|
641
|
+
if obj.get('type') == 'tag'
|
|
642
|
+
else None
|
|
643
|
+
)
|
|
644
|
+
out.append(
|
|
645
|
+
_tag_record(
|
|
646
|
+
project_id=project_id,
|
|
647
|
+
name=name,
|
|
648
|
+
sha=sha,
|
|
649
|
+
annotated=annotated,
|
|
650
|
+
url=_tag_web_url(client, name),
|
|
607
651
|
)
|
|
652
|
+
)
|
|
608
653
|
next_url = _next_page_url(resp.headers.get('link'))
|
|
609
654
|
if next_url is None:
|
|
610
655
|
return out
|
|
@@ -650,6 +695,7 @@ async def sync_tags(
|
|
|
650
695
|
name=name,
|
|
651
696
|
sha=after,
|
|
652
697
|
annotated=annotated,
|
|
698
|
+
url=_tag_web_url(client, name),
|
|
653
699
|
)
|
|
654
700
|
]
|
|
655
701
|
if action_config.reconcile_all:
|
|
@@ -470,12 +470,20 @@ class SyncTagsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
470
470
|
respx.get(
|
|
471
471
|
f'https://api.github.com/repos/octo/demo/git/tags/{sha}'
|
|
472
472
|
).mock(return_value=httpx.Response(404))
|
|
473
|
-
respx.get(
|
|
473
|
+
respx.get(
|
|
474
|
+
'https://api.github.com/repos/octo/demo/git/matching-refs/tags'
|
|
475
|
+
).mock(
|
|
474
476
|
return_value=httpx.Response(
|
|
475
477
|
200,
|
|
476
478
|
json=[
|
|
477
|
-
{
|
|
478
|
-
|
|
479
|
+
{
|
|
480
|
+
'ref': 'refs/tags/v1.2.3',
|
|
481
|
+
'object': {'sha': sha, 'type': 'commit'},
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
'ref': 'refs/tags/v1.0.0',
|
|
485
|
+
'object': {'sha': 'z' * 40, 'type': 'commit'},
|
|
486
|
+
},
|
|
479
487
|
],
|
|
480
488
|
)
|
|
481
489
|
)
|
|
@@ -490,6 +498,14 @@ class SyncTagsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
490
498
|
_, records = _await_args(insert)
|
|
491
499
|
names = {r.name for r in records}
|
|
492
500
|
self.assertEqual({'v1.2.3', 'v1.0.0'}, names)
|
|
501
|
+
urls = {r.name: r.url for r in records}
|
|
502
|
+
self.assertEqual(
|
|
503
|
+
{
|
|
504
|
+
'v1.2.3': 'https://github.com/octo/demo/releases/tag/v1.2.3',
|
|
505
|
+
'v1.0.0': 'https://github.com/octo/demo/releases/tag/v1.0.0',
|
|
506
|
+
},
|
|
507
|
+
urls,
|
|
508
|
+
)
|
|
493
509
|
|
|
494
510
|
@respx.mock
|
|
495
511
|
async def test_reconcile_all_paginates(self) -> None:
|
|
@@ -497,17 +513,27 @@ class SyncTagsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
497
513
|
respx.get(
|
|
498
514
|
f'https://api.github.com/repos/octo/demo/git/tags/{sha}'
|
|
499
515
|
).mock(return_value=httpx.Response(404))
|
|
500
|
-
url = 'https://api.github.com/repos/octo/demo/tags'
|
|
516
|
+
url = 'https://api.github.com/repos/octo/demo/git/matching-refs/tags'
|
|
501
517
|
respx.get(url).mock(
|
|
502
518
|
side_effect=[
|
|
503
519
|
httpx.Response(
|
|
504
520
|
200,
|
|
505
|
-
json=[
|
|
521
|
+
json=[
|
|
522
|
+
{
|
|
523
|
+
'ref': 'refs/tags/v1.2.3',
|
|
524
|
+
'object': {'sha': sha, 'type': 'commit'},
|
|
525
|
+
}
|
|
526
|
+
],
|
|
506
527
|
headers={'link': f'<{url}?page=2>; rel="next"'},
|
|
507
528
|
),
|
|
508
529
|
httpx.Response(
|
|
509
530
|
200,
|
|
510
|
-
json=[
|
|
531
|
+
json=[
|
|
532
|
+
{
|
|
533
|
+
'ref': 'refs/tags/v1.0.0',
|
|
534
|
+
'object': {'sha': 'z' * 40, 'type': 'commit'},
|
|
535
|
+
}
|
|
536
|
+
],
|
|
511
537
|
),
|
|
512
538
|
]
|
|
513
539
|
)
|
|
@@ -522,6 +548,14 @@ class SyncTagsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
522
548
|
_, records = _await_args(insert)
|
|
523
549
|
names = {r.name for r in records}
|
|
524
550
|
self.assertEqual({'v1.2.3', 'v1.0.0'}, names)
|
|
551
|
+
urls = {r.name: r.url for r in records}
|
|
552
|
+
self.assertEqual(
|
|
553
|
+
{
|
|
554
|
+
'v1.2.3': 'https://github.com/octo/demo/releases/tag/v1.2.3',
|
|
555
|
+
'v1.0.0': 'https://github.com/octo/demo/releases/tag/v1.0.0',
|
|
556
|
+
},
|
|
557
|
+
urls,
|
|
558
|
+
)
|
|
525
559
|
|
|
526
560
|
@respx.mock
|
|
527
561
|
async def test_401_raises_authentication_failed(self) -> None:
|
|
@@ -850,12 +884,18 @@ class SyncAllHistoryTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
850
884
|
200, json=[_commit('c' * 40), _commit('d' * 40)]
|
|
851
885
|
)
|
|
852
886
|
)
|
|
853
|
-
respx.get(f'{self._REPO}/tags').mock(
|
|
887
|
+
respx.get(f'{self._REPO}/git/matching-refs/tags').mock(
|
|
854
888
|
return_value=httpx.Response(
|
|
855
889
|
200,
|
|
856
890
|
json=[
|
|
857
|
-
{
|
|
858
|
-
|
|
891
|
+
{
|
|
892
|
+
'ref': 'refs/tags/v1.0.0',
|
|
893
|
+
'object': {'sha': 'z' * 40, 'type': 'commit'},
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
'ref': 'refs/tags/v1.1.0',
|
|
897
|
+
'object': {'sha': 'y' * 40, 'type': 'commit'},
|
|
898
|
+
},
|
|
859
899
|
],
|
|
860
900
|
)
|
|
861
901
|
)
|
|
@@ -876,6 +916,17 @@ class SyncAllHistoryTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
876
916
|
records = commit_call.args[1]
|
|
877
917
|
self.assertIsInstance(records[0], CommitRecord)
|
|
878
918
|
self.assertEqual('main', records[0].ref)
|
|
919
|
+
tag_call = next(
|
|
920
|
+
c for c in insert.await_args_list if c.args[0] == 'tags'
|
|
921
|
+
)
|
|
922
|
+
urls = {r.name: r.url for r in tag_call.args[1]}
|
|
923
|
+
self.assertEqual(
|
|
924
|
+
{
|
|
925
|
+
'v1.0.0': 'https://github.com/octo/demo/releases/tag/v1.0.0',
|
|
926
|
+
'v1.1.0': 'https://github.com/octo/demo/releases/tag/v1.1.0',
|
|
927
|
+
},
|
|
928
|
+
urls,
|
|
929
|
+
)
|
|
879
930
|
|
|
880
931
|
@respx.mock
|
|
881
932
|
async def test_paginates_commits(self) -> None:
|
|
@@ -891,7 +942,7 @@ class SyncAllHistoryTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
891
942
|
httpx.Response(200, json=[_commit('2' * 40)]),
|
|
892
943
|
]
|
|
893
944
|
)
|
|
894
|
-
respx.get(f'{self._REPO}/tags').mock(
|
|
945
|
+
respx.get(f'{self._REPO}/git/matching-refs/tags').mock(
|
|
895
946
|
return_value=httpx.Response(200, json=[])
|
|
896
947
|
)
|
|
897
948
|
with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
|
|
@@ -917,7 +968,7 @@ class SyncAllHistoryTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
917
968
|
headers={'link': f'<{url}?page=2>; rel="next"'},
|
|
918
969
|
)
|
|
919
970
|
)
|
|
920
|
-
respx.get(f'{self._REPO}/tags').mock(
|
|
971
|
+
respx.get(f'{self._REPO}/git/matching-refs/tags').mock(
|
|
921
972
|
return_value=httpx.Response(200, json=[])
|
|
922
973
|
)
|
|
923
974
|
with mock.patch.object(commits, '_MAX_HISTORY_PAGES', 1):
|
|
@@ -941,7 +992,7 @@ class SyncAllHistoryTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
941
992
|
respx.get(f'{self._REPO}/commits').mock(
|
|
942
993
|
return_value=httpx.Response(200, json=[_commit('c' * 40)])
|
|
943
994
|
)
|
|
944
|
-
respx.get(f'{self._REPO}/tags').mock(
|
|
995
|
+
respx.get(f'{self._REPO}/git/matching-refs/tags').mock(
|
|
945
996
|
return_value=httpx.Response(200, json=[])
|
|
946
997
|
)
|
|
947
998
|
failing = mock.AsyncMock(side_effect=RuntimeError('clickhouse down'))
|
|
@@ -969,7 +1020,7 @@ class SyncAllHistoryTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
969
1020
|
respx.get(f'{self._REPO}/commits').mock(
|
|
970
1021
|
return_value=httpx.Response(200, json=[_commit('c' * 40)])
|
|
971
1022
|
)
|
|
972
|
-
respx.get(f'{self._REPO}/tags').mock(
|
|
1023
|
+
respx.get(f'{self._REPO}/git/matching-refs/tags').mock(
|
|
973
1024
|
return_value=httpx.Response(200, json=[])
|
|
974
1025
|
)
|
|
975
1026
|
creds = {
|
|
@@ -982,3 +1033,65 @@ class SyncAllHistoryTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
982
1033
|
ctx=self._ctx(), credentials=creds
|
|
983
1034
|
)
|
|
984
1035
|
self.assertEqual(1, token_route.call_count)
|
|
1036
|
+
|
|
1037
|
+
@respx.mock
|
|
1038
|
+
async def test_annotated_tags_enriched_with_metadata(self) -> None:
|
|
1039
|
+
self._mock_default_branch()
|
|
1040
|
+
respx.get(f'{self._REPO}/commits').mock(
|
|
1041
|
+
return_value=httpx.Response(200, json=[_commit('c' * 40)])
|
|
1042
|
+
)
|
|
1043
|
+
tag_sha = 'a' * 40
|
|
1044
|
+
respx.get(f'{self._REPO}/git/matching-refs/tags').mock(
|
|
1045
|
+
return_value=httpx.Response(
|
|
1046
|
+
200,
|
|
1047
|
+
json=[
|
|
1048
|
+
{
|
|
1049
|
+
'ref': 'refs/tags/v2.0.0',
|
|
1050
|
+
'object': {'sha': tag_sha, 'type': 'tag'},
|
|
1051
|
+
}
|
|
1052
|
+
],
|
|
1053
|
+
)
|
|
1054
|
+
)
|
|
1055
|
+
respx.get(f'{self._REPO}/git/tags/{tag_sha}').mock(
|
|
1056
|
+
return_value=httpx.Response(
|
|
1057
|
+
200,
|
|
1058
|
+
json={
|
|
1059
|
+
'message': 'Release 2.0.0',
|
|
1060
|
+
'tagger': {
|
|
1061
|
+
'name': 'Rel Bot',
|
|
1062
|
+
'email': 'rel@example.com',
|
|
1063
|
+
'date': '2026-02-01T00:00:00Z',
|
|
1064
|
+
},
|
|
1065
|
+
},
|
|
1066
|
+
)
|
|
1067
|
+
)
|
|
1068
|
+
with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
|
|
1069
|
+
await commits.GitHubCommitSyncPlugin().sync_all_history(
|
|
1070
|
+
ctx=self._ctx(), credentials=_CREDS
|
|
1071
|
+
)
|
|
1072
|
+
tag_call = next(
|
|
1073
|
+
c for c in insert.await_args_list if c.args[0] == 'tags'
|
|
1074
|
+
)
|
|
1075
|
+
record = tag_call.args[1][0]
|
|
1076
|
+
self.assertEqual('Release 2.0.0', record.message)
|
|
1077
|
+
self.assertEqual('Rel Bot', record.tagger_name)
|
|
1078
|
+
self.assertIsNotNone(record.tagged_at)
|
|
1079
|
+
self.assertEqual(
|
|
1080
|
+
'https://github.com/octo/demo/releases/tag/v2.0.0', record.url
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
class WebBaseTestCase(unittest.TestCase):
|
|
1085
|
+
def test_web_base_flavors(self) -> None:
|
|
1086
|
+
self.assertEqual(
|
|
1087
|
+
'https://github.com',
|
|
1088
|
+
commits._web_base('https://api.github.com'),
|
|
1089
|
+
)
|
|
1090
|
+
self.assertEqual(
|
|
1091
|
+
'https://tenant.ghe.com',
|
|
1092
|
+
commits._web_base('https://api.tenant.ghe.com'),
|
|
1093
|
+
)
|
|
1094
|
+
self.assertEqual(
|
|
1095
|
+
'https://ghe.example.com',
|
|
1096
|
+
commits._web_base('https://ghe.example.com/api/v3'),
|
|
1097
|
+
)
|
|
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
|
|
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
|
|
File without changes
|