imbi-plugin-github 2.25.0__tar.gz → 2.26.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 (27) hide show
  1. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/PKG-INFO +3 -3
  2. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/pyproject.toml +2 -2
  3. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/deployment.py +261 -2
  4. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/tests/test_deployment.py +424 -0
  5. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/.gitignore +0 -0
  6. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/README.md +0 -0
  7. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/moon.yml +0 -0
  8. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/README.md +0 -0
  9. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/__init__.py +0 -0
  10. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/_app_auth.py +0 -0
  11. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/_hosts.py +0 -0
  12. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/_repos.py +0 -0
  13. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/commits.py +0 -0
  14. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/doctor.py +0 -0
  15. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/identity.py +0 -0
  16. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/lifecycle.py +0 -0
  17. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/plugin.py +0 -0
  18. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/pull_requests.py +0 -0
  19. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/py.typed +0 -0
  20. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/tests/__init__.py +0 -0
  21. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/tests/test_commits.py +0 -0
  22. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/tests/test_doctor.py +0 -0
  23. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/tests/test_hosts.py +0 -0
  24. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/tests/test_identity.py +0 -0
  25. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/tests/test_lifecycle.py +0 -0
  26. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/tests/test_pull_requests.py +0 -0
  27. {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/tests/test_repos.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: imbi-plugin-github
3
- Version: 2.25.0
3
+ Version: 2.26.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.25.0
15
+ Requires-Dist: imbi-common[databases]==2.26.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.25.0"
3
+ version = "2.26.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.25.0",
21
+ "imbi-common[databases]==2.26.0",
22
22
  "pydantic>=2",
23
23
  "pyjwt[crypto]>=2.8",
24
24
  ]
@@ -32,6 +32,7 @@ access token through the Integration credential blob's
32
32
  from __future__ import annotations
33
33
 
34
34
  import asyncio
35
+ import base64
35
36
  import collections.abc
36
37
  import contextlib
37
38
  import datetime
@@ -139,6 +140,31 @@ def _auth_headers(token: str) -> dict[str, str]:
139
140
  }
140
141
 
141
142
 
143
+ #: A push event's ``before`` when the ref did not exist until now.
144
+ _ZERO_SHA = '0' * 40
145
+ # GitHub's compare endpoint lists at most 300 changed files and offers
146
+ # no pagination for them -- a list this long may be incomplete.
147
+ _COMPARE_FILES_CAP = 300
148
+ # How many note-blob reads run at once when listing a whole notes tree.
149
+ _NOTE_BLOB_CONCURRENCY = 10
150
+
151
+ _FULL_SHA_PATTERN = re.compile(r'^[0-9a-f]{40}$')
152
+
153
+
154
+ def _note_sha(path: object) -> str | None:
155
+ """Annotated commit SHA for a notes-tree path, or ``None``.
156
+
157
+ Notes trees key blobs by the annotated commit's full SHA, optionally
158
+ fanned out into subtrees (``ab/cdef...``); flattening the path
159
+ recovers the SHA. Non-note files (``README`` and friends can live
160
+ on a notes ref) do not flatten to 40 hex chars and are skipped.
161
+ """
162
+ if not isinstance(path, str):
163
+ return None
164
+ flattened = path.replace('/', '').lower()
165
+ return flattened if _FULL_SHA_PATTERN.match(flattened) else None
166
+
167
+
142
168
  def _short_sha(sha: str) -> str:
143
169
  return sha[:7]
144
170
 
@@ -414,11 +440,12 @@ def _commit_from_payload(payload: dict[str, typing.Any]) -> Commit:
414
440
  commit_meta: dict[str, typing.Any] = payload.get('commit') or {}
415
441
  author_meta: dict[str, typing.Any] = commit_meta.get('author') or {}
416
442
  raw_message = str(commit_meta.get('message') or '')
417
- message_lines = raw_message.splitlines()
443
+ subject, _, body = raw_message.partition('\n')
418
444
  return Commit(
419
445
  sha=sha,
420
446
  short_sha=_short_sha(sha),
421
- message=message_lines[0] if message_lines else '',
447
+ message=subject,
448
+ body=body.strip() or None,
422
449
  author=author_meta.get('name'),
423
450
  authored_at=_parse_iso(author_meta.get('date')),
424
451
  url=payload.get('html_url'),
@@ -848,6 +875,238 @@ class GitHubDeployment(DeploymentCapability):
848
875
  deletions=deletions,
849
876
  )
850
877
 
