imbi-plugin-github 2.26.1__tar.gz → 2.26.2__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.26.1 → imbi_plugin_github-2.26.2}/PKG-INFO +2 -2
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/pyproject.toml +2 -2
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/_repos.py +61 -5
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/deployment.py +64 -16
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/lifecycle.py +189 -53
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/tests/test_deployment.py +90 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/tests/test_lifecycle.py +468 -2
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/.gitignore +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/README.md +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/moon.yml +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/README.md +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/__init__.py +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/_app_auth.py +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/_hosts.py +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/commits.py +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/doctor.py +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/identity.py +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/plugin.py +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/pull_requests.py +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/py.typed +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/tests/test_commits.py +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/tests/test_doctor.py +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/tests/test_pull_requests.py +0 -0
- {imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/tests/test_repos.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: imbi-plugin-github
|
|
3
|
-
Version: 2.26.
|
|
3
|
+
Version: 2.26.2
|
|
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.26.
|
|
15
|
+
Requires-Dist: imbi-common[databases]==2.26.2
|
|
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.26.
|
|
3
|
+
version = "2.26.2"
|
|
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.26.
|
|
21
|
+
"imbi-common[databases]==2.26.2",
|
|
22
22
|
"pydantic>=2",
|
|
23
23
|
"pyjwt[crypto]>=2.8",
|
|
24
24
|
]
|
|
@@ -10,10 +10,15 @@ plugin agrees on what counts as a "real" repo URL.
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import dataclasses
|
|
14
|
+
import logging
|
|
15
|
+
import typing
|
|
13
16
|
import urllib.parse
|
|
14
17
|
|
|
15
18
|
from imbi.common.plugins.base import PluginContext
|
|
16
19
|
|
|
20
|
+
LOGGER = logging.getLogger(__name__)
|
|
21
|
+
|
|
17
22
|
# Reserved GitHub URL prefixes that share the host with repository URLs
|
|
18
23
|
# but never point at a real ``<owner>/<repo>`` pair. Any link whose
|
|
19
24
|
# first path segment matches one of these is skipped during owner/repo
|
|
@@ -99,14 +104,31 @@ def derive_owner_repo_from_links(
|
|
|
99
104
|
return None
|
|
100
105
|
|
|
101
106
|
|
|
102
|
-
|
|
107
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
108
|
+
class RepositoryTarget:
|
|
109
|
+
"""A resolved ``<owner>/<repo>`` plus where it came from.
|
|
110
|
+
|
|
111
|
+
Provenance matters wherever the answer is used to decide something
|
|
112
|
+
other than "which repo do I read". A ``'link'`` owner is what Imbi
|
|
113
|
+
believes about this project and carries authority; a
|
|
114
|
+
``'type_slug_fallback'`` owner is a naming convention that happens
|
|
115
|
+
to resolve, and carries none -- it is a project *type* slug being
|
|
116
|
+
read as a GitHub org, which is very often not one.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
owner: str
|
|
120
|
+
repo: str
|
|
121
|
+
source: typing.Literal['link', 'type_slug_fallback']
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def resolve_repository_target(
|
|
103
125
|
ctx: PluginContext,
|
|
104
126
|
host: str,
|
|
105
127
|
plugin_label: str,
|
|
106
128
|
*,
|
|
107
129
|
prefer_previous_slug: bool = False,
|
|
108
|
-
) ->
|
|
109
|
-
"""Resolve the repo for a plugin call from ``ctx
|
|
130
|
+
) -> RepositoryTarget:
|
|
131
|
+
"""Resolve the repo for a plugin call from ``ctx``, with provenance.
|
|
110
132
|
|
|
111
133
|
First tries the project links; falls back to
|
|
112
134
|
``<project_type_slug>/<project_slug>`` as a convention. Raises
|
|
@@ -118,6 +140,12 @@ def resolve_owner_repo(
|
|
|
118
140
|
``ctx.previous_project_slug`` (when set) over ``ctx.project_slug``
|
|
119
141
|
so callers reacting to a slug rename (e.g. ``on_project_updated``)
|
|
120
142
|
can still locate the pre-rename repo on GitHub.
|
|
143
|
+
|
|
144
|
+
Every fallback resolution is logged. The fallback is load-bearing
|
|
145
|
+
today -- commit sync, PR sync and deployments all lean on it for
|
|
146
|
+
projects that never had their repo link recorded (issue #254,
|
|
147
|
+
defect 3) -- so it cannot be removed until that population is known
|
|
148
|
+
to be empty, and the log is how that gets measured.
|
|
121
149
|
"""
|
|
122
150
|
derived = derive_owner_repo_from_links(
|
|
123
151
|
ctx.project_links,
|
|
@@ -125,15 +153,43 @@ def resolve_owner_repo(
|
|
|
125
153
|
preferred_key=ctx.integration_slug,
|
|
126
154
|
)
|
|
127
155
|
if derived is not None:
|
|
128
|
-
return derived
|
|
156
|
+
return RepositoryTarget(derived[0], derived[1], 'link')
|
|
129
157
|
if ctx.project_type_slugs:
|
|
130
158
|
slug = ctx.project_slug
|
|
131
159
|
if prefer_previous_slug and ctx.previous_project_slug:
|
|
132
160
|
slug = ctx.previous_project_slug
|
|
133
161
|
if slug:
|
|
134
|
-
|
|
162
|
+
owner = ctx.project_type_slugs[0]
|
|
163
|
+
LOGGER.info(
|
|
164
|
+
'Repo target for project %s resolved by project-type '
|
|
165
|
+
'fallback to %s/%s (caller=%s); the project has no '
|
|
166
|
+
'usable GitHub link',
|
|
167
|
+
ctx.project_id,
|
|
168
|
+
owner,
|
|
169
|
+
slug,
|
|
170
|
+
plugin_label,
|
|
171
|
+
)
|
|
172
|
+
return RepositoryTarget(owner, slug, 'type_slug_fallback')
|
|
135
173
|
raise ValueError(
|
|
136
174
|
f'{plugin_label} could not determine the target repository: '
|
|
137
175
|
"set the project's GitHub Repository link or tag the project "
|
|
138
176
|
'with a ProjectType'
|
|
139
177
|
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def resolve_owner_repo(
|
|
181
|
+
ctx: PluginContext,
|
|
182
|
+
host: str,
|
|
183
|
+
plugin_label: str,
|
|
184
|
+
*,
|
|
185
|
+
prefer_previous_slug: bool = False,
|
|
186
|
+
) -> tuple[str, str]:
|
|
187
|
+
"""Resolve ``(owner, repo)`` for a plugin call from ``ctx``.
|
|
188
|
+
|
|
189
|
+
Provenance-free shorthand for :func:`resolve_repository_target`, for
|
|
190
|
+
the read paths that only need to know which repo to talk to.
|
|
191
|
+
"""
|
|
192
|
+
target = resolve_repository_target(
|
|
193
|
+
ctx, host, plugin_label, prefer_previous_slug=prefer_previous_slug
|
|
194
|
+
)
|
|
195
|
+
return target.owner, target.repo
|
{imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/deployment.py
RENAMED
|
@@ -54,6 +54,7 @@ from imbi.common.plugins.base import (
|
|
|
54
54
|
DeploymentEventStatus,
|
|
55
55
|
DeploymentRun,
|
|
56
56
|
LinkWriteback,
|
|
57
|
+
NotesListing,
|
|
57
58
|
PluginContext,
|
|
58
59
|
Ref,
|
|
59
60
|
RefInfo,
|
|
@@ -906,6 +907,29 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
906
907
|
return None
|
|
907
908
|
return await self._blob_text(client, blob_sha)
|
|
908
909
|
|
|
910
|
+
async def list_commit_notes(
|
|
911
|
+
self,
|
|
912
|
+
ctx: PluginContext,
|
|
913
|
+
credentials: dict[str, str],
|
|
914
|
+
namespace: str,
|
|
915
|
+
) -> NotesListing:
|
|
916
|
+
"""Every note on ``refs/notes/<namespace>`` at its current tip.
|
|
917
|
+
|
|
918
|
+
Two Git Data calls to reach the tree, then one blob read per
|
|
919
|
+
note. A missing ref answers an empty, complete listing.
|
|
920
|
+
|
|
921
|
+
``complete`` compares what the tree holds against what came
|
|
922
|
+
back: :meth:`_all_notes` drops a note whose blob it cannot read
|
|
923
|
+
(logging why), and a truncated tree listing hides notes before
|
|
924
|
+
that. Either way the caller must not treat the result as the
|
|
925
|
+
whole ref.
|
|
926
|
+
"""
|
|
927
|
+
async with self._client(ctx, credentials) as client:
|
|
928
|
+
tip = await self._notes_ref_tip(client, namespace)
|
|
929
|
+
if tip is None:
|
|
930
|
+
return NotesListing({}, True)
|
|
931
|
+
return await self._all_notes(client, tip)
|
|
932
|
+
|
|
909
933
|
async def diff_commit_notes(
|
|
910
934
|
self,
|
|
911
935
|
ctx: PluginContext,
|
|
@@ -934,14 +958,14 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
934
958
|
"""
|
|
935
959
|
async with self._client(ctx, credentials) as client:
|
|
936
960
|
if before == _ZERO_SHA:
|
|
937
|
-
return await self._all_notes(client, after)
|
|
961
|
+
return (await self._all_notes(client, after)).notes
|
|
938
962
|
quoted = urllib.parse.quote(f'{before}...{after}', safe='.')
|
|
939
963
|
resp = await client.get(f'/compare/{quoted}')
|
|
940
964
|
if resp.status_code == 404:
|
|
941
965
|
# ``before`` was garbage-collected or the ref history
|
|
942
966
|
# was rewritten; fall back to the full tree at ``after``
|
|
943
967
|
# so the push still lands rather than being dropped.
|
|
944
|
-
return await self._all_notes(client, after)
|
|
968
|
+
return (await self._all_notes(client, after)).notes
|
|
945
969
|
resp.raise_for_status()
|
|
946
970
|
payload = typing.cast('dict[str, typing.Any]', resp.json())
|
|
947
971
|
files: list[dict[str, typing.Any]] = payload.get('files') or []
|
|
@@ -959,7 +983,7 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
959
983
|
after,
|
|
960
984
|
_COMPARE_FILES_CAP,
|
|
961
985
|
)
|
|
962
|
-
return await self._all_notes(client, after)
|
|
986
|
+
return (await self._all_notes(client, after)).notes
|
|
963
987
|
out: dict[str, str | None] = {}
|
|
964
988
|
for item in files:
|
|
965
989
|
status = str(item.get('status') or '')
|
|
@@ -997,30 +1021,50 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
997
1021
|
out[annotated] = body
|
|
998
1022
|
return out
|
|
999
1023
|
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1024
|
+
@staticmethod
|
|
1025
|
+
async def _notes_ref_tip(
|
|
1026
|
+
client: httpx.AsyncClient, namespace: str
|
|
1027
|
+
) -> str | None:
|
|
1028
|
+
"""The commit ``refs/notes/<namespace>`` points at, or ``None``."""
|
|
1004
1029
|
ref = await client.get(
|
|
1005
1030
|
f'/git/ref/{urllib.parse.quote(f"notes/{namespace}", safe="/")}'
|
|
1006
1031
|
)
|
|
1007
1032
|
if ref.status_code == 404:
|
|
1008
1033
|
return None
|
|
1009
1034
|
ref.raise_for_status()
|
|
1010
|
-
|
|
1011
|
-
|
|
1035
|
+
return str(ref.json()['object']['sha'])
|
|
1036
|
+
|
|
1037
|
+
async def _notes_tree(
|
|
1038
|
+
self, client: httpx.AsyncClient, namespace: str
|
|
1039
|
+
) -> dict[str, str] | None:
|
|
1040
|
+
"""Map annotated full SHA -> note blob SHA, or ``None`` sans ref."""
|
|
1041
|
+
tip = await self._notes_ref_tip(client, namespace)
|
|
1042
|
+
if tip is None:
|
|
1043
|
+
return None
|
|
1044
|
+
# A truncated tree can only cost this caller a note it then
|
|
1045
|
+
# reports as absent, which is already its answer for "no note".
|
|
1046
|
+
notes, _complete = await self._tree_notes(client, tip)
|
|
1047
|
+
return notes
|
|
1012
1048
|
|
|
1013
1049
|
async def _tree_notes(
|
|
1014
1050
|
self, client: httpx.AsyncClient, commit_sha: str
|
|
1015
|
-
) -> dict[str, str]:
|
|
1016
|
-
"""Flatten one notes-ref commit's tree to annotated SHA -> blob.
|
|
1051
|
+
) -> tuple[dict[str, str], bool]:
|
|
1052
|
+
"""Flatten one notes-ref commit's tree to annotated SHA -> blob.
|
|
1053
|
+
|
|
1054
|
+
Also answers whether the tree listing was whole. GitHub
|
|
1055
|
+
truncates a large recursive tree, and the truncated map is
|
|
1056
|
+
indistinguishable from a small complete one, so the flag has to
|
|
1057
|
+
travel with it -- a caller that persists "backfill finished"
|
|
1058
|
+
would otherwise do so over notes it never saw.
|
|
1059
|
+
"""
|
|
1017
1060
|
commit = await client.get(f'/git/commits/{commit_sha}')
|
|
1018
1061
|
commit.raise_for_status()
|
|
1019
1062
|
tree_sha = str(commit.json()['tree']['sha'])
|
|
1020
1063
|
tree = await client.get(f'/git/trees/{tree_sha}?recursive=1')
|
|
1021
1064
|
tree.raise_for_status()
|
|
1022
1065
|
payload = typing.cast('dict[str, typing.Any]', tree.json())
|
|
1023
|
-
|
|
1066
|
+
truncated = bool(payload.get('truncated'))
|
|
1067
|
+
if truncated:
|
|
1024
1068
|
LOGGER.warning(
|
|
1025
1069
|
'Notes tree %s is truncated; some notes will be missed',
|
|
1026
1070
|
tree_sha,
|
|
@@ -1033,18 +1077,22 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1033
1077
|
annotated = _note_sha(entry.get('path'))
|
|
1034
1078
|
if annotated is not None:
|
|
1035
1079
|
out[annotated] = str(entry['sha'])
|
|
1036
|
-
return out
|
|
1080
|
+
return out, not truncated
|
|
1037
1081
|
|
|
1038
1082
|
async def _all_notes(
|
|
1039
1083
|
self, client: httpx.AsyncClient, commit_sha: str
|
|
1040
|
-
) ->
|
|
1084
|
+
) -> NotesListing:
|
|
1041
1085
|
"""Every note at one notes-ref commit, bodies included.
|
|
1042
1086
|
|
|
1043
1087
|
Blob reads run a few at a time (one request per note) and an
|
|
1044
1088
|
unreadable note is skipped rather than failing the batch or
|
|
1045
1089
|
recording a false "removed".
|
|
1090
|
+
|
|
1091
|
+
``complete`` combines the two ways this can fall short: a
|
|
1092
|
+
truncated tree listing, and a blob that would not read. Either
|
|
1093
|
+
means the map is not the whole ref.
|
|
1046
1094
|
"""
|
|
1047
|
-
notes = await self._tree_notes(client, commit_sha)
|
|
1095
|
+
notes, tree_complete = await self._tree_notes(client, commit_sha)
|
|
1048
1096
|
gate = asyncio.Semaphore(_NOTE_BLOB_CONCURRENCY)
|
|
1049
1097
|
|
|
1050
1098
|
async def _read(blob_sha: str) -> str | BaseException | None:
|
|
@@ -1072,7 +1120,7 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1072
1120
|
# a ``None`` here would read as "note removed".
|
|
1073
1121
|
continue
|
|
1074
1122
|
out[annotated] = body
|
|
1075
|
-
return out
|
|
1123
|
+
return NotesListing(out, tree_complete and len(out) == len(notes))
|
|
1076
1124
|
|
|
1077
1125
|
@staticmethod
|
|
1078
1126
|
async def _blob_text(
|
{imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/lifecycle.py
RENAMED
|
@@ -60,8 +60,10 @@ from imbi.common.plugins.errors import PluginAuthenticationFailed
|
|
|
60
60
|
from imbi.common.plugins.templates import expand_template
|
|
61
61
|
from imbi.plugins.github._hosts import flavor_host, host_to_api_base
|
|
62
62
|
from imbi.plugins.github._repos import (
|
|
63
|
+
RepositoryTarget,
|
|
63
64
|
derive_owner_repo_from_links,
|
|
64
65
|
resolve_owner_repo,
|
|
66
|
+
resolve_repository_target,
|
|
65
67
|
)
|
|
66
68
|
|
|
67
69
|
LOGGER = logging.getLogger(__name__)
|
|
@@ -79,6 +81,41 @@ _TRANSFER_ARCHIVE_BACKOFFS: tuple[float, ...] = (0.5, 1.0, 2.0)
|
|
|
79
81
|
# is only meaningful on a GHEC tenant or GHES appliance.
|
|
80
82
|
_VISIBILITIES = frozenset({'private', 'internal', 'public'})
|
|
81
83
|
|
|
84
|
+
# Emitted identically by create and by the update-path upsert, so the
|
|
85
|
+
# same misconfiguration reads the same way whichever event hit it.
|
|
86
|
+
_NO_CREATE_ORG = (
|
|
87
|
+
'No target org configured for project creation; set the '
|
|
88
|
+
"plugin's create_org or org_mapping option"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# GitHub's cap on a repo description. Imbi's own project description
|
|
92
|
+
# field has no limit, and GitHub rejects the *whole* request when the
|
|
93
|
+
# value is longer -- so an over-long description cost the repo entirely
|
|
94
|
+
# until this was clamped (issue #254).
|
|
95
|
+
_MAX_DESCRIPTION_CHARS = 350
|
|
96
|
+
_DESCRIPTION_ELLIPSIS = '\u2026'
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _normalize_description(value: str | None) -> str | None:
|
|
100
|
+
"""Clamp a project description to GitHub's repo-description limit.
|
|
101
|
+
|
|
102
|
+
Truncates on a word boundary where one exists in the last word of
|
|
103
|
+
the budget, and marks the cut with an ellipsis. Losing the tail is
|
|
104
|
+
acceptable because the full text stays in Imbi and the repo's
|
|
105
|
+
``homepage`` links back to it; losing the repo is not.
|
|
106
|
+
|
|
107
|
+
``None`` passes through unchanged so the caller can still tell
|
|
108
|
+
"unknown" from "empty" -- see :meth:`_patch_repo_attrs`.
|
|
109
|
+
"""
|
|
110
|
+
if value is None or len(value) <= _MAX_DESCRIPTION_CHARS:
|
|
111
|
+
return value
|
|
112
|
+
budget = _MAX_DESCRIPTION_CHARS - len(_DESCRIPTION_ELLIPSIS)
|
|
113
|
+
candidate = value[:budget]
|
|
114
|
+
head, separator, _ = candidate.rpartition(' ')
|
|
115
|
+
if separator and head.strip():
|
|
116
|
+
candidate = head
|
|
117
|
+
return candidate.rstrip() + _DESCRIPTION_ELLIPSIS
|
|
118
|
+
|
|
82
119
|
|
|
83
120
|
def _error_detail(response: httpx.Response) -> str:
|
|
84
121
|
"""Flatten a GitHub error body into one operator-facing string.
|
|
@@ -249,13 +286,7 @@ class GitHubLifecycle(LifecycleCapability):
|
|
|
249
286
|
) -> LifecycleResult:
|
|
250
287
|
target_org = self._resolve_create_org(ctx)
|
|
251
288
|
if not target_org:
|
|
252
|
-
return LifecycleResult(
|
|
253
|
-
status='skipped',
|
|
254
|
-
message=(
|
|
255
|
-
'No target org configured for project creation; set '
|
|
256
|
-
"the plugin's ``create_org`` or ``org_mapping`` option"
|
|
257
|
-
),
|
|
258
|
-
)
|
|
289
|
+
return LifecycleResult(status='skipped', message=_NO_CREATE_ORG)
|
|
259
290
|
host = self._resolve_host(ctx)
|
|
260
291
|
async with self._client(ctx, credentials) as client:
|
|
261
292
|
existing = await self._get_repo_or_none(
|
|
@@ -294,54 +325,135 @@ class GitHubLifecycle(LifecycleCapability):
|
|
|
294
325
|
host = self._resolve_host(ctx)
|
|
295
326
|
# ``prefer_previous_slug`` so a slug rename still locates the
|
|
296
327
|
# pre-rename repo on GitHub when the project has no stored link.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
328
|
+
try:
|
|
329
|
+
target = resolve_repository_target(
|
|
330
|
+
ctx,
|
|
331
|
+
host,
|
|
332
|
+
'GitHub lifecycle plugin',
|
|
333
|
+
prefer_previous_slug=True,
|
|
334
|
+
)
|
|
335
|
+
except ValueError:
|
|
336
|
+
# No link and no project type: there is nothing to look up,
|
|
337
|
+
# so provisioning from configuration is the only action left.
|
|
338
|
+
# This is the state a project whose first repository creation
|
|
339
|
+
# failed is left in when it carries no ProjectType either --
|
|
340
|
+
# the very case ``POST /lifecycle/sync`` has to repair
|
|
341
|
+
# (issue #254, defect 2).
|
|
342
|
+
target = None
|
|
303
343
|
async with self._client(ctx, credentials) as client:
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
#
|
|
308
|
-
#
|
|
309
|
-
|
|
310
|
-
|
|
344
|
+
if target is None:
|
|
345
|
+
return await self._provision_missing(ctx, client, host, None)
|
|
346
|
+
# ``_get_repo_or_none`` rather than ``_get_repo``: a genuine
|
|
347
|
+
# 404 means the remote is missing and this call provisions
|
|
348
|
+
# it, while every other status still raises so an auth or
|
|
349
|
+
# permission failure can't be mistaken for absence.
|
|
350
|
+
current = await self._get_repo_or_none(
|
|
351
|
+
client, target.owner, target.repo
|
|
311
352
|
)
|
|
353
|
+
if current is None:
|
|
354
|
+
return await self._provision_missing(ctx, client, host, target)
|
|
355
|
+
current_owner = self._current_owner(current, target.owner)
|
|
356
|
+
current_repo = self._current_repo(current, target.repo)
|
|
312
357
|
patched = await self._patch_repo_attrs(
|
|
313
358
|
client,
|
|
314
359
|
current_owner,
|
|
315
360
|
current_repo,
|
|
316
361
|
name=ctx.project_slug,
|
|
317
|
-
description=ctx.project_description
|
|
318
|
-
homepage=ctx.project_ui_url
|
|
362
|
+
description=ctx.project_description,
|
|
363
|
+
homepage=ctx.project_ui_url,
|
|
319
364
|
)
|
|
320
365
|
new_repo = str(patched.get('name') or current_repo)
|
|
321
|
-
#
|
|
322
|
-
#
|
|
323
|
-
# the
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
366
|
+
# Record on every success, not only on a rename. Recording
|
|
367
|
+
# is idempotent, and skipping it on the ordinary path left
|
|
368
|
+
# the ``EXISTS_IN`` edge and the repo link permanently
|
|
369
|
+
# unwritten for any project not provisioned by
|
|
370
|
+
# ``on_project_created`` (issue #254, defect 3).
|
|
371
|
+
#
|
|
372
|
+
# One call, so the rename provenance can't be clobbered by a
|
|
373
|
+
# second: ``old`` is what Imbi believed, which covers both a
|
|
374
|
+
# rename we asked for and one that happened outside Imbi.
|
|
375
|
+
old_owner_repo = f'{target.owner}/{target.repo}'
|
|
376
|
+
new_owner_repo = f'{current_owner}/{new_repo}'
|
|
377
|
+
renamed = new_owner_repo.lower() != old_owner_repo.lower()
|
|
378
|
+
new_url = self._record_repo(
|
|
379
|
+
ctx,
|
|
380
|
+
host,
|
|
381
|
+
current_owner,
|
|
382
|
+
new_repo,
|
|
383
|
+
patched,
|
|
384
|
+
old_owner_repo=old_owner_repo if renamed else None,
|
|
385
|
+
new_owner_repo=new_owner_repo if renamed else None,
|
|
386
|
+
)
|
|
339
387
|
return LifecycleResult(
|
|
340
388
|
status='ok',
|
|
341
389
|
message=f'Updated {current_owner}/{new_repo}',
|
|
342
390
|
artifacts={'repo_url': new_url},
|
|
343
391
|
)
|
|
344
392
|
|
|
393
|
+
async def _provision_missing(
|
|
394
|
+
self,
|
|
395
|
+
ctx: PluginContext,
|
|
396
|
+
client: httpx.AsyncClient,
|
|
397
|
+
host: str,
|
|
398
|
+
target: RepositoryTarget | None,
|
|
399
|
+
) -> LifecycleResult:
|
|
400
|
+
"""Create the repo an update found missing, or refuse to.
|
|
401
|
+
|
|
402
|
+
``on_project_created`` is dispatched from exactly one place -- the
|
|
403
|
+
project-create endpoint -- so a project whose creation failed had
|
|
404
|
+
no API-driven way back: every later update 404'd and never
|
|
405
|
+
created (issue #254, defect 2). An update is the only event that
|
|
406
|
+
can repair it, which is what ``POST /lifecycle/sync`` documents.
|
|
407
|
+
|
|
408
|
+
The creation org comes from ``create_org`` / ``org_mapping``, the
|
|
409
|
+
same resolver ``on_project_created`` uses, and from nowhere else.
|
|
410
|
+
In particular never from ``target.owner`` on the fallback path:
|
|
411
|
+
that owner is a project-type slug read as a GitHub org, so
|
|
412
|
+
provisioning there would invent a repo in an org nobody
|
|
413
|
+
configured.
|
|
414
|
+
|
|
415
|
+
A stored link that disagrees with the configured org is refused
|
|
416
|
+
rather than reconciled. Creating at the new org while the link
|
|
417
|
+
still names the old one is an org migration, which needs to be
|
|
418
|
+
someone's decision -- and silently making it would leave a
|
|
419
|
+
duplicate repo behind with automation still pointed at the
|
|
420
|
+
original. The comparison folds case, as every other org
|
|
421
|
+
comparison here does, because GitHub org names are
|
|
422
|
+
case-insensitive: ``Aweber-APIs`` and ``aweber-apis`` are one
|
|
423
|
+
org, not a migration.
|
|
424
|
+
|
|
425
|
+
``target`` is ``None`` when nothing resolved at all -- no link
|
|
426
|
+
and no project type. There is no stored belief to disagree
|
|
427
|
+
with, so the org check has nothing to check.
|
|
428
|
+
"""
|
|
429
|
+
create_org = self._resolve_create_org(ctx)
|
|
430
|
+
if not create_org:
|
|
431
|
+
return LifecycleResult(status='skipped', message=_NO_CREATE_ORG)
|
|
432
|
+
if (
|
|
433
|
+
target is not None
|
|
434
|
+
and target.source == 'link'
|
|
435
|
+
and target.owner.lower() != create_org.lower()
|
|
436
|
+
):
|
|
437
|
+
return LifecycleResult(
|
|
438
|
+
status='failed',
|
|
439
|
+
message=(
|
|
440
|
+
f'Repository link points at {target.owner}/'
|
|
441
|
+
f'{target.repo}, which does not exist, but '
|
|
442
|
+
f'provisioning resolves to {create_org}. Refusing '
|
|
443
|
+
'to create: changing orgs is a migration and needs '
|
|
444
|
+
'to be done deliberately.'
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
created = await self._create_repo(client, create_org, ctx)
|
|
448
|
+
html_url = self._record_repo(
|
|
449
|
+
ctx, host, create_org, ctx.project_slug, created
|
|
450
|
+
)
|
|
451
|
+
return LifecycleResult(
|
|
452
|
+
status='ok',
|
|
453
|
+
message=f'Created {create_org}/{ctx.project_slug}',
|
|
454
|
+
artifacts={'repo_url': html_url},
|
|
455
|
+
)
|
|
456
|
+
|
|
345
457
|
async def on_project_deleted(
|
|
346
458
|
self,
|
|
347
459
|
ctx: PluginContext,
|
|
@@ -663,8 +775,13 @@ class GitHubLifecycle(LifecycleCapability):
|
|
|
663
775
|
) -> dict[str, typing.Any] | None:
|
|
664
776
|
"""Read a repo, returning ``None`` on 404 instead of raising.
|
|
665
777
|
|
|
666
|
-
|
|
667
|
-
|
|
778
|
+
Only a genuine 404 answers ``None``; any other status is a real
|
|
779
|
+
failure and is re-raised, so "cannot see it" is never mistaken
|
|
780
|
+
for "it is not there". Three callers depend on that:
|
|
781
|
+
:meth:`on_project_created` for its idempotency check,
|
|
782
|
+
:meth:`on_project_updated` to detect a remote it must
|
|
783
|
+
provision, and :meth:`_create_repo` to reconcile a 422 that
|
|
784
|
+
turns out to be a create race.
|
|
668
785
|
"""
|
|
669
786
|
resp = await client.get(f'/repos/{owner}/{repo}')
|
|
670
787
|
if resp.status_code == 404:
|
|
@@ -683,7 +800,8 @@ class GitHubLifecycle(LifecycleCapability):
|
|
|
683
800
|
f'/orgs/{org}/repos',
|
|
684
801
|
json={
|
|
685
802
|
'name': ctx.project_slug,
|
|
686
|
-
'description': ctx.project_description
|
|
803
|
+
'description': _normalize_description(ctx.project_description)
|
|
804
|
+
or '',
|
|
687
805
|
'homepage': ctx.project_ui_url or '',
|
|
688
806
|
# ``visibility`` is authoritative where it's supported;
|
|
689
807
|
# ``private`` rides along so a host that only honors the
|
|
@@ -693,6 +811,18 @@ class GitHubLifecycle(LifecycleCapability):
|
|
|
693
811
|
'private': visibility != 'public',
|
|
694
812
|
},
|
|
695
813
|
)
|
|
814
|
+
if resp.status_code == 422:
|
|
815
|
+
# 422 covers every validation failure, "name already exists"
|
|
816
|
+
# among them -- so ask about this exact repo instead of
|
|
817
|
+
# pattern-matching the message. A repo created between the
|
|
818
|
+
# caller's 404 and this POST is reconciled rather than
|
|
819
|
+
# reported as a failure; anything else falls through to the
|
|
820
|
+
# error below with GitHub's own reason intact.
|
|
821
|
+
existing = await self._get_repo_or_none(
|
|
822
|
+
client, org, ctx.project_slug
|
|
823
|
+
)
|
|
824
|
+
if existing is not None:
|
|
825
|
+
return existing
|
|
696
826
|
try:
|
|
697
827
|
resp.raise_for_status()
|
|
698
828
|
except httpx.HTTPStatusError as exc:
|
|
@@ -712,23 +842,29 @@ class GitHubLifecycle(LifecycleCapability):
|
|
|
712
842
|
repo: str,
|
|
713
843
|
*,
|
|
714
844
|
name: str,
|
|
715
|
-
description: str,
|
|
716
|
-
homepage: str,
|
|
845
|
+
description: str | None,
|
|
846
|
+
homepage: str | None,
|
|
717
847
|
) -> dict[str, typing.Any]:
|
|
718
848
|
"""Sync name / description / homepage via a single PATCH.
|
|
719
849
|
|
|
720
850
|
One call covers all three sync fields so an update that touches
|
|
721
851
|
several is still a single GitHub round trip. ``raise_for_status``
|
|
722
852
|
on any non-2xx so the dispatcher captures the failure.
|
|
853
|
+
|
|
854
|
+
``None`` means "the caller doesn't know this value" and omits the
|
|
855
|
+
key, so a dispatch path that never populated
|
|
856
|
+
``ctx.project_description`` leaves the repo's own description
|
|
857
|
+
alone instead of clearing it. An empty string still rides
|
|
858
|
+
along, because that is a deliberate clear. The description is
|
|
859
|
+
clamped here rather than at the call site so create and update
|
|
860
|
+
cannot disagree about the limit.
|
|
723
861
|
"""
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
},
|
|
731
|
-
)
|
|
862
|
+
payload: dict[str, typing.Any] = {'name': name}
|
|
863
|
+
if description is not None:
|
|
864
|
+
payload['description'] = _normalize_description(description)
|
|
865
|
+
if homepage is not None:
|
|
866
|
+
payload['homepage'] = homepage
|
|
867
|
+
resp = await client.patch(f'/repos/{owner}/{repo}', json=payload)
|
|
732
868
|
resp.raise_for_status()
|
|
733
869
|
return typing.cast(dict[str, typing.Any], resp.json())
|
|
734
870
|
|
|
@@ -3015,6 +3015,96 @@ class GitNotesTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
3015
3015
|
)
|
|
3016
3016
|
self.assertEqual({other_sha: '{"drift_detected":false}'}, changed)
|
|
3017
3017
|
|
|
3018
|
+
@respx.mock
|
|
3019
|
+
async def test_list_commit_notes_reads_the_whole_ref(self) -> None:
|
|
3020
|
+
fanout = f'{self.FULL_SHA[:2]}/{self.FULL_SHA[2:]}'
|
|
3021
|
+
self._mock_tree(
|
|
3022
|
+
[
|
|
3023
|
+
{'type': 'blob', 'path': fanout, 'sha': 'b1'},
|
|
3024
|
+
{'type': 'tree', 'path': 'ab', 'sha': 'sub'},
|
|
3025
|
+
]
|
|
3026
|
+
)
|
|
3027
|
+
self._blob('b1', '{"drift_detected":true}')
|
|
3028
|
+
listing = await self.handler.list_commit_notes(
|
|
3029
|
+
_ctx(), _CREDS, 'imbi-drift'
|
|
3030
|
+
)
|
|
3031
|
+
# Fan-out subtree flattened back to the annotated full SHA.
|
|
3032
|
+
self.assertEqual(
|
|
3033
|
+
{self.FULL_SHA: '{"drift_detected":true}'}, listing.notes
|
|
3034
|
+
)
|
|
3035
|
+
self.assertTrue(listing.complete)
|
|
3036
|
+
|
|
3037
|
+
@respx.mock
|
|
3038
|
+
async def test_list_commit_notes_reports_an_unreadable_blob(self) -> None:
|
|
3039
|
+
# The readable note still comes back, but the listing says it is
|
|
3040
|
+
# not the whole ref, so a caller cannot record a finished
|
|
3041
|
+
# backfill over a note it never saw.
|
|
3042
|
+
other_sha = 'e' * 40
|
|
3043
|
+
self._mock_tree(
|
|
3044
|
+
[
|
|
3045
|
+
{'type': 'blob', 'path': self.FULL_SHA, 'sha': 'bad'},
|
|
3046
|
+
{'type': 'blob', 'path': other_sha, 'sha': 'b2'},
|
|
3047
|
+
]
|
|
3048
|
+
)
|
|
3049
|
+
respx.get(f'{self.REPO}/git/blobs/bad').mock(
|
|
3050
|
+
return_value=httpx.Response(500)
|
|
3051
|
+
)
|
|
3052
|
+
self._blob('b2', '{"drift_detected":false}')
|
|
3053
|
+
with self.assertLogs('imbi.plugins.github', level='WARNING'):
|
|
3054
|
+
listing = await self.handler.list_commit_notes(
|
|
3055
|
+
_ctx(), _CREDS, 'imbi-drift'
|
|
3056
|
+
)
|
|
3057
|
+
self.assertEqual(
|
|
3058
|
+
{other_sha: '{"drift_detected":false}'}, listing.notes
|
|
3059
|
+
)
|
|
3060
|
+
self.assertFalse(listing.complete)
|
|
3061
|
+
|
|
3062
|
+
@respx.mock
|
|
3063
|
+
async def test_list_commit_notes_reports_a_truncated_tree(self) -> None:
|
|
3064
|
+
# Every note in the (partial) tree read fine, so blob-read
|
|
3065
|
+
# completeness alone would call this whole. The tree itself says
|
|
3066
|
+
# otherwise.
|
|
3067
|
+
respx.get(f'{self.REPO}/git/ref/notes/imbi-drift').mock(
|
|
3068
|
+
return_value=httpx.Response(
|
|
3069
|
+
200, json={'object': {'sha': 'notes-tip'}}
|
|
3070
|
+
)
|
|
3071
|
+
)
|
|
3072
|
+
respx.get(f'{self.REPO}/git/commits/notes-tip').mock(
|
|
3073
|
+
return_value=httpx.Response(200, json={'tree': {'sha': 't1'}})
|
|
3074
|
+
)
|
|
3075
|
+
respx.get(f'{self.REPO}/git/trees/t1').mock(
|
|
3076
|
+
return_value=httpx.Response(
|
|
3077
|
+
200,
|
|
3078
|
+
json={
|
|
3079
|
+
'tree': [
|
|
3080
|
+
{'type': 'blob', 'path': self.FULL_SHA, 'sha': 'b1'}
|
|
3081
|
+
],
|
|
3082
|
+
'truncated': True,
|
|
3083
|
+
},
|
|
3084
|
+
)
|
|
3085
|
+
)
|
|
3086
|
+
self._blob('b1', '{"drift_detected":true}')
|
|
3087
|
+
with self.assertLogs('imbi.plugins.github', level='WARNING'):
|
|
3088
|
+
listing = await self.handler.list_commit_notes(
|
|
3089
|
+
_ctx(), _CREDS, 'imbi-drift'
|
|
3090
|
+
)
|
|
3091
|
+
self.assertEqual(
|
|
3092
|
+
{self.FULL_SHA: '{"drift_detected":true}'}, listing.notes
|
|
3093
|
+
)
|
|
3094
|
+
self.assertFalse(listing.complete)
|
|
3095
|
+
|
|
3096
|
+
@respx.mock
|
|
3097
|
+
async def test_list_commit_notes_without_the_ref_is_empty(self) -> None:
|
|
3098
|
+
respx.get(f'{self.REPO}/git/ref/notes/imbi-drift').mock(
|
|
3099
|
+
return_value=httpx.Response(404)
|
|
3100
|
+
)
|
|
3101
|
+
listing = await self.handler.list_commit_notes(
|
|
3102
|
+
_ctx(), _CREDS, 'imbi-drift'
|
|
3103
|
+
)
|
|
3104
|
+
# Empty but complete: "no notes" is the whole truth here.
|
|
3105
|
+
self.assertEqual({}, listing.notes)
|
|
3106
|
+
self.assertTrue(listing.complete)
|
|
3107
|
+
|
|
3018
3108
|
@respx.mock
|
|
3019
3109
|
async def test_diff_commit_notes_zero_before_lists_everything(
|
|
3020
3110
|
self,
|
|
@@ -19,7 +19,11 @@ from imbi.common.plugins.base import (
|
|
|
19
19
|
PluginContext,
|
|
20
20
|
)
|
|
21
21
|
from imbi.common.plugins.errors import PluginAuthenticationFailed
|
|
22
|
-
from imbi.plugins.github.lifecycle import
|
|
22
|
+
from imbi.plugins.github.lifecycle import (
|
|
23
|
+
_MAX_DESCRIPTION_CHARS,
|
|
24
|
+
GitHubLifecycle,
|
|
25
|
+
_normalize_description,
|
|
26
|
+
)
|
|
23
27
|
from imbi.plugins.github.plugin import GitHubPlugin
|
|
24
28
|
|
|
25
29
|
|
|
@@ -1169,7 +1173,82 @@ class UpdateTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
1169
1173
|
self.assertIn(
|
|
1170
1174
|
b'"homepage":"https://imbi.example.com/projects/p"', body
|
|
1171
1175
|
)
|
|
1172
|
-
|
|
1176
|
+
# Recorded even though nothing was renamed -- this assertion
|
|
1177
|
+
# used to require the opposite, which is what left the repo link
|
|
1178
|
+
# unwritten on the ordinary path (issue #254, defect 3).
|
|
1179
|
+
assert ctx.link_writeback is not None
|
|
1180
|
+
self.assertEqual(
|
|
1181
|
+
ctx.link_writeback.new_url, 'https://github.com/octo/demo'
|
|
1182
|
+
)
|
|
1183
|
+
self.assertIsNone(ctx.link_writeback.old_owner_repo)
|
|
1184
|
+
|
|
1185
|
+
@respx.mock
|
|
1186
|
+
async def test_update_omits_unknown_description_and_homepage(
|
|
1187
|
+
self,
|
|
1188
|
+
) -> None:
|
|
1189
|
+
# The ``/lifecycle/sync`` dispatch path leaves
|
|
1190
|
+
# ``project_description`` / ``project_ui_url`` unpopulated. An
|
|
1191
|
+
# unknown value must be absent from the PATCH body -- sending
|
|
1192
|
+
# ``""`` would wipe whatever the repo had (issue #254, defect 4).
|
|
1193
|
+
respx.get('https://api.github.com/repos/octo/demo').mock(
|
|
1194
|
+
return_value=httpx.Response(
|
|
1195
|
+
200,
|
|
1196
|
+
json={'name': 'demo', 'owner': {'login': 'octo'}},
|
|
1197
|
+
)
|
|
1198
|
+
)
|
|
1199
|
+
patch_route = respx.patch(
|
|
1200
|
+
'https://api.github.com/repos/octo/demo'
|
|
1201
|
+
).mock(
|
|
1202
|
+
return_value=httpx.Response(
|
|
1203
|
+
200,
|
|
1204
|
+
json={
|
|
1205
|
+
'name': 'demo',
|
|
1206
|
+
'html_url': 'https://github.com/octo/demo',
|
|
1207
|
+
'owner': {'login': 'octo'},
|
|
1208
|
+
},
|
|
1209
|
+
)
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
ctx = _ctx(project_description=None, project_ui_url=None)
|
|
1213
|
+
plugin = GitHubLifecycle()
|
|
1214
|
+
result = await plugin.on_project_updated(ctx, _CREDS)
|
|
1215
|
+
|
|
1216
|
+
self.assertEqual(result.status, 'ok')
|
|
1217
|
+
body = json.loads(patch_route.calls.last.request.read())
|
|
1218
|
+
self.assertEqual(body, {'name': 'demo'})
|
|
1219
|
+
|
|
1220
|
+
@respx.mock
|
|
1221
|
+
async def test_update_sends_empty_description_when_cleared(self) -> None:
|
|
1222
|
+
# An explicit empty string is a deliberate clear and must still
|
|
1223
|
+
# reach GitHub -- only ``None`` (unknown) is omitted.
|
|
1224
|
+
respx.get('https://api.github.com/repos/octo/demo').mock(
|
|
1225
|
+
return_value=httpx.Response(
|
|
1226
|
+
200,
|
|
1227
|
+
json={'name': 'demo', 'owner': {'login': 'octo'}},
|
|
1228
|
+
)
|
|
1229
|
+
)
|
|
1230
|
+
patch_route = respx.patch(
|
|
1231
|
+
'https://api.github.com/repos/octo/demo'
|
|
1232
|
+
).mock(
|
|
1233
|
+
return_value=httpx.Response(
|
|
1234
|
+
200,
|
|
1235
|
+
json={
|
|
1236
|
+
'name': 'demo',
|
|
1237
|
+
'html_url': 'https://github.com/octo/demo',
|
|
1238
|
+
'owner': {'login': 'octo'},
|
|
1239
|
+
},
|
|
1240
|
+
)
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
ctx = _ctx(project_description='', project_ui_url='')
|
|
1244
|
+
plugin = GitHubLifecycle()
|
|
1245
|
+
result = await plugin.on_project_updated(ctx, _CREDS)
|
|
1246
|
+
|
|
1247
|
+
self.assertEqual(result.status, 'ok')
|
|
1248
|
+
body = json.loads(patch_route.calls.last.request.read())
|
|
1249
|
+
self.assertEqual(
|
|
1250
|
+
body, {'name': 'demo', 'description': '', 'homepage': ''}
|
|
1251
|
+
)
|
|
1173
1252
|
|
|
1174
1253
|
@respx.mock
|
|
1175
1254
|
async def test_update_records_writeback_when_slug_changes(self) -> None:
|
|
@@ -1381,3 +1460,390 @@ class ResolveRelocationTargetTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
1381
1460
|
plugin = GitHubLifecycle()
|
|
1382
1461
|
target = await plugin.resolve_relocation_target(ctx, _CREDS)
|
|
1383
1462
|
self.assertIsNone(target)
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
class NormalizeDescriptionTestCase(unittest.TestCase):
|
|
1466
|
+
"""``_normalize_description`` clamps to GitHub's 350-char cap."""
|
|
1467
|
+
|
|
1468
|
+
def test_none_passes_through(self) -> None:
|
|
1469
|
+
# ``None`` is "unknown", not "empty" -- collapsing the two is
|
|
1470
|
+
# what wiped repo descriptions in issue #254.
|
|
1471
|
+
self.assertIsNone(_normalize_description(None))
|
|
1472
|
+
|
|
1473
|
+
def test_empty_string_passes_through(self) -> None:
|
|
1474
|
+
self.assertEqual(_normalize_description(''), '')
|
|
1475
|
+
|
|
1476
|
+
def test_exactly_at_the_limit_is_untouched(self) -> None:
|
|
1477
|
+
value = 'a' * _MAX_DESCRIPTION_CHARS
|
|
1478
|
+
self.assertEqual(_normalize_description(value), value)
|
|
1479
|
+
|
|
1480
|
+
def test_one_over_the_limit_is_clamped(self) -> None:
|
|
1481
|
+
value = 'a' * (_MAX_DESCRIPTION_CHARS + 1)
|
|
1482
|
+
result = _normalize_description(value)
|
|
1483
|
+
assert result is not None
|
|
1484
|
+
self.assertEqual(len(result), _MAX_DESCRIPTION_CHARS)
|
|
1485
|
+
self.assertTrue(result.endswith('\u2026'))
|
|
1486
|
+
|
|
1487
|
+
def test_clamps_on_a_word_boundary(self) -> None:
|
|
1488
|
+
value = ('word ' * 100).strip()
|
|
1489
|
+
result = _normalize_description(value)
|
|
1490
|
+
assert result is not None
|
|
1491
|
+
self.assertLessEqual(len(result), _MAX_DESCRIPTION_CHARS)
|
|
1492
|
+
self.assertTrue(result.endswith('word\u2026'))
|
|
1493
|
+
self.assertNotIn('wor\u2026', result)
|
|
1494
|
+
|
|
1495
|
+
def test_single_token_longer_than_the_limit_is_hard_cut(self) -> None:
|
|
1496
|
+
# No space to break on, so the ellipsis lands mid-token rather
|
|
1497
|
+
# than the function returning nothing.
|
|
1498
|
+
value = 'x' * 500
|
|
1499
|
+
result = _normalize_description(value)
|
|
1500
|
+
self.assertEqual(result, 'x' * (_MAX_DESCRIPTION_CHARS - 1) + '\u2026')
|
|
1501
|
+
|
|
1502
|
+
def test_counts_characters_not_bytes(self) -> None:
|
|
1503
|
+
# GitHub's limit is on characters; a multi-byte description at
|
|
1504
|
+
# the limit must survive untouched.
|
|
1505
|
+
value = '\u00e9' * _MAX_DESCRIPTION_CHARS
|
|
1506
|
+
self.assertEqual(_normalize_description(value), value)
|
|
1507
|
+
|
|
1508
|
+
def test_strips_whitespace_before_the_ellipsis(self) -> None:
|
|
1509
|
+
value = 'a' * 340 + ' ' + 'b' * 50
|
|
1510
|
+
result = _normalize_description(value)
|
|
1511
|
+
assert result is not None
|
|
1512
|
+
self.assertEqual(result, 'a' * 340 + '\u2026')
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
class DescriptionClampTestCase(unittest.IsolatedAsyncioTestCase):
|
|
1516
|
+
"""Both the create and the update path clamp the description."""
|
|
1517
|
+
|
|
1518
|
+
@respx.mock
|
|
1519
|
+
async def test_create_clamps_an_over_long_description(self) -> None:
|
|
1520
|
+
# An 817-char description used to fail the whole POST with
|
|
1521
|
+
# "description cannot be more than 350 characters", costing the
|
|
1522
|
+
# repo over a cosmetic field (issue #254, defect 1).
|
|
1523
|
+
create_route = respx.post(
|
|
1524
|
+
'https://api.github.com/orgs/aweber-apis/repos'
|
|
1525
|
+
).mock(
|
|
1526
|
+
return_value=httpx.Response(
|
|
1527
|
+
201,
|
|
1528
|
+
json={
|
|
1529
|
+
'id': 42,
|
|
1530
|
+
'name': 'demo',
|
|
1531
|
+
'html_url': 'https://github.com/aweber-apis/demo',
|
|
1532
|
+
'owner': {'login': 'aweber-apis'},
|
|
1533
|
+
},
|
|
1534
|
+
)
|
|
1535
|
+
)
|
|
1536
|
+
respx.get('https://api.github.com/repos/aweber-apis/demo').mock(
|
|
1537
|
+
return_value=httpx.Response(404)
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1540
|
+
ctx = _ctx(
|
|
1541
|
+
options={'create_org': 'aweber-apis'},
|
|
1542
|
+
project_links={},
|
|
1543
|
+
project_description='word ' * 200,
|
|
1544
|
+
)
|
|
1545
|
+
plugin = GitHubLifecycle()
|
|
1546
|
+
result = await plugin.on_project_created(ctx, _CREDS)
|
|
1547
|
+
|
|
1548
|
+
self.assertEqual(result.status, 'ok')
|
|
1549
|
+
body = json.loads(create_route.calls.last.request.read())
|
|
1550
|
+
self.assertLessEqual(len(body['description']), _MAX_DESCRIPTION_CHARS)
|
|
1551
|
+
self.assertTrue(body['description'].endswith('\u2026'))
|
|
1552
|
+
|
|
1553
|
+
@respx.mock
|
|
1554
|
+
async def test_update_clamps_an_over_long_description(self) -> None:
|
|
1555
|
+
respx.get('https://api.github.com/repos/octo/demo').mock(
|
|
1556
|
+
return_value=httpx.Response(
|
|
1557
|
+
200,
|
|
1558
|
+
json={'name': 'demo', 'owner': {'login': 'octo'}},
|
|
1559
|
+
)
|
|
1560
|
+
)
|
|
1561
|
+
patch_route = respx.patch(
|
|
1562
|
+
'https://api.github.com/repos/octo/demo'
|
|
1563
|
+
).mock(
|
|
1564
|
+
return_value=httpx.Response(
|
|
1565
|
+
200,
|
|
1566
|
+
json={
|
|
1567
|
+
'name': 'demo',
|
|
1568
|
+
'html_url': 'https://github.com/octo/demo',
|
|
1569
|
+
'owner': {'login': 'octo'},
|
|
1570
|
+
},
|
|
1571
|
+
)
|
|
1572
|
+
)
|
|
1573
|
+
|
|
1574
|
+
ctx = _ctx(project_description='word ' * 200)
|
|
1575
|
+
plugin = GitHubLifecycle()
|
|
1576
|
+
result = await plugin.on_project_updated(ctx, _CREDS)
|
|
1577
|
+
|
|
1578
|
+
self.assertEqual(result.status, 'ok')
|
|
1579
|
+
body = json.loads(patch_route.calls.last.request.read())
|
|
1580
|
+
self.assertLessEqual(len(body['description']), _MAX_DESCRIPTION_CHARS)
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
class UpdateUpsertTestCase(unittest.IsolatedAsyncioTestCase):
|
|
1584
|
+
"""``on_project_updated`` provisions a repo it finds missing.
|
|
1585
|
+
|
|
1586
|
+
``on_project_created`` is reachable only from the project-create
|
|
1587
|
+
endpoint, so before this an update was the sole event that could
|
|
1588
|
+
repair a failed creation -- and it 404'd instead (issue #254,
|
|
1589
|
+
defect 2).
|
|
1590
|
+
"""
|
|
1591
|
+
|
|
1592
|
+
@staticmethod
|
|
1593
|
+
def _created_response() -> httpx.Response:
|
|
1594
|
+
return httpx.Response(
|
|
1595
|
+
201,
|
|
1596
|
+
json={
|
|
1597
|
+
'id': 7,
|
|
1598
|
+
'name': 'demo',
|
|
1599
|
+
'html_url': 'https://github.com/aweber-apis/demo',
|
|
1600
|
+
'owner': {'login': 'aweber-apis'},
|
|
1601
|
+
},
|
|
1602
|
+
)
|
|
1603
|
+
|
|
1604
|
+
@respx.mock
|
|
1605
|
+
async def test_creates_when_the_repo_is_missing(self) -> None:
|
|
1606
|
+
respx.get('https://api.github.com/repos/aweber-apis/demo').mock(
|
|
1607
|
+
return_value=httpx.Response(404)
|
|
1608
|
+
)
|
|
1609
|
+
create_route = respx.post(
|
|
1610
|
+
'https://api.github.com/orgs/aweber-apis/repos'
|
|
1611
|
+
).mock(return_value=self._created_response())
|
|
1612
|
+
|
|
1613
|
+
ctx = _ctx(
|
|
1614
|
+
options={'create_org': 'aweber-apis'},
|
|
1615
|
+
project_links={
|
|
1616
|
+
'github-repository': 'https://github.com/aweber-apis/demo'
|
|
1617
|
+
},
|
|
1618
|
+
)
|
|
1619
|
+
plugin = GitHubLifecycle()
|
|
1620
|
+
result = await plugin.on_project_updated(ctx, _CREDS)
|
|
1621
|
+
|
|
1622
|
+
self.assertEqual(result.status, 'ok')
|
|
1623
|
+
self.assertEqual(create_route.calls.call_count, 1)
|
|
1624
|
+
self.assertEqual(
|
|
1625
|
+
result.artifacts['repo_url'],
|
|
1626
|
+
'https://github.com/aweber-apis/demo',
|
|
1627
|
+
)
|
|
1628
|
+
self.assertIsNotNone(ctx.link_writeback)
|
|
1629
|
+
|
|
1630
|
+
@respx.mock
|
|
1631
|
+
async def test_refuses_when_the_link_disagrees_with_the_org(
|
|
1632
|
+
self,
|
|
1633
|
+
) -> None:
|
|
1634
|
+
# Creating at the configured org while the link still names the
|
|
1635
|
+
# old one is an org migration, not a sync.
|
|
1636
|
+
respx.get('https://api.github.com/repos/old-org/demo').mock(
|
|
1637
|
+
return_value=httpx.Response(404)
|
|
1638
|
+
)
|
|
1639
|
+
create_route = respx.post(
|
|
1640
|
+
'https://api.github.com/orgs/new-org/repos'
|
|
1641
|
+
).mock(return_value=self._created_response())
|
|
1642
|
+
|
|
1643
|
+
ctx = _ctx(
|
|
1644
|
+
options={'create_org': 'new-org'},
|
|
1645
|
+
project_links={
|
|
1646
|
+
'github-repository': 'https://github.com/old-org/demo'
|
|
1647
|
+
},
|
|
1648
|
+
)
|
|
1649
|
+
plugin = GitHubLifecycle()
|
|
1650
|
+
result = await plugin.on_project_updated(ctx, _CREDS)
|
|
1651
|
+
|
|
1652
|
+
self.assertEqual(result.status, 'failed')
|
|
1653
|
+
self.assertEqual(create_route.calls.call_count, 0)
|
|
1654
|
+
assert result.message is not None
|
|
1655
|
+
self.assertIn('old-org/demo', result.message)
|
|
1656
|
+
self.assertIn('new-org', result.message)
|
|
1657
|
+
|
|
1658
|
+
@respx.mock
|
|
1659
|
+
async def test_creates_at_the_configured_org_not_the_type_slug(
|
|
1660
|
+
self,
|
|
1661
|
+
) -> None:
|
|
1662
|
+
# With no link the lookup owner is a *project type* slug read as
|
|
1663
|
+
# a GitHub org. It has no authority, so it must not veto
|
|
1664
|
+
# provisioning -- and must not be provisioned into either.
|
|
1665
|
+
respx.get('https://api.github.com/repos/api-service/demo').mock(
|
|
1666
|
+
return_value=httpx.Response(404)
|
|
1667
|
+
)
|
|
1668
|
+
create_route = respx.post(
|
|
1669
|
+
'https://api.github.com/orgs/aweber-apis/repos'
|
|
1670
|
+
).mock(return_value=self._created_response())
|
|
1671
|
+
|
|
1672
|
+
ctx = _ctx(
|
|
1673
|
+
options={'create_org': 'aweber-apis'},
|
|
1674
|
+
project_links={},
|
|
1675
|
+
project_type_slugs=['api-service'],
|
|
1676
|
+
)
|
|
1677
|
+
plugin = GitHubLifecycle()
|
|
1678
|
+
result = await plugin.on_project_updated(ctx, _CREDS)
|
|
1679
|
+
|
|
1680
|
+
self.assertEqual(result.status, 'ok')
|
|
1681
|
+
self.assertEqual(create_route.calls.call_count, 1)
|
|
1682
|
+
# The type slug was read once, to look for the repo, and never
|
|
1683
|
+
# used as a creation target.
|
|
1684
|
+
self.assertEqual(
|
|
1685
|
+
[
|
|
1686
|
+
call.request.url.path
|
|
1687
|
+
for call in respx.calls
|
|
1688
|
+
if 'api-service' in call.request.url.path
|
|
1689
|
+
],
|
|
1690
|
+
['/repos/api-service/demo'],
|
|
1691
|
+
)
|
|
1692
|
+
|
|
1693
|
+
@respx.mock
|
|
1694
|
+
async def test_creates_when_nothing_resolves_at_all(self) -> None:
|
|
1695
|
+
# No usable link and no project type, so there is no repo to
|
|
1696
|
+
# look up at all. This is where a failed first creation leaves
|
|
1697
|
+
# an untyped project, and the sync has to repair it rather than
|
|
1698
|
+
# report failed on an unresolvable target.
|
|
1699
|
+
create_route = respx.post(
|
|
1700
|
+
'https://api.github.com/orgs/aweber-apis/repos'
|
|
1701
|
+
).mock(return_value=self._created_response())
|
|
1702
|
+
|
|
1703
|
+
ctx = _ctx(
|
|
1704
|
+
options={'create_org': 'aweber-apis'},
|
|
1705
|
+
project_links={},
|
|
1706
|
+
project_type_slugs=[],
|
|
1707
|
+
)
|
|
1708
|
+
plugin = GitHubLifecycle()
|
|
1709
|
+
result = await plugin.on_project_updated(ctx, _CREDS)
|
|
1710
|
+
|
|
1711
|
+
self.assertEqual(result.status, 'ok')
|
|
1712
|
+
self.assertEqual(create_route.calls.call_count, 1)
|
|
1713
|
+
# Nothing was looked up, because there was no candidate to look
|
|
1714
|
+
# up -- not a 404 that happened to be tolerated.
|
|
1715
|
+
self.assertEqual(
|
|
1716
|
+
[
|
|
1717
|
+
call.request.method
|
|
1718
|
+
for call in respx.calls
|
|
1719
|
+
if call.request.method == 'GET'
|
|
1720
|
+
],
|
|
1721
|
+
[],
|
|
1722
|
+
)
|
|
1723
|
+
self.assertIsNotNone(ctx.link_writeback)
|
|
1724
|
+
|
|
1725
|
+
@respx.mock
|
|
1726
|
+
async def test_org_comparison_folds_case(self) -> None:
|
|
1727
|
+
# GitHub org names are case-insensitive, so a link recording
|
|
1728
|
+
# ``Aweber-APIs`` against a ``aweber-apis`` config names one org,
|
|
1729
|
+
# not a migration to refuse.
|
|
1730
|
+
respx.get('https://api.github.com/repos/Aweber-APIs/demo').mock(
|
|
1731
|
+
return_value=httpx.Response(404)
|
|
1732
|
+
)
|
|
1733
|
+
create_route = respx.post(
|
|
1734
|
+
'https://api.github.com/orgs/aweber-apis/repos'
|
|
1735
|
+
).mock(return_value=self._created_response())
|
|
1736
|
+
|
|
1737
|
+
ctx = _ctx(
|
|
1738
|
+
options={'create_org': 'aweber-apis'},
|
|
1739
|
+
project_links={
|
|
1740
|
+
'github-repository': 'https://github.com/Aweber-APIs/demo'
|
|
1741
|
+
},
|
|
1742
|
+
)
|
|
1743
|
+
plugin = GitHubLifecycle()
|
|
1744
|
+
result = await plugin.on_project_updated(ctx, _CREDS)
|
|
1745
|
+
|
|
1746
|
+
self.assertEqual(result.status, 'ok')
|
|
1747
|
+
self.assertEqual(create_route.calls.call_count, 1)
|
|
1748
|
+
|
|
1749
|
+
@respx.mock
|
|
1750
|
+
async def test_skips_when_no_org_is_configured(self) -> None:
|
|
1751
|
+
respx.get('https://api.github.com/repos/octo/demo').mock(
|
|
1752
|
+
return_value=httpx.Response(404)
|
|
1753
|
+
)
|
|
1754
|
+
|
|
1755
|
+
ctx = _ctx(options={})
|
|
1756
|
+
plugin = GitHubLifecycle()
|
|
1757
|
+
result = await plugin.on_project_updated(ctx, _CREDS)
|
|
1758
|
+
|
|
1759
|
+
# Same status and message ``on_project_created`` gives, so one
|
|
1760
|
+
# misconfiguration cannot read two ways.
|
|
1761
|
+
self.assertEqual(result.status, 'skipped')
|
|
1762
|
+
created = await plugin.on_project_created(_ctx(options={}), _CREDS)
|
|
1763
|
+
self.assertEqual(result.message, created.message)
|
|
1764
|
+
|
|
1765
|
+
@respx.mock
|
|
1766
|
+
async def test_non_404_lookup_does_not_provision(self) -> None:
|
|
1767
|
+
# A 403 means "cannot see it", not "it is not there".
|
|
1768
|
+
respx.get('https://api.github.com/repos/aweber-apis/demo').mock(
|
|
1769
|
+
return_value=httpx.Response(403, json={'message': 'nope'})
|
|
1770
|
+
)
|
|
1771
|
+
create_route = respx.post(
|
|
1772
|
+
'https://api.github.com/orgs/aweber-apis/repos'
|
|
1773
|
+
).mock(return_value=self._created_response())
|
|
1774
|
+
|
|
1775
|
+
ctx = _ctx(
|
|
1776
|
+
options={'create_org': 'aweber-apis'},
|
|
1777
|
+
project_links={
|
|
1778
|
+
'github-repository': 'https://github.com/aweber-apis/demo'
|
|
1779
|
+
},
|
|
1780
|
+
)
|
|
1781
|
+
plugin = GitHubLifecycle()
|
|
1782
|
+
with self.assertRaises(httpx.HTTPStatusError):
|
|
1783
|
+
await plugin.on_project_updated(ctx, _CREDS)
|
|
1784
|
+
self.assertEqual(create_route.calls.call_count, 0)
|
|
1785
|
+
|
|
1786
|
+
@respx.mock
|
|
1787
|
+
async def test_reconciles_a_create_race(self) -> None:
|
|
1788
|
+
# Someone created the repo between the 404 and the POST. GitHub
|
|
1789
|
+
# answers 422; the repo is adopted rather than reported failed.
|
|
1790
|
+
respx.get('https://api.github.com/repos/aweber-apis/demo').mock(
|
|
1791
|
+
side_effect=[
|
|
1792
|
+
httpx.Response(404),
|
|
1793
|
+
httpx.Response(
|
|
1794
|
+
200,
|
|
1795
|
+
json={
|
|
1796
|
+
'id': 9,
|
|
1797
|
+
'name': 'demo',
|
|
1798
|
+
'html_url': 'https://github.com/aweber-apis/demo',
|
|
1799
|
+
'owner': {'login': 'aweber-apis'},
|
|
1800
|
+
},
|
|
1801
|
+
),
|
|
1802
|
+
]
|
|
1803
|
+
)
|
|
1804
|
+
respx.post('https://api.github.com/orgs/aweber-apis/repos').mock(
|
|
1805
|
+
return_value=httpx.Response(
|
|
1806
|
+
422, json={'message': 'Repository creation failed.'}
|
|
1807
|
+
)
|
|
1808
|
+
)
|
|
1809
|
+
|
|
1810
|
+
ctx = _ctx(
|
|
1811
|
+
options={'create_org': 'aweber-apis'},
|
|
1812
|
+
project_links={
|
|
1813
|
+
'github-repository': 'https://github.com/aweber-apis/demo'
|
|
1814
|
+
},
|
|
1815
|
+
)
|
|
1816
|
+
plugin = GitHubLifecycle()
|
|
1817
|
+
result = await plugin.on_project_updated(ctx, _CREDS)
|
|
1818
|
+
|
|
1819
|
+
self.assertEqual(result.status, 'ok')
|
|
1820
|
+
self.assertEqual(
|
|
1821
|
+
result.artifacts['repo_url'],
|
|
1822
|
+
'https://github.com/aweber-apis/demo',
|
|
1823
|
+
)
|
|
1824
|
+
|
|
1825
|
+
@respx.mock
|
|
1826
|
+
async def test_a_real_422_still_fails(self) -> None:
|
|
1827
|
+
respx.get('https://api.github.com/repos/aweber-apis/demo').mock(
|
|
1828
|
+
return_value=httpx.Response(404)
|
|
1829
|
+
)
|
|
1830
|
+
respx.post('https://api.github.com/orgs/aweber-apis/repos').mock(
|
|
1831
|
+
return_value=httpx.Response(
|
|
1832
|
+
422,
|
|
1833
|
+
json={
|
|
1834
|
+
'message': 'Repository creation failed.',
|
|
1835
|
+
'errors': [{'message': 'name is invalid'}],
|
|
1836
|
+
},
|
|
1837
|
+
)
|
|
1838
|
+
)
|
|
1839
|
+
|
|
1840
|
+
ctx = _ctx(
|
|
1841
|
+
options={'create_org': 'aweber-apis'},
|
|
1842
|
+
project_links={
|
|
1843
|
+
'github-repository': 'https://github.com/aweber-apis/demo'
|
|
1844
|
+
},
|
|
1845
|
+
)
|
|
1846
|
+
plugin = GitHubLifecycle()
|
|
1847
|
+
with self.assertRaises(RuntimeError) as caught:
|
|
1848
|
+
await plugin.on_project_updated(ctx, _CREDS)
|
|
1849
|
+
self.assertIn('name is invalid', str(caught.exception))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.26.1 → imbi_plugin_github-2.26.2}/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.26.1 → imbi_plugin_github-2.26.2}/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
|