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.
Files changed (26) hide show
  1. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/PKG-INFO +1 -1
  2. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/pyproject.toml +1 -1
  3. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/commits.py +56 -10
  4. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/tests/test_commits.py +126 -13
  5. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/uv.lock +1 -1
  6. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/.github/workflows/publish.yml +0 -0
  7. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/.github/workflows/test.yml +0 -0
  8. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/.gitignore +0 -0
  9. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/.pre-commit-config.yaml +0 -0
  10. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/CLAUDE.md +0 -0
  11. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/LICENSE +0 -0
  12. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/README.md +0 -0
  13. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/justfile +0 -0
  14. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/__init__.py +0 -0
  15. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/_app_auth.py +0 -0
  16. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/_hosts.py +0 -0
  17. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/_repos.py +0 -0
  18. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/deployment.py +0 -0
  19. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/identity.py +0 -0
  20. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/src/imbi_plugin_github/lifecycle.py +0 -0
  21. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/tests/__init__.py +0 -0
  22. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/tests/test_deployment.py +0 -0
  23. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/tests/test_hosts.py +0 -0
  24. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/tests/test_identity.py +0 -0
  25. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/tests/test_lifecycle.py +0 -0
  26. {imbi_plugin_github-2.9.2 → imbi_plugin_github-2.9.3}/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.9.2
3
+ Version: 2.9.3
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: BSD-3-Clause
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "imbi-plugin-github"
3
- version = "2.9.2"
3
+ version = "2.9.3"
4
4
  description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -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 (lightweight); ``ReplacingMergeTree``
593
- dedupes against rows recorded from individual pushes."""
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
- name = str(row.get('name') or '')
602
- commit: dict[str, typing.Any] = row.get('commit') or {}
603
- sha = str(commit.get('sha') or '')
604
- if name and sha:
605
- out.append(
606
- _tag_record(project_id=project_id, name=name, sha=sha)
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('https://api.github.com/repos/octo/demo/tags').mock(
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
- {'name': 'v1.2.3', 'commit': {'sha': sha}},
478
- {'name': 'v1.0.0', 'commit': {'sha': 'z' * 40}},
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=[{'name': 'v1.2.3', 'commit': {'sha': sha}}],
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=[{'name': 'v1.0.0', 'commit': {'sha': 'z' * 40}}],
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
- {'name': 'v1.0.0', 'commit': {'sha': 'z' * 40}},
858
- {'name': 'v1.1.0', 'commit': {'sha': 'y' * 40}},
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
+ )
@@ -703,7 +703,7 @@ server = [
703
703
 
704
704
  [[package]]
705
705
  name = "imbi-plugin-github"
706
- version = "2.9.2"
706
+ version = "2.9.3"
707
707
  source = { editable = "." }
708
708
  dependencies = [
709
709
  { name = "httpx" },