878
+ # -- Git notes ----------------------------------------------------------
879
+
880
+ async def get_commit_note(
881
+ self,
882
+ ctx: PluginContext,
883
+ credentials: dict[str, str],
884
+ namespace: str,
885
+ committish: str,
886
+ ) -> str | None:
887
+ """Read the note on ``committish`` from ``refs/notes/<namespace>``.
888
+
889
+ The notes tree keys blobs by the *full* SHA of the annotated
890
+ commit (with optional fan-out subtrees like ``ab/cdef...``), so a
891
+ short committish is resolved through the commits endpoint first.
892
+ """
893
+ async with self._client(ctx, credentials) as client:
894
+ full_sha = committish.lower()
895
+ if not _FULL_SHA_PATTERN.match(full_sha):
896
+ commit = await client.get(
897
+ f'/commits/{urllib.parse.quote(committish, safe="")}'
898
+ )
899
+ commit.raise_for_status()
900
+ full_sha = str(commit.json()['sha']).lower()
901
+ notes = await self._notes_tree(client, namespace)
902
+ if notes is None:
903
+ return None
904
+ blob_sha = notes.get(full_sha)
905
+ if blob_sha is None:
906
+ return None
907
+ return await self._blob_text(client, blob_sha)
908
+
909
+ async def diff_commit_notes(
910
+ self,
911
+ ctx: PluginContext,
912
+ credentials: dict[str, str],
913
+ namespace: str,
914
+ before: str,
915
+ after: str,
916
+ ) -> dict[str, str | None]:
917
+ """Diff ``refs/notes/<namespace>`` between two of its commits.
918
+
919
+ ``before``/``after`` come from a push event on the notes ref;
920
+ the *files* changed between them are the notes of the annotated
921
+ commits, so the tree diff -- not the push's ``commits`` list --
922
+ is what names the commits whose notes changed. An all-zero
923
+ ``before`` (the ref was just created) returns every note at
924
+ ``after``.
925
+
926
+ Known limitation: GitHub's compare endpoint is three-dot only
927
+ (``after`` against the merge base), so a *force-pushed* notes
928
+ ref whose ``before`` is not an ancestor of ``after`` can miss a
929
+ note that the rewrite flipped or removed. The 404 fallback
930
+ below covers a ``before`` GitHub no longer has at all, and the
931
+ sweep backfill only repairs verdicts that are still ``null`` --
932
+ a stale non-null verdict from this window persists until the
933
+ next ordinary push touches the note.
934
+ """
935
+ async with self._client(ctx, credentials) as client:
936
+ if before == _ZERO_SHA:
937
+ return await self._all_notes(client, after)
938
+ quoted = urllib.parse.quote(f'{before}...{after}', safe='.')
939
+ resp = await client.get(f'/compare/{quoted}')
940
+ if resp.status_code == 404:
941
+ # ``before`` was garbage-collected or the ref history
942
+ # was rewritten; fall back to the full tree at ``after``
943
+ # so the push still lands rather than being dropped.
944
+ return await self._all_notes(client, after)
945
+ resp.raise_for_status()
946
+ payload = typing.cast('dict[str, typing.Any]', resp.json())
947
+ files: list[dict[str, typing.Any]] = payload.get('files') or []
948
+ if len(files) >= _COMPARE_FILES_CAP:
949
+ # The compare endpoint stops listing files at 300 and
950
+ # offers no pagination for them, so a full list this
951
+ # long may be missing entries. Fall back to the whole
952
+ # tree at ``after`` -- same trade-off as the 404 path:
953
+ # removals in the window are missed until the sweep or
954
+ # the next push touches them.
955
+ LOGGER.warning(
956
+ 'Notes compare %s...%s hit the %d-file cap; '
957
+ 'reading the full tree instead',
958
+ before,
959
+ after,
960
+ _COMPARE_FILES_CAP,
961
+ )
962
+ return await self._all_notes(client, after)
963
+ out: dict[str, str | None] = {}
964
+ for item in files:
965
+ status = str(item.get('status') or '')
966
+ previous = _note_sha(item.get('previous_filename'))
967
+ if status == 'renamed' and previous is not None:
968
+ # Fan-out reshuffle: the note moved paths. The old
969
+ # path's flattened SHA only differs from the new one
970
+ # if the note now annotates a different commit.
971
+ out.setdefault(previous, None)
972
+ annotated = _note_sha(item.get('filename'))
973
+ if annotated is None:
974
+ continue
975
+ if status == 'removed':
976
+ out[annotated] = None
977
+ continue
978
+ blob_sha = item.get('sha')
979
+ if not blob_sha:
980
+ continue
981
+ try:
982
+ body = await self._blob_text(client, str(blob_sha))
983
+ except httpx.HTTPError:
984
+ LOGGER.warning(
985
+ 'Could not read note blob %s for %s; skipping',
986
+ blob_sha,
987
+ annotated,
988
+ )
989
+ body = None
990
+ if body is None:
991
+ # Skip rather than record ``None``: in the diff a
992
+ # ``None`` means "note removed" and would resolve a
993
+ # drift blocker over a note we merely could not
994
+ # read (``_blob_text`` already logged why).
995
+ out.pop(annotated, None)
996
+ continue
997
+ out[annotated] = body
998
+ return out
999
+
1000
+ async def _notes_tree(
1001
+ self, client: httpx.AsyncClient, namespace: str
1002
+ ) -> dict[str, str] | None:
1003
+ """Map annotated full SHA -> note blob SHA, or ``None`` sans ref."""
1004
+ ref = await client.get(
1005
+ f'/git/ref/{urllib.parse.quote(f"notes/{namespace}", safe="/")}'
1006
+ )
1007
+ if ref.status_code == 404:
1008
+ return None
1009
+ ref.raise_for_status()
1010
+ tip = str(ref.json()['object']['sha'])
1011
+ return await self._tree_notes(client, tip)
1012
+
1013
+ async def _tree_notes(
1014
+ self, client: httpx.AsyncClient, commit_sha: str
1015
+ ) -> dict[str, str]:
1016
+ """Flatten one notes-ref commit's tree to annotated SHA -> blob."""
1017
+ commit = await client.get(f'/git/commits/{commit_sha}')
1018
+ commit.raise_for_status()
1019
+ tree_sha = str(commit.json()['tree']['sha'])
1020
+ tree = await client.get(f'/git/trees/{tree_sha}?recursive=1')
1021
+ tree.raise_for_status()
1022
+ payload = typing.cast('dict[str, typing.Any]', tree.json())
1023
+ if payload.get('truncated'):
1024
+ LOGGER.warning(
1025
+ 'Notes tree %s is truncated; some notes will be missed',
1026
+ tree_sha,
1027
+ )
1028
+ entries: list[dict[str, typing.Any]] = payload.get('tree') or []
1029
+ out: dict[str, str] = {}
1030
+ for entry in entries:
1031
+ if entry.get('type') != 'blob':
1032
+ continue
1033
+ annotated = _note_sha(entry.get('path'))
1034
+ if annotated is not None:
1035
+ out[annotated] = str(entry['sha'])
1036
+ return out
1037
+
1038
+ async def _all_notes(
1039
+ self, client: httpx.AsyncClient, commit_sha: str
1040
+ ) -> dict[str, str | None]:
1041
+ """Every note at one notes-ref commit, bodies included.
1042
+
1043
+ Blob reads run a few at a time (one request per note) and an
1044
+ unreadable note is skipped rather than failing the batch or
1045
+ recording a false "removed".
1046
+ """
1047
+ notes = await self._tree_notes(client, commit_sha)
1048
+ gate = asyncio.Semaphore(_NOTE_BLOB_CONCURRENCY)
1049
+
1050
+ async def _read(blob_sha: str) -> str | BaseException | None:
1051
+ async with gate:
1052
+ try:
1053
+ return await self._blob_text(client, blob_sha)
1054
+ except httpx.HTTPError as exc:
1055
+ return exc
1056
+
1057
+ items = list(notes.items())
1058
+ bodies = await asyncio.gather(
1059
+ *(_read(blob_sha) for _, blob_sha in items)
1060
+ )
1061
+ out: dict[str, str | None] = {}
1062
+ for (annotated, blob_sha), body in zip(items, bodies, strict=True):
1063
+ if isinstance(body, BaseException):
1064
+ LOGGER.warning(
1065
+ 'Could not read note blob %s for %s; skipping',
1066
+ blob_sha,
1067
+ annotated,
1068
+ )
1069
+ continue
1070
+ if body is None:
1071
+ # ``_blob_text`` could not decode it and logged why;
1072
+ # a ``None`` here would read as "note removed".
1073
+ continue
1074
+ out[annotated] = body
1075
+ return out
1076
+
1077
+ @staticmethod
1078
+ async def _blob_text(
1079
+ client: httpx.AsyncClient, blob_sha: str
1080
+ ) -> str | None:
1081
+ """Fetch and decode one note blob; ``None`` when undecodable."""
1082
+ resp = await client.get(f'/git/blobs/{blob_sha}')
1083
+ resp.raise_for_status()
1084
+ payload = typing.cast('dict[str, typing.Any]', resp.json())
1085
+ content = str(payload.get('content') or '')
1086
+ if payload.get('encoding') != 'base64':
1087
+ if not content:
1088
+ # ``encoding: none`` with an empty body is how GitHub
1089
+ # answers for blobs above the inline size limit --
1090
+ # "cannot read", not "empty note".
1091
+ LOGGER.warning(
1092
+ 'Note blob %s answered encoding %r with no content',
1093
+ blob_sha,
1094
+ payload.get('encoding'),
1095
+ )
1096
+ return None
1097
+ return content
1098
+ try:
1099
+ # Strip GitHub's line wrapping, then decode strictly --
1100
+ # the default decoder silently discards invalid characters,
1101
+ # turning garbage like '%%%%' into an empty body instead of
1102
+ # landing on this "cannot read" path.
1103
+ return base64.b64decode(
1104
+ ''.join(content.split()), validate=True
1105
+ ).decode('utf-8')
1106
+ except (ValueError, UnicodeDecodeError):
1107
+ LOGGER.warning('Could not decode note blob %s', blob_sha)
1108
+ return None
1109
+
851
1110
  # -- Tags / Releases ----------------------------------------------------
852
1111
 
853
1112
  async def create_tag(
@@ -1,6 +1,7 @@
1
1
  """Smoke tests for the GitHub deployment capability handler."""
2
2
 
3
3
  import asyncio
4
+ import base64
4
5
  import datetime
5
6
  import json
6
7
  import time
@@ -642,6 +643,44 @@ class CompareTestCase(unittest.IsolatedAsyncioTestCase):
642
643
  self.assertEqual(result.head_sha, 'c2')
643
644
  self.assertEqual(result.base_sha, 'base-sha')
644
645
 
646
+ @respx.mock
647
+ async def test_compare_splits_subject_from_body(self) -> None:
648
+ respx.get(
649
+ 'https://api.github.com/repos/octo/demo/compare/base...head'
650
+ ).mock(
651
+ return_value=httpx.Response(
652
+ 200,
653
+ json={
654
+ 'commits': [
655
+ {
656
+ 'sha': 'c1',
657
+ 'commit': {
658
+ 'message': 'Add widgets (#8)\n\n## Summary\n'
659
+ 'Adds the widget endpoint.\n',
660
+ 'author': {'name': 'A', 'date': None},
661
+ },
662
+ },
663
+ {
664
+ 'sha': 'c2',
665
+ 'commit': {
666
+ 'message': 'Subject only',
667
+ 'author': {'name': 'A', 'date': None},
668
+ },
669
+ },
670
+ ],
671
+ },
672
+ )
673
+ )
674
+ plugin = GitHubDeployment()
675
+ result = await plugin.compare(_ctx(), _CREDS, 'base', 'head')
676
+ self.assertEqual(result.commits[0].message, 'Add widgets (#8)')
677
+ self.assertEqual(
678
+ result.commits[0].body,
679
+ '## Summary\nAdds the widget endpoint.',
680
+ )
681
+ self.assertEqual(result.commits[1].message, 'Subject only')
682
+ self.assertIsNone(result.commits[1].body)
683
+
645
684
 
646
685
  class TriggerDeploymentTestCase(unittest.IsolatedAsyncioTestCase):
647
686
  @respx.mock
@@ -2614,3 +2653,388 @@ class RepoRenameRelocationTestCase(unittest.IsolatedAsyncioTestCase):
2614
2653
  plugin = GitHubDeployment()
2615
2654
  await plugin.list_commits(ctx, _CREDS, ref='main')
2616
2655
  self.assertIsNone(ctx.link_writeback)
2656
+
2657
+
2658
+ class GitNotesTestCase(unittest.IsolatedAsyncioTestCase):
2659
+ """Reading and diffing ``refs/notes/imbi-drift`` via the Git Data API."""
2660
+
2661
+ REPO = 'https://api.github.com/repos/octo/demo'
2662
+ FULL_SHA = 'abc1234' + 'f' * 33
2663
+
2664
+ def setUp(self) -> None:
2665
+ self.handler = GitHubDeployment()
2666
+
2667
+ def _mock_tree(self, entries: list[dict[str, object]]) -> None:
2668
+ respx.get(f'{self.REPO}/git/ref/notes/imbi-drift').mock(
2669
+ return_value=httpx.Response(
2670
+ 200, json={'object': {'sha': 'notes-tip'}}
2671
+ )
2672
+ )
2673
+ respx.get(f'{self.REPO}/git/commits/notes-tip').mock(
2674
+ return_value=httpx.Response(200, json={'tree': {'sha': 't1'}})
2675
+ )
2676
+ respx.get(f'{self.REPO}/git/trees/t1').mock(
2677
+ return_value=httpx.Response(200, json={'tree': entries})
2678
+ )
2679
+
2680
+ @staticmethod
2681
+ def _blob(sha: str, text: str) -> None:
2682
+ respx.get(
2683
+ f'https://api.github.com/repos/octo/demo/git/blobs/{sha}'
2684
+ ).mock(
2685
+ return_value=httpx.Response(
2686
+ 200,
2687
+ json={
2688
+ 'encoding': 'base64',
2689
+ 'content': base64.b64encode(text.encode()).decode(),
2690
+ },
2691
+ )
2692
+ )
2693
+
2694
+ @respx.mock
2695
+ async def test_get_commit_note_resolves_short_sha_and_fanout(
2696
+ self,
2697
+ ) -> None:
2698
+ respx.get(f'{self.REPO}/commits/abc1234').mock(
2699
+ return_value=httpx.Response(200, json={'sha': self.FULL_SHA})
2700
+ )
2701
+ fanout = f'{self.FULL_SHA[:2]}/{self.FULL_SHA[2:]}'
2702
+ self._mock_tree([{'type': 'blob', 'path': fanout, 'sha': 'b1'}])
2703
+ self._blob('b1', '{"drift_detected":false}')
2704
+ note = await self.handler.get_commit_note(
2705
+ _ctx(), _CREDS, 'imbi-drift', 'abc1234'
2706
+ )
2707
+ self.assertEqual('{"drift_detected":false}', note)
2708
+
2709
+ @respx.mock
2710
+ async def test_get_commit_note_missing_ref_is_none(self) -> None:
2711
+ respx.get(f'{self.REPO}/git/ref/notes/imbi-drift').mock(
2712
+ return_value=httpx.Response(404, json={'message': 'Not Found'})
2713
+ )
2714
+ note = await self.handler.get_commit_note(
2715
+ _ctx(), _CREDS, 'imbi-drift', self.FULL_SHA
2716
+ )
2717
+ self.assertIsNone(note)
2718
+
2719
+ @respx.mock
2720
+ async def test_get_commit_note_no_entry_is_none(self) -> None:
2721
+ self._mock_tree([{'type': 'blob', 'path': 'e' * 40, 'sha': 'b1'}])
2722
+ note = await self.handler.get_commit_note(
2723
+ _ctx(), _CREDS, 'imbi-drift', self.FULL_SHA
2724
+ )
2725
+ self.assertIsNone(note)
2726
+
2727
+ @respx.mock
2728
+ async def test_diff_commit_notes_reads_the_tree_diff(self) -> None:
2729
+ removed_sha = 'e' * 40
2730
+ respx.get(f'{self.REPO}/compare/{"a" * 40}...{"b" * 40}').mock(
2731
+ return_value=httpx.Response(
2732
+ 200,
2733
+ json={
2734
+ 'files': [
2735
+ {
2736
+ 'filename': (
2737
+ f'{self.FULL_SHA[:2]}/{self.FULL_SHA[2:]}'
2738
+ ),
2739
+ 'status': 'modified',
2740
+ 'sha': 'b2',
2741
+ },
2742
+ {'filename': removed_sha, 'status': 'removed'},
2743
+ {'filename': 'README', 'status': 'added'},
2744
+ ]
2745
+ },
2746
+ )
2747
+ )
2748
+ self._blob('b2', '{"drift_detected":true}')
2749
+ changed = await self.handler.diff_commit_notes(
2750
+ _ctx(), _CREDS, 'imbi-drift', 'a' * 40, 'b' * 40
2751
+ )
2752
+ self.assertEqual(
2753
+ {
2754
+ self.FULL_SHA: '{"drift_detected":true}',
2755
+ removed_sha: None,
2756
+ },
2757
+ changed,
2758
+ )
2759
+
2760
+ def _mock_after_tree(
2761
+ self, entries: list[dict[str, object]] | None = None
2762
+ ) -> None:
2763
+ """The full tree at ``after`` the fallback paths read."""
2764
+ respx.get(f'{self.REPO}/git/commits/{"b" * 40}').mock(
2765
+ return_value=httpx.Response(200, json={'tree': {'sha': 't1'}})
2766
+ )
2767
+ respx.get(f'{self.REPO}/git/trees/t1').mock(
2768
+ return_value=httpx.Response(
2769
+ 200,
2770
+ json={
2771
+ 'tree': entries
2772
+ or [{'type': 'blob', 'path': self.FULL_SHA, 'sha': 'b1'}]
2773
+ },
2774
+ )
2775
+ )
2776
+
2777
+ @respx.mock
2778
+ async def test_diff_commit_notes_tracks_a_renamed_note(self) -> None:
2779
+ old_sha = 'e' * 40
2780
+ respx.get(f'{self.REPO}/compare/{"a" * 40}...{"b" * 40}').mock(
2781
+ return_value=httpx.Response(
2782
+ 200,
2783
+ json={
2784
+ 'files': [
2785
+ {
2786
+ 'filename': (
2787
+ f'{self.FULL_SHA[:2]}/{self.FULL_SHA[2:]}'
2788
+ ),
2789
+ 'previous_filename': old_sha,
2790
+ 'status': 'renamed',
2791
+ 'sha': 'b2',
2792
+ }
2793
+ ]
2794
+ },
2795
+ )
2796
+ )
2797
+ self._blob('b2', '{"drift_detected":true}')
2798
+ changed = await self.handler.diff_commit_notes(
2799
+ _ctx(), _CREDS, 'imbi-drift', 'a' * 40, 'b' * 40
2800
+ )
2801
+ self.assertEqual(
2802
+ {
2803
+ self.FULL_SHA: '{"drift_detected":true}',
2804
+ old_sha: None,
2805
+ },
2806
+ changed,
2807
+ )
2808
+
2809
+ @respx.mock
2810
+ async def test_diff_commit_notes_404_falls_back_to_the_tree(
2811
+ self,
2812
+ ) -> None:
2813
+ respx.get(f'{self.REPO}/compare/{"a" * 40}...{"b" * 40}').mock(
2814
+ return_value=httpx.Response(404, json={'message': 'Not Found'})
2815
+ )
2816
+ self._mock_after_tree()
2817
+ self._blob('b1', '{"drift_detected":false}')
2818
+ changed = await self.handler.diff_commit_notes(
2819
+ _ctx(), _CREDS, 'imbi-drift', 'a' * 40, 'b' * 40
2820
+ )
2821
+ self.assertEqual({self.FULL_SHA: '{"drift_detected":false}'}, changed)
2822
+
2823
+ @respx.mock
2824
+ async def test_diff_commit_notes_file_cap_falls_back_to_the_tree(
2825
+ self,
2826
+ ) -> None:
2827
+ # 300 files means the unpaginated list may be incomplete.
2828
+ files = [
2829
+ {'filename': f'{i:040x}', 'status': 'added', 'sha': f's{i}'}
2830
+ for i in range(300)
2831
+ ]
2832
+ respx.get(f'{self.REPO}/compare/{"a" * 40}...{"b" * 40}').mock(
2833
+ return_value=httpx.Response(200, json={'files': files})
2834
+ )
2835
+ self._mock_after_tree()
2836
+ self._blob('b1', '{"drift_detected":false}')
2837
+ with self.assertLogs('imbi.plugins.github', level='WARNING'):
2838
+ changed = await self.handler.diff_commit_notes(
2839
+ _ctx(), _CREDS, 'imbi-drift', 'a' * 40, 'b' * 40
2840
+ )
2841
+ self.assertEqual({self.FULL_SHA: '{"drift_detected":false}'}, changed)
2842
+
2843
+ @respx.mock
2844
+ async def test_diff_commit_notes_skips_an_unreadable_blob(self) -> None:
2845
+ # A failed read is skipped, not recorded as ``None`` -- in the
2846
+ # diff a ``None`` means "note removed" and resolves blockers.
2847
+ other_sha = 'e' * 40
2848
+ respx.get(f'{self.REPO}/compare/{"a" * 40}...{"b" * 40}').mock(
2849
+ return_value=httpx.Response(
2850
+ 200,
2851
+ json={
2852
+ 'files': [
2853
+ {
2854
+ 'filename': self.FULL_SHA,
2855
+ 'status': 'modified',
2856
+ 'sha': 'bad',
2857
+ },
2858
+ {
2859
+ 'filename': other_sha,
2860
+ 'status': 'modified',
2861
+ 'sha': 'b2',
2862
+ },
2863
+ ]
2864
+ },
2865
+ )
2866
+ )
2867
+ respx.get(f'{self.REPO}/git/blobs/bad').mock(
2868
+ return_value=httpx.Response(500)
2869
+ )
2870
+ self._blob('b2', '{"drift_detected":true}')
2871
+ with self.assertLogs('imbi.plugins.github', level='WARNING'):
2872
+ changed = await self.handler.diff_commit_notes(
2873
+ _ctx(), _CREDS, 'imbi-drift', 'a' * 40, 'b' * 40
2874
+ )
2875
+ self.assertEqual({other_sha: '{"drift_detected":true}'}, changed)
2876
+
2877
+ @respx.mock
2878
+ async def test_undecodable_blob_is_skipped(self) -> None:
2879
+ # Not recorded as ``None``: in the diff a ``None`` means "note
2880
+ # removed" and would resolve a drift blocker over a note that
2881
+ # merely could not be decoded.
2882
+ respx.get(f'{self.REPO}/compare/{"a" * 40}...{"b" * 40}').mock(
2883
+ return_value=httpx.Response(
2884
+ 200,
2885
+ json={
2886
+ 'files': [
2887
+ {
2888
+ 'filename': self.FULL_SHA,
2889
+ 'status': 'modified',
2890
+ 'sha': 'b2',
2891
+ }
2892
+ ]
2893
+ },
2894
+ )
2895
+ )
2896
+ respx.get(f'{self.REPO}/git/blobs/b2').mock(
2897
+ return_value=httpx.Response(
2898
+ 200,
2899
+ json={'encoding': 'base64', 'content': '%%%not-base64%%%'},
2900
+ )
2901
+ )
2902
+ with self.assertLogs('imbi.plugins.github', level='WARNING'):
2903
+ changed = await self.handler.diff_commit_notes(
2904
+ _ctx(), _CREDS, 'imbi-drift', 'a' * 40, 'b' * 40
2905
+ )
2906
+ self.assertEqual({}, changed)
2907
+
2908
+ @respx.mock
2909
+ async def test_garbage_base64_is_skipped_not_read_as_empty(self) -> None:
2910
+ # The default decoder discards invalid characters, so '%%%%'
2911
+ # would decode to an empty body and stamp a null verdict
2912
+ # instead of landing on the "cannot read" skip path.
2913
+ respx.get(f'{self.REPO}/compare/{"a" * 40}...{"b" * 40}').mock(
2914
+ return_value=httpx.Response(
2915
+ 200,
2916
+ json={
2917
+ 'files': [
2918
+ {
2919
+ 'filename': self.FULL_SHA,
2920
+ 'status': 'modified',
2921
+ 'sha': 'b2',
2922
+ }
2923
+ ]
2924
+ },
2925
+ )
2926
+ )
2927
+ respx.get(f'{self.REPO}/git/blobs/b2').mock(
2928
+ return_value=httpx.Response(
2929
+ 200, json={'encoding': 'base64', 'content': '%%%%'}
2930
+ )
2931
+ )
2932
+ with self.assertLogs('imbi.plugins.github', level='WARNING'):
2933
+ changed = await self.handler.diff_commit_notes(
2934
+ _ctx(), _CREDS, 'imbi-drift', 'a' * 40, 'b' * 40
2935
+ )
2936
+ self.assertEqual({}, changed)
2937
+
2938
+ @respx.mock
2939
+ async def test_line_wrapped_base64_still_decodes(self) -> None:
2940
+ # GitHub wraps blob content in newlines; strict validation must
2941
+ # not reject its own wrapping.
2942
+ fanout = f'{self.FULL_SHA[:2]}/{self.FULL_SHA[2:]}'
2943
+ self._mock_tree([{'type': 'blob', 'path': fanout, 'sha': 'b1'}])
2944
+ encoded = base64.b64encode(b'{"drift_detected":true}').decode()
2945
+ wrapped = f'{encoded[:16]}\n{encoded[16:]}\n'
2946
+ respx.get(f'{self.REPO}/git/blobs/b1').mock(
2947
+ return_value=httpx.Response(
2948
+ 200, json={'encoding': 'base64', 'content': wrapped}
2949
+ )
2950
+ )
2951
+ note = await self.handler.get_commit_note(
2952
+ _ctx(), _CREDS, 'imbi-drift', self.FULL_SHA
2953
+ )
2954
+ self.assertEqual('{"drift_detected":true}', note)
2955
+
2956
+ @respx.mock
2957
+ async def test_undecodable_blob_is_skipped_in_the_full_tree(
2958
+ self,
2959
+ ) -> None:
2960
+ other_sha = 'e' * 40
2961
+ self._mock_after_tree(
2962
+ [
2963
+ {'type': 'blob', 'path': self.FULL_SHA, 'sha': 'b2'},
2964
+ {'type': 'blob', 'path': other_sha, 'sha': 'b3'},
2965
+ ]
2966
+ )
2967
+ respx.get(f'{self.REPO}/git/blobs/b2').mock(
2968
+ return_value=httpx.Response(
2969
+ 200,
2970
+ json={'encoding': 'base64', 'content': '%%%not-base64%%%'},
2971
+ )
2972
+ )
2973
+ self._blob('b3', '{"drift_detected":false}')
2974
+ with self.assertLogs('imbi.plugins.github', level='WARNING'):
2975
+ changed = await self.handler.diff_commit_notes(
2976
+ _ctx(), _CREDS, 'imbi-drift', '0' * 40, 'b' * 40
2977
+ )
2978
+ self.assertEqual({other_sha: '{"drift_detected":false}'}, changed)
2979
+
2980
+ @respx.mock
2981
+ async def test_oversized_blob_is_none(self) -> None:
2982
+ # ``encoding: none`` with empty content is GitHub's answer for
2983
+ # blobs above the inline size limit -- "cannot read", not an
2984
+ # empty note.
2985
+ self._mock_tree(
2986
+ [{'type': 'blob', 'path': self.FULL_SHA, 'sha': 'big'}]
2987
+ )
2988
+ respx.get(f'{self.REPO}/git/blobs/big').mock(
2989
+ return_value=httpx.Response(
2990
+ 200, json={'encoding': 'none', 'content': ''}
2991
+ )
2992
+ )
2993
+ with self.assertLogs('imbi.plugins.github', level='WARNING'):
2994
+ note = await self.handler.get_commit_note(
2995
+ _ctx(), _CREDS, 'imbi-drift', self.FULL_SHA
2996
+ )
2997
+ self.assertIsNone(note)
2998
+
2999
+ @respx.mock
3000
+ async def test_all_notes_skips_an_unreadable_blob(self) -> None:
3001
+ other_sha = 'e' * 40
3002
+ self._mock_after_tree(
3003
+ [
3004
+ {'type': 'blob', 'path': self.FULL_SHA, 'sha': 'bad'},
3005
+ {'type': 'blob', 'path': other_sha, 'sha': 'b2'},
3006
+ ]
3007
+ )
3008
+ respx.get(f'{self.REPO}/git/blobs/bad').mock(
3009
+ return_value=httpx.Response(500)
3010
+ )
3011
+ self._blob('b2', '{"drift_detected":false}')
3012
+ with self.assertLogs('imbi.plugins.github', level='WARNING'):
3013
+ changed = await self.handler.diff_commit_notes(
3014
+ _ctx(), _CREDS, 'imbi-drift', '0' * 40, 'b' * 40
3015
+ )
3016
+ self.assertEqual({other_sha: '{"drift_detected":false}'}, changed)
3017
+
3018
+ @respx.mock
3019
+ async def test_diff_commit_notes_zero_before_lists_everything(
3020
+ self,
3021
+ ) -> None:
3022
+ respx.get(f'{self.REPO}/git/commits/{"b" * 40}').mock(
3023
+ return_value=httpx.Response(200, json={'tree': {'sha': 't1'}})
3024
+ )
3025
+ respx.get(f'{self.REPO}/git/trees/t1').mock(
3026
+ return_value=httpx.Response(
3027
+ 200,
3028
+ json={
3029
+ 'tree': [
3030
+ {'type': 'blob', 'path': self.FULL_SHA, 'sha': 'b1'},
3031
+ {'type': 'tree', 'path': 'ab', 'sha': 'sub'},
3032
+ ]
3033
+ },
3034
+ )
3035
+ )
3036
+ self._blob('b1', '{"drift_detected":false}')
3037
+ changed = await self.handler.diff_commit_notes(
3038
+ _ctx(), _CREDS, 'imbi-drift', '0' * 40, 'b' * 40
3039
+ )
3040
+ self.assertEqual({self.FULL_SHA: '{"drift_detected":false}'}, changed)