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.
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/PKG-INFO +3 -3
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/pyproject.toml +2 -2
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/deployment.py +261 -2
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/tests/test_deployment.py +424 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/.gitignore +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/README.md +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/moon.yml +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/README.md +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/__init__.py +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/_app_auth.py +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/_hosts.py +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/_repos.py +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/commits.py +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/doctor.py +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/identity.py +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/lifecycle.py +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/plugin.py +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/pull_requests.py +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/py.typed +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/tests/test_commits.py +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/tests/test_doctor.py +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/tests/test_pull_requests.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: imbi-plugin-github
|
|
3
|
-
Version: 2.
|
|
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.
|
|
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.
|
|
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.
|
|
21
|
+
"imbi-common[databases]==2.26.0",
|
|
22
22
|
"pydantic>=2",
|
|
23
23
|
"pyjwt[crypto]>=2.8",
|
|
24
24
|
]
|
{imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/deployment.py
RENAMED
|
@@ -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
|
-
|
|
443
|
+
subject, _, body = raw_message.partition('\n')
|
|
418
444
|
return Commit(
|
|
419
445
|
sha=sha,
|
|
420
446
|
short_sha=_short_sha(sha),
|
|
421
|
-
message=
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/_app_auth.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.0}/src/imbi/plugins/github/lifecycle.py
RENAMED
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.25.0 → imbi_plugin_github-2.26.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
|
|
File without changes
|