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.
Files changed (27) hide show
  1. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/pyproject.toml +2 -2
  3. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/README.md +1 -0
  4. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/deployment.py +52 -13
  5. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/lifecycle.py +79 -1
  6. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/plugin.py +15 -0
  7. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/tests/test_deployment.py +60 -0
  8. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/tests/test_lifecycle.py +143 -3
  9. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/.gitignore +0 -0
  10. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/README.md +0 -0
  11. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/moon.yml +0 -0
  12. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/__init__.py +0 -0
  13. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/_app_auth.py +0 -0
  14. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/_hosts.py +0 -0
  15. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/_repos.py +0 -0
  16. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/commits.py +0 -0
  17. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/doctor.py +0 -0
  18. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/identity.py +0 -0
  19. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/pull_requests.py +0 -0
  20. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/src/imbi/plugins/github/py.typed +0 -0
  21. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/tests/__init__.py +0 -0
  22. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/tests/test_commits.py +0 -0
  23. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/tests/test_doctor.py +0 -0
  24. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/tests/test_hosts.py +0 -0
  25. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/tests/test_identity.py +0 -0
  26. {imbi_plugin_github-2.20.0 → imbi_plugin_github-2.21.1}/tests/test_pull_requests.py +0 -0
  27. {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.20.0
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.20.0
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.20.0"
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.20.0",
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
 
@@ -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 _release_notes_for_ref(
1200
+ async def _release_for_ref(
1200
1201
  self, client: httpx.AsyncClient, ref: str
1201
- ) -> str | None:
1202
- """Return the GitHub release notes body for a deployed ref.
1203
-
1204
- A deployment created against a semver tag has a matching GitHub
1205
- release whose ``body`` is the "What's Changed" markdown the host
1206
- persists on the ``Release`` node. Refs that aren't a release tag
1207
- (branches, raw SHAs) 404 here and yield ``None`` -- resync is
1208
- never blocked by a missing or unreadable release. A ``403``
1209
- (token lacks scope to read releases) is cached process-wide so a
1210
- forbidden token short-circuits instead of re-issuing the request
1211
- for every deployment on every resync sweep.
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
- return str(body) if body else None
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
@@ -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
- resp.raise_for_status()
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
- b'{"name":"demo","description":"An example API",'
831
- b'"homepage":"https://imbi.example.com/projects/p"}',
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(