imbi-plugin-github 2.26.0__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.
Files changed (27) hide show
  1. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/pyproject.toml +2 -2
  3. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/_repos.py +61 -5
  4. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/deployment.py +64 -16
  5. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/lifecycle.py +189 -53
  6. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/tests/test_deployment.py +90 -0
  7. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/tests/test_lifecycle.py +468 -2
  8. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/.gitignore +0 -0
  9. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/README.md +0 -0
  10. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/moon.yml +0 -0
  11. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/README.md +0 -0
  12. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/__init__.py +0 -0
  13. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/_app_auth.py +0 -0
  14. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/_hosts.py +0 -0
  15. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/commits.py +0 -0
  16. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/doctor.py +0 -0
  17. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/identity.py +0 -0
  18. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/plugin.py +0 -0
  19. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/pull_requests.py +0 -0
  20. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/src/imbi/plugins/github/py.typed +0 -0
  21. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/tests/__init__.py +0 -0
  22. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/tests/test_commits.py +0 -0
  23. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/tests/test_doctor.py +0 -0
  24. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/tests/test_hosts.py +0 -0
  25. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/tests/test_identity.py +0 -0
  26. {imbi_plugin_github-2.26.0 → imbi_plugin_github-2.26.2}/tests/test_pull_requests.py +0 -0
  27. {imbi_plugin_github-2.26.0 → 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.0
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.0
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.0"
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.0",
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
- def resolve_owner_repo(
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
- ) -> tuple[str, str]:
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
- return ctx.project_type_slugs[0], slug
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
@@ -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
- async def _notes_tree(
1001
- self, client: httpx.AsyncClient, namespace: str
1002
- ) -> dict[str, str] | None:
1003
- """Map annotated full SHA -> note blob SHA, or ``None`` sans ref."""
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
- tip = str(ref.json()['object']['sha'])
1011
- return await self._tree_notes(client, tip)
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
- if payload.get('truncated'):
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
- ) -> dict[str, str | None]:
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(
@@ -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
- owner, repo = resolve_owner_repo(
298
- ctx,
299
- host,
300
- 'GitHub lifecycle plugin',
301
- prefer_previous_slug=True,
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
- current = await self._get_repo(client, owner, repo)
305
- current_owner = self._current_owner(current, owner)
306
- current_repo = self._current_repo(current, repo)
307
- # Surface an external rename even when there's nothing else
308
- # to do, so the host can self-heal the link.
309
- self._maybe_report_relocation(
310
- ctx, host, current, owner, repo, current_owner, current_repo
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 or '',
318
- homepage=ctx.project_ui_url or '',
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
- # If the patch itself renamed the repo (we asked GitHub to
322
- # set ``name`` to a new slug), record the writeback even when
323
- # the external-rename check above didn't.
324
- if new_repo != current_repo:
325
- new_url = self._record_repo(
326
- ctx,
327
- host,
328
- current_owner,
329
- new_repo,
330
- patched,
331
- old_owner_repo=f'{current_owner}/{current_repo}',
332
- new_owner_repo=f'{current_owner}/{new_repo}',
333
- )
334
- else:
335
- new_url = str(
336
- patched.get('html_url')
337
- or self._repo_html_url(host, current_owner, new_repo)
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
- Used by :meth:`on_project_created` for the idempotency check
667
- any other status is treated as a real failure and re-raised.
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 or '',
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
- resp = await client.patch(
725
- f'/repos/{owner}/{repo}',
726
- json={
727
- 'name': name,
728
- 'description': description,
729
- 'homepage': homepage,
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 GitHubLifecycle
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
- self.assertIsNone(ctx.link_writeback)
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))