imbi-plugin-github 2.20.0__tar.gz → 2.21.1__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.20.0 → imbi_plugin_github-2.21.1}/PKG-INFO +2 -2
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/pyproject.toml +2 -2
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/README.md +1 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/deployment.py +52 -13
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/lifecycle.py +79 -1
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/plugin.py +15 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/tests/test_deployment.py +60 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/tests/test_lifecycle.py +143 -3
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/.gitignore +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/README.md +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/moon.yml +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/__init__.py +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/_app_auth.py +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/_hosts.py +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/_repos.py +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/commits.py +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/doctor.py +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/identity.py +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/pull_requests.py +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/py.typed +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/tests/test_commits.py +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/tests/test_doctor.py +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/tests/test_pull_requests.py +0 -0
- {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/tests/test_repos.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: imbi-plugin-github
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.21.1
|
|
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.21.1
|
|
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.21.1"
|
|
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.21.1",
|
|
22
22
|
"pydantic>=2",
|
|
23
23
|
"pyjwt[crypto]>=2.8",
|
|
24
24
|
]
|
|
@@ -109,6 +109,7 @@ Scoped to one capability and delivered on
|
|
|
109
109
|
| `lifecycle` | `archive_target_org` | Org to transfer repos to before archiving; blank archives in place. |
|
|
110
110
|
| `lifecycle` | `create_org` | Default org for repo creation when no `org_mapping` entry matches. |
|
|
111
111
|
| `lifecycle` | `org_mapping` | Per-project-type-slug org overrides; the first match wins over `create_org`. |
|
|
112
|
+
| `lifecycle` | `create_visibility` | Visibility for created repos: `private`, `internal`, or `public`. Blank follows the flavor — `internal` for `ghec`/`ghes`, `public` for `github`. |
|
|
112
113
|
|
|
113
114
|
## Credentials
|
|
114
115
|
|
{imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/deployment.py
RENAMED
|
@@ -46,6 +46,7 @@ from imbi.common.plugins.base import (
|
|
|
46
46
|
RefInfo,
|
|
47
47
|
ReleaseInfo,
|
|
48
48
|
RemoteDeployment,
|
|
49
|
+
RemoteRelease,
|
|
49
50
|
WorkflowFile,
|
|
50
51
|
)
|
|
51
52
|
from imbi.common.plugins.errors import PluginAuthenticationFailed
|
|
@@ -1196,19 +1197,19 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1196
1197
|
run_cache[run_id] = result
|
|
1197
1198
|
return result
|
|
1198
1199
|
|
|
1199
|
-
async def
|
|
1200
|
+
async def _release_for_ref(
|
|
1200
1201
|
self, client: httpx.AsyncClient, ref: str
|
|
1201
|
-
) ->
|
|
1202
|
-
"""Return the GitHub release
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1202
|
+
) -> RemoteRelease | None:
|
|
1203
|
+
"""Return the GitHub release for ``ref``, metadata included.
|
|
1204
|
+
|
|
1205
|
+
The single release lookup every caller degrades through: a 404 /
|
|
1206
|
+
non-200 / parse failure yields ``None``, and a ``403`` (token lacks
|
|
1207
|
+
scope to read releases) is cached process-wide so a forbidden token
|
|
1208
|
+
short-circuits instead of re-issuing the request for every
|
|
1209
|
+
deployment on every resync sweep. ``author`` is the login GitHub
|
|
1210
|
+
credits with the release and ``author_subject`` its numeric user id,
|
|
1211
|
+
which the host resolves to an Imbi user through the identity plugins
|
|
1212
|
+
on the same service.
|
|
1212
1213
|
"""
|
|
1213
1214
|
if _releases_forbidden(client):
|
|
1214
1215
|
return None
|
|
@@ -1227,8 +1228,46 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1227
1228
|
data = typing.cast(dict[str, typing.Any], resp.json())
|
|
1228
1229
|
except ValueError:
|
|
1229
1230
|
return None
|
|
1231
|
+
author: dict[str, typing.Any] = data.get('author') or {}
|
|
1232
|
+
login = author.get('login')
|
|
1233
|
+
subject = author.get('id')
|
|
1230
1234
|
body = data.get('body')
|
|
1231
|
-
|
|
1235
|
+
name = data.get('name')
|
|
1236
|
+
html_url = data.get('html_url')
|
|
1237
|
+
return RemoteRelease(
|
|
1238
|
+
tag=ref,
|
|
1239
|
+
name=str(name) if name else None,
|
|
1240
|
+
body_markdown=str(body) if body else None,
|
|
1241
|
+
author=str(login) if login else None,
|
|
1242
|
+
author_subject=str(subject) if subject is not None else None,
|
|
1243
|
+
html_url=str(html_url) if html_url else None,
|
|
1244
|
+
published_at=_parse_iso(data.get('published_at')),
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
async def get_release(
|
|
1248
|
+
self,
|
|
1249
|
+
ctx: PluginContext,
|
|
1250
|
+
credentials: dict[str, str],
|
|
1251
|
+
tag: str,
|
|
1252
|
+
) -> RemoteRelease | None:
|
|
1253
|
+
"""Return the GitHub release for ``tag`` with its author."""
|
|
1254
|
+
async with self._client(ctx, credentials) as client:
|
|
1255
|
+
return await self._release_for_ref(client, tag)
|
|
1256
|
+
|
|
1257
|
+
async def _release_notes_for_ref(
|
|
1258
|
+
self, client: httpx.AsyncClient, ref: str
|
|
1259
|
+
) -> str | None:
|
|
1260
|
+
"""Return the GitHub release notes body for a deployed ref.
|
|
1261
|
+
|
|
1262
|
+
A deployment created against a semver tag has a matching GitHub
|
|
1263
|
+
release whose ``body`` is the "What's Changed" markdown the host
|
|
1264
|
+
persists on the ``Release`` node. Refs that aren't a release tag
|
|
1265
|
+
(branches, raw SHAs) 404 in :meth:`_release_for_ref` and yield
|
|
1266
|
+
``None`` -- resync is never blocked by a missing or unreadable
|
|
1267
|
+
release.
|
|
1268
|
+
"""
|
|
1269
|
+
release = await self._release_for_ref(client, ref)
|
|
1270
|
+
return release.body_markdown if release else None
|
|
1232
1271
|
|
|
1233
1272
|
async def _latest_status(
|
|
1234
1273
|
self, client: httpx.AsyncClient, deployment_id: str
|
{imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/lifecycle.py
RENAMED
|
@@ -75,6 +75,39 @@ _HTTP_TIMEOUT_SECONDS = 10.0
|
|
|
75
75
|
# hanging the operator's archive request.
|
|
76
76
|
_TRANSFER_ARCHIVE_BACKOFFS: tuple[float, ...] = (0.5, 1.0, 2.0)
|
|
77
77
|
|
|
78
|
+
# Visibilities accepted by the ``create_visibility`` option. ``internal``
|
|
79
|
+
# is only meaningful on a GHEC tenant or GHES appliance.
|
|
80
|
+
_VISIBILITIES = frozenset({'private', 'internal', 'public'})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _error_detail(response: httpx.Response) -> str:
|
|
84
|
+
"""Flatten a GitHub error body into one operator-facing string.
|
|
85
|
+
|
|
86
|
+
Keeps the top-level ``message`` and each entry of the ``errors``
|
|
87
|
+
array (``field: message``) so a validation failure names the field
|
|
88
|
+
that GitHub rejected.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
body = response.json()
|
|
92
|
+
except ValueError:
|
|
93
|
+
body = None
|
|
94
|
+
if not isinstance(body, dict):
|
|
95
|
+
return f'HTTP {response.status_code}: {response.text[:200]}'
|
|
96
|
+
payload = typing.cast(dict[str, object], body)
|
|
97
|
+
parts = [str(payload.get('message') or f'HTTP {response.status_code}')]
|
|
98
|
+
errors = payload.get('errors')
|
|
99
|
+
if isinstance(errors, list):
|
|
100
|
+
for item in typing.cast(list[object], errors):
|
|
101
|
+
if not isinstance(item, dict):
|
|
102
|
+
continue
|
|
103
|
+
entry = typing.cast(dict[str, object], item)
|
|
104
|
+
text = entry.get('message') or entry.get('code')
|
|
105
|
+
if not text:
|
|
106
|
+
continue
|
|
107
|
+
field = entry.get('field')
|
|
108
|
+
parts.append(f'{field}: {text}' if field else str(text))
|
|
109
|
+
return ' - '.join(parts)
|
|
110
|
+
|
|
78
111
|
|
|
79
112
|
async def _raise_on_401(response: httpx.Response) -> None:
|
|
80
113
|
"""Convert a 401 from GitHub into :class:`PluginAuthenticationFailed`.
|
|
@@ -144,6 +177,35 @@ class GitHubLifecycle(LifecycleCapability):
|
|
|
144
177
|
return raw.strip()
|
|
145
178
|
return None
|
|
146
179
|
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _default_visibility(ctx: PluginContext) -> str:
|
|
182
|
+
"""Pick the create visibility when the option is unset.
|
|
183
|
+
|
|
184
|
+
The right default differs per host, so it can't be a manifest
|
|
185
|
+
constant: enterprise hosts (a GHEC tenant or a GHES appliance)
|
|
186
|
+
get ``internal`` -- readable across the enterprise, and the only
|
|
187
|
+
useful non-public choice on orgs that forbid public repos --
|
|
188
|
+
while github.com has no ``internal`` visibility at all and gets
|
|
189
|
+
``public``. Never *silently* narrower than that on enterprise
|
|
190
|
+
and never wider on github.com; operators who want something
|
|
191
|
+
else set ``create_visibility`` explicitly.
|
|
192
|
+
"""
|
|
193
|
+
flavor = str(ctx.integration_options.get('flavor') or '').strip()
|
|
194
|
+
return 'public' if flavor == 'github' else 'internal'
|
|
195
|
+
|
|
196
|
+
def _resolve_visibility(self, ctx: PluginContext) -> str:
|
|
197
|
+
"""Resolve the visibility new repos are created with.
|
|
198
|
+
|
|
199
|
+
An explicit ``create_visibility`` option wins; anything unset or
|
|
200
|
+
unrecognized falls back to the host-appropriate default rather
|
|
201
|
+
than inheriting GitHub's create API default of *public*, which
|
|
202
|
+
an enterprise org that forbids public repos rejects with a 422.
|
|
203
|
+
"""
|
|
204
|
+
raw = ctx.capability_options.get('create_visibility')
|
|
205
|
+
if isinstance(raw, str) and raw.strip().lower() in _VISIBILITIES:
|
|
206
|
+
return raw.strip().lower()
|
|
207
|
+
return self._default_visibility(ctx)
|
|
208
|
+
|
|
147
209
|
@staticmethod
|
|
148
210
|
def _resolve_create_org(ctx: PluginContext) -> str | None:
|
|
149
211
|
"""Resolve the GitHub org for create / relocate from plugin options.
|
|
@@ -616,15 +678,31 @@ class GitHubLifecycle(LifecycleCapability):
|
|
|
616
678
|
org: str,
|
|
617
679
|
ctx: PluginContext,
|
|
618
680
|
) -> dict[str, typing.Any]:
|
|
681
|
+
visibility = self._resolve_visibility(ctx)
|
|
619
682
|
resp = await client.post(
|
|
620
683
|
f'/orgs/{org}/repos',
|
|
621
684
|
json={
|
|
622
685
|
'name': ctx.project_slug,
|
|
623
686
|
'description': ctx.project_description or '',
|
|
624
687
|
'homepage': ctx.project_ui_url or '',
|
|
688
|
+
# ``visibility`` is authoritative where it's supported;
|
|
689
|
+
# ``private`` rides along so a host that only honors the
|
|
690
|
+
# older field degrades to private instead of silently
|
|
691
|
+
# creating a public repo.
|
|
692
|
+
'visibility': visibility,
|
|
693
|
+
'private': visibility != 'public',
|
|
625
694
|
},
|
|
626
695
|
)
|
|
627
|
-
|
|
696
|
+
try:
|
|
697
|
+
resp.raise_for_status()
|
|
698
|
+
except httpx.HTTPStatusError as exc:
|
|
699
|
+
# ``raise_for_status`` reports only the status line, hiding
|
|
700
|
+
# the ``message``/``errors`` body that says *why* -- e.g. an
|
|
701
|
+
# org whose policy forbids the requested visibility.
|
|
702
|
+
raise RuntimeError(
|
|
703
|
+
f'GitHub refused to create {org}/{ctx.project_slug} with '
|
|
704
|
+
f'visibility={visibility}: {_error_detail(exc.response)}'
|
|
705
|
+
) from exc
|
|
628
706
|
return typing.cast(dict[str, typing.Any], resp.json())
|
|
629
707
|
|
|
630
708
|
async def _patch_repo_attrs(
|
|
@@ -174,6 +174,21 @@ _LIFECYCLE_OPTIONS: list[PluginOption] = [
|
|
|
174
174
|
type='string',
|
|
175
175
|
required=False,
|
|
176
176
|
),
|
|
177
|
+
PluginOption(
|
|
178
|
+
name='create_visibility',
|
|
179
|
+
label='Visibility for created repos',
|
|
180
|
+
description=(
|
|
181
|
+
'Visibility applied to repos created by project creation. '
|
|
182
|
+
'Leave blank to follow the Integration flavor: ``internal`` '
|
|
183
|
+
'on a GHEC tenant or GHES appliance, ``public`` on '
|
|
184
|
+
'github.com, which has no internal visibility. Set a value '
|
|
185
|
+
'to override -- note that an org forbidding public repos '
|
|
186
|
+
'rejects ``public`` with a 422.'
|
|
187
|
+
),
|
|
188
|
+
type='string',
|
|
189
|
+
choices=['private', 'internal', 'public'],
|
|
190
|
+
required=False,
|
|
191
|
+
),
|
|
177
192
|
PluginOption(
|
|
178
193
|
name='org_mapping',
|
|
179
194
|
label='Project-type to org overrides',
|
|
@@ -1555,6 +1555,66 @@ class GetReleaseNotesTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
1555
1555
|
notes = await plugin.get_release_notes(_ctx(), _CREDS, '9.9.9')
|
|
1556
1556
|
self.assertIsNone(notes)
|
|
1557
1557
|
|
|
1558
|
+
@respx.mock
|
|
1559
|
+
async def test_get_release_carries_the_remote_author(self) -> None:
|
|
1560
|
+
# Attribution comes from GitHub: the login it credits with the
|
|
1561
|
+
# release, plus the numeric id the host resolves to an Imbi user.
|
|
1562
|
+
respx.get(
|
|
1563
|
+
'https://api.github.com/repos/octo/demo/releases/tags/3.23.4'
|
|
1564
|
+
).mock(
|
|
1565
|
+
return_value=httpx.Response(
|
|
1566
|
+
200,
|
|
1567
|
+
json={
|
|
1568
|
+
'tag_name': '3.23.4',
|
|
1569
|
+
'name': 'Release 3.23.4',
|
|
1570
|
+
'body': '## Fixed\n- the thing',
|
|
1571
|
+
'html_url': 'https://github.com/octo/demo/releases/3.23.4',
|
|
1572
|
+
'published_at': '2026-07-29T12:00:00Z',
|
|
1573
|
+
'author': {'login': 'octocat', 'id': 583231},
|
|
1574
|
+
},
|
|
1575
|
+
)
|
|
1576
|
+
)
|
|
1577
|
+
plugin = GitHubDeployment()
|
|
1578
|
+
release = await plugin.get_release(_ctx(), _CREDS, '3.23.4')
|
|
1579
|
+
assert release is not None
|
|
1580
|
+
self.assertEqual('octocat', release.author)
|
|
1581
|
+
self.assertEqual('583231', release.author_subject)
|
|
1582
|
+
self.assertEqual('Release 3.23.4', release.name)
|
|
1583
|
+
self.assertEqual('## Fixed\n- the thing', release.body_markdown)
|
|
1584
|
+
self.assertEqual(
|
|
1585
|
+
'https://github.com/octo/demo/releases/3.23.4', release.html_url
|
|
1586
|
+
)
|
|
1587
|
+
self.assertEqual(
|
|
1588
|
+
datetime.datetime(2026, 7, 29, 12, 0, tzinfo=datetime.UTC),
|
|
1589
|
+
release.published_at,
|
|
1590
|
+
)
|
|
1591
|
+
|
|
1592
|
+
@respx.mock
|
|
1593
|
+
async def test_get_release_returns_none_when_no_release(self) -> None:
|
|
1594
|
+
respx.get(
|
|
1595
|
+
'https://api.github.com/repos/octo/demo/releases/tags/9.9.9'
|
|
1596
|
+
).mock(return_value=httpx.Response(404, json={'message': 'Not Found'}))
|
|
1597
|
+
plugin = GitHubDeployment()
|
|
1598
|
+
self.assertIsNone(await plugin.get_release(_ctx(), _CREDS, '9.9.9'))
|
|
1599
|
+
|
|
1600
|
+
@respx.mock
|
|
1601
|
+
async def test_get_release_tolerates_a_missing_author(self) -> None:
|
|
1602
|
+
# A release with no author (a tag-only release, or a deleted
|
|
1603
|
+
# account) still yields its body rather than failing the lookup.
|
|
1604
|
+
respx.get(
|
|
1605
|
+
'https://api.github.com/repos/octo/demo/releases/tags/1.0.0'
|
|
1606
|
+
).mock(
|
|
1607
|
+
return_value=httpx.Response(
|
|
1608
|
+
200, json={'tag_name': '1.0.0', 'body': 'notes'}
|
|
1609
|
+
)
|
|
1610
|
+
)
|
|
1611
|
+
plugin = GitHubDeployment()
|
|
1612
|
+
release = await plugin.get_release(_ctx(), _CREDS, '1.0.0')
|
|
1613
|
+
assert release is not None
|
|
1614
|
+
self.assertIsNone(release.author)
|
|
1615
|
+
self.assertIsNone(release.author_subject)
|
|
1616
|
+
self.assertEqual('notes', release.body_markdown)
|
|
1617
|
+
|
|
1558
1618
|
|
|
1559
1619
|
class CheckStatusTestCase(unittest.IsolatedAsyncioTestCase):
|
|
1560
1620
|
@respx.mock
|
|
@@ -7,6 +7,7 @@ archived repo, the rename-on-transfer case, repo-resolution failure
|
|
|
7
7
|
from absent links, and the 401 -> PluginAuthenticationFailed path.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
import json
|
|
10
11
|
import unittest
|
|
11
12
|
import unittest.mock
|
|
12
13
|
|
|
@@ -98,6 +99,18 @@ class ManifestTestCase(unittest.TestCase):
|
|
|
98
99
|
self.assertIn('org_mapping', opts)
|
|
99
100
|
self.assertEqual(opts['org_mapping'].type, 'mapping')
|
|
100
101
|
|
|
102
|
+
def test_create_visibility_option_has_no_manifest_default(self) -> None:
|
|
103
|
+
# The default is flavor-dependent, so it must be resolved by the
|
|
104
|
+
# handler -- a manifest default gets pre-filled into the stored
|
|
105
|
+
# options by the admin wizard and would defeat that.
|
|
106
|
+
opts = {opt.name: opt for opt in _lifecycle_cap().options}
|
|
107
|
+
self.assertIn('create_visibility', opts)
|
|
108
|
+
self.assertIsNone(opts['create_visibility'].default)
|
|
109
|
+
self.assertEqual(
|
|
110
|
+
opts['create_visibility'].choices,
|
|
111
|
+
['private', 'internal', 'public'],
|
|
112
|
+
)
|
|
113
|
+
|
|
101
114
|
def test_no_host_option(self) -> None:
|
|
102
115
|
# Host now comes from the Integration's flavor/host options, not a
|
|
103
116
|
# per-capability option on the lifecycle capability itself.
|
|
@@ -826,9 +839,16 @@ class CreateTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
826
839
|
self.assertEqual(result.status, 'ok')
|
|
827
840
|
self.assertEqual(create_route.calls.call_count, 1)
|
|
828
841
|
self.assertEqual(
|
|
829
|
-
create_route.calls.last.request.read(),
|
|
830
|
-
|
|
831
|
-
|
|
842
|
+
json.loads(create_route.calls.last.request.read()),
|
|
843
|
+
{
|
|
844
|
+
'name': 'demo',
|
|
845
|
+
'description': 'An example API',
|
|
846
|
+
'homepage': 'https://imbi.example.com/projects/p',
|
|
847
|
+
# ``github`` flavor, no option set -> public, the only
|
|
848
|
+
# visibility github.com shares with the enterprise hosts.
|
|
849
|
+
'visibility': 'public',
|
|
850
|
+
'private': False,
|
|
851
|
+
},
|
|
832
852
|
)
|
|
833
853
|
self.assertIsNotNone(ctx.link_writeback)
|
|
834
854
|
assert ctx.link_writeback is not None
|
|
@@ -867,6 +887,126 @@ class CreateTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
867
887
|
self.assertEqual(result.status, 'ok')
|
|
868
888
|
self.assertEqual(create_route.calls.call_count, 1)
|
|
869
889
|
|
|
890
|
+
@respx.mock
|
|
891
|
+
async def test_create_honors_configured_visibility(self) -> None:
|
|
892
|
+
respx.get('https://api.github.com/repos/aweber-apis/demo').mock(
|
|
893
|
+
return_value=httpx.Response(404, json={'message': 'Not Found'})
|
|
894
|
+
)
|
|
895
|
+
create_route = respx.post(
|
|
896
|
+
'https://api.github.com/orgs/aweber-apis/repos'
|
|
897
|
+
).mock(
|
|
898
|
+
return_value=httpx.Response(
|
|
899
|
+
201,
|
|
900
|
+
json={
|
|
901
|
+
'name': 'demo',
|
|
902
|
+
'html_url': 'https://github.com/aweber-apis/demo',
|
|
903
|
+
},
|
|
904
|
+
)
|
|
905
|
+
)
|
|
906
|
+
ctx = _ctx(
|
|
907
|
+
options={
|
|
908
|
+
'org_mapping': {'api-service': 'aweber-apis'},
|
|
909
|
+
'create_visibility': 'private',
|
|
910
|
+
},
|
|
911
|
+
project_links={},
|
|
912
|
+
project_type_slugs=['api-service'],
|
|
913
|
+
)
|
|
914
|
+
plugin = GitHubLifecycle()
|
|
915
|
+
result = await plugin.on_project_created(ctx, _CREDS)
|
|
916
|
+
|
|
917
|
+
self.assertEqual(result.status, 'ok')
|
|
918
|
+
body = json.loads(create_route.calls.last.request.read())
|
|
919
|
+
# The option overrides the ``internal`` default.
|
|
920
|
+
self.assertEqual(body['visibility'], 'private')
|
|
921
|
+
self.assertIs(body['private'], True)
|
|
922
|
+
|
|
923
|
+
async def _create_on(
|
|
924
|
+
self, flavor: str, host: str, api_base: str
|
|
925
|
+
) -> dict[str, object]:
|
|
926
|
+
"""Run a create against one flavor, returning the POST body."""
|
|
927
|
+
respx.get(f'{api_base}/repos/aweber-apis/demo').mock(
|
|
928
|
+
return_value=httpx.Response(404, json={'message': 'Not Found'})
|
|
929
|
+
)
|
|
930
|
+
create_route = respx.post(f'{api_base}/orgs/aweber-apis/repos').mock(
|
|
931
|
+
return_value=httpx.Response(
|
|
932
|
+
201,
|
|
933
|
+
json={
|
|
934
|
+
'name': 'demo',
|
|
935
|
+
'html_url': f'https://{host}/aweber-apis/demo',
|
|
936
|
+
},
|
|
937
|
+
)
|
|
938
|
+
)
|
|
939
|
+
ctx = _ctx(
|
|
940
|
+
options={'org_mapping': {'api-service': 'aweber-apis'}},
|
|
941
|
+
project_links={},
|
|
942
|
+
project_type_slugs=['api-service'],
|
|
943
|
+
connection=_connection(flavor, host),
|
|
944
|
+
)
|
|
945
|
+
result = await GitHubLifecycle().on_project_created(ctx, _CREDS)
|
|
946
|
+
self.assertEqual(result.status, 'ok')
|
|
947
|
+
body: dict[str, object] = json.loads(
|
|
948
|
+
create_route.calls.last.request.read()
|
|
949
|
+
)
|
|
950
|
+
return body
|
|
951
|
+
|
|
952
|
+
@respx.mock
|
|
953
|
+
async def test_create_defaults_to_internal_on_ghec(self) -> None:
|
|
954
|
+
# An unset option on an enterprise host means ``internal``, not
|
|
955
|
+
# the ``public`` default that github.com gets.
|
|
956
|
+
body = await self._create_on(
|
|
957
|
+
'ghec', 'tenant.ghe.com', 'https://api.tenant.ghe.com'
|
|
958
|
+
)
|
|
959
|
+
self.assertEqual(body['visibility'], 'internal')
|
|
960
|
+
self.assertIs(body['private'], True)
|
|
961
|
+
|
|
962
|
+
@respx.mock
|
|
963
|
+
async def test_create_defaults_to_internal_on_ghes(self) -> None:
|
|
964
|
+
body = await self._create_on(
|
|
965
|
+
'ghes', 'github.example.com', 'https://github.example.com/api/v3'
|
|
966
|
+
)
|
|
967
|
+
self.assertEqual(body['visibility'], 'internal')
|
|
968
|
+
self.assertIs(body['private'], True)
|
|
969
|
+
|
|
970
|
+
@respx.mock
|
|
971
|
+
async def test_create_surfaces_github_validation_detail(self) -> None:
|
|
972
|
+
# A 422 from GitHub (e.g. org policy forbids the visibility) must
|
|
973
|
+
# name the reason -- ``raise_for_status`` alone hides the body.
|
|
974
|
+
respx.get('https://api.github.com/repos/aweber-apis/demo').mock(
|
|
975
|
+
return_value=httpx.Response(404, json={'message': 'Not Found'})
|
|
976
|
+
)
|
|
977
|
+
respx.post('https://api.github.com/orgs/aweber-apis/repos').mock(
|
|
978
|
+
return_value=httpx.Response(
|
|
979
|
+
422,
|
|
980
|
+
json={
|
|
981
|
+
'message': 'Repository creation failed.',
|
|
982
|
+
'errors': [
|
|
983
|
+
{
|
|
984
|
+
'resource': 'Repository',
|
|
985
|
+
'field': 'visibility',
|
|
986
|
+
'message': "Visibility can't be public",
|
|
987
|
+
}
|
|
988
|
+
],
|
|
989
|
+
},
|
|
990
|
+
)
|
|
991
|
+
)
|
|
992
|
+
ctx = _ctx(
|
|
993
|
+
options={
|
|
994
|
+
'org_mapping': {'api-service': 'aweber-apis'},
|
|
995
|
+
'create_visibility': 'public',
|
|
996
|
+
},
|
|
997
|
+
project_links={},
|
|
998
|
+
project_type_slugs=['api-service'],
|
|
999
|
+
)
|
|
1000
|
+
plugin = GitHubLifecycle()
|
|
1001
|
+
with self.assertRaises(RuntimeError) as raised:
|
|
1002
|
+
await plugin.on_project_created(ctx, _CREDS)
|
|
1003
|
+
|
|
1004
|
+
message = str(raised.exception)
|
|
1005
|
+
self.assertIn('aweber-apis/demo', message)
|
|
1006
|
+
self.assertIn('visibility=public', message)
|
|
1007
|
+
self.assertIn('Repository creation failed.', message)
|
|
1008
|
+
self.assertIn("visibility: Visibility can't be public", message)
|
|
1009
|
+
|
|
870
1010
|
async def test_create_skips_when_no_target_org_configured(self) -> None:
|
|
871
1011
|
# Neither mapping nor template -> clean skip rather than HTTP.
|
|
872
1012
|
ctx = _ctx(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/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.20.0 → imbi_plugin_github-2.21.1}/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
|