imbi-plugin-github 2.38.1__tar.gz → 2.39.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 (28) hide show
  1. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/pyproject.toml +2 -2
  3. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/src/imbi/plugins/github/doctor.py +143 -6
  4. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/src/imbi/plugins/github/plugin.py +3 -2
  5. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/tests/test_doctor.py +162 -2
  6. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/.gitignore +0 -0
  7. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/README.md +0 -0
  8. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/moon.yml +0 -0
  9. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/src/imbi/plugins/github/README.md +0 -0
  10. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/src/imbi/plugins/github/__init__.py +0 -0
  11. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/src/imbi/plugins/github/_app_auth.py +0 -0
  12. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/src/imbi/plugins/github/_hosts.py +0 -0
  13. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/src/imbi/plugins/github/_repos.py +0 -0
  14. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/src/imbi/plugins/github/commits.py +0 -0
  15. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/src/imbi/plugins/github/deployment.py +0 -0
  16. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/src/imbi/plugins/github/identity.py +0 -0
  17. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/src/imbi/plugins/github/lifecycle.py +0 -0
  18. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/src/imbi/plugins/github/pull_requests.py +0 -0
  19. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/src/imbi/plugins/github/py.typed +0 -0
  20. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/tests/__init__.py +0 -0
  21. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/tests/test_app_auth_scopes.py +0 -0
  22. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/tests/test_commits.py +0 -0
  23. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/tests/test_deployment.py +0 -0
  24. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/tests/test_hosts.py +0 -0
  25. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/tests/test_identity.py +0 -0
  26. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/tests/test_lifecycle.py +0 -0
  27. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/tests/test_pull_requests.py +0 -0
  28. {imbi_plugin_github-2.38.1 → imbi_plugin_github-2.39.1}/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.38.1
3
+ Version: 2.39.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.38.1
15
+ Requires-Dist: imbi-common[databases]==2.39.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.38.1"
3
+ version = "2.39.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.38.1",
21
+ "imbi-common[databases]==2.39.1",
22
22
  "pydantic>=2",
23
23
  "pyjwt[crypto]>=2.8",
24
24
  ]
@@ -2,8 +2,9 @@
2
2
 
3
3
  Performs richer, GitHub-aware validation than the generic exists-in doctor:
4
4
  identifier is an integer, canonical URL follows the /repositories/{id} shape
5
- that the lifecycle plugin writes, dashboard link matches the API html_url, and
6
- the legacy github-repository link matches as well.
5
+ that the lifecycle plugin writes, dashboard link matches the API html_url,
6
+ the legacy github-repository link matches as well, and the repository
7
+ ``homepage`` points back at the project's Imbi page.
7
8
 
8
9
  A single analysis capability covers github.com, GHEC tenants, and GHES
9
10
  appliances: the GitHub host is resolved from the Integration's ``flavor`` +
@@ -53,8 +54,14 @@ _REPO_ID_RE = re.compile(r'.*/repositories/(\d+)$')
53
54
  # edge-shaped discrepancy (identifier, canonical URL shape, dashboard
54
55
  # link) is repaired by one ``ServiceWriteback`` so they share an id; the
55
56
  # legacy ``github-repository`` link is a separate ``LinkWriteback``.
57
+ # The ``homepage`` repair is the one that writes *to* GitHub: it PATCHes
58
+ # the repository so its homepage is the project's Imbi page.
56
59
  _REPAIR_EDGE = 'repair-edge'
57
60
  _REPAIR_GITHUB_LINK = 'repair-github-link'
61
+ _REPAIR_HOMEPAGE = 'repair-homepage'
62
+ _REMEDIATION_IDS = frozenset(
63
+ {_REPAIR_EDGE, _REPAIR_GITHUB_LINK, _REPAIR_HOMEPAGE}
64
+ )
58
65
 
59
66
 
60
67
  async def _raise_on_401(response: httpx.Response) -> None:
@@ -554,6 +561,8 @@ class GitHubDoctor(AnalysisCapability):
554
561
  )
555
562
  )
556
563
 
564
+ results.append(_homepage_item(ctx, body))
565
+
557
566
  return results
558
567
 
559
568
  async def remediate( # noqa: C901 — flat sequence of guard clauses
@@ -573,7 +582,7 @@ class GitHubDoctor(AnalysisCapability):
573
582
  ``PluginAuthenticationFailed`` so the host can refresh the actor's
574
583
  identity and retry once.
575
584
  """
576
- if remediation_id not in (_REPAIR_EDGE, _REPAIR_GITHUB_LINK):
585
+ if remediation_id not in _REMEDIATION_IDS:
577
586
  return await super().remediate(ctx, credentials, remediation_id)
578
587
 
579
588
  try:
@@ -593,9 +602,9 @@ class GitHubDoctor(AnalysisCapability):
593
602
  connection = _find_connection(ctx.service_connections, slug)
594
603
  # ``_REPAIR_EDGE`` creates a missing edge from GitHub, so a
595
604
  # missing connection is only fatal for the github-repository
596
- # link repair (which never needs the edge but is only ever
597
- # offered once the edge is present).
598
- if connection is None and remediation_id == _REPAIR_GITHUB_LINK:
605
+ # link and homepage repairs (which never need the edge but are
606
+ # only ever offered once the edge is present).
607
+ if connection is None and remediation_id != _REPAIR_EDGE:
599
608
  return RemediationResult(
600
609
  status='failed',
601
610
  message=(
@@ -661,6 +670,9 @@ class GitHubDoctor(AnalysisCapability):
661
670
  message='GitHub response did not include an html_url.',
662
671
  )
663
672
 
673
+ if remediation_id == _REPAIR_HOMEPAGE:
674
+ return await _repair_homepage(ctx, body, api_base, headers)
675
+
664
676
  if remediation_id == _REPAIR_GITHUB_LINK:
665
677
  if ctx.project_links.get('github-repository') == html_url:
666
678
  return RemediationResult(
@@ -769,4 +781,129 @@ def _body_unavailable_items() -> list[AnalysisResultItem]:
769
781
  'warn',
770
782
  'Cannot verify github-repository link: repository fetch failed.',
771
783
  ),
784
+ _item(
785
+ 'homepage-url-match',
786
+ 'Homepage URL match',
787
+ 'warn',
788
+ 'Cannot verify the repository homepage: repository fetch failed.',
789
+ ),
772
790
  ]
791
+
792
+
793
+ def _repo_homepage(body: dict[str, typing.Any]) -> str:
794
+ """Return the repository ``homepage``, normalising unset to ``''``.
795
+
796
+ GitHub reports an unset homepage as ``null`` on some flavors and as
797
+ the empty string on others; both mean "not set".
798
+ """
799
+ homepage = body.get('homepage')
800
+ return homepage if isinstance(homepage, str) else ''
801
+
802
+
803
+ def _homepage_item(
804
+ ctx: PluginContext, body: dict[str, typing.Any]
805
+ ) -> AnalysisResultItem:
806
+ """Check that the repository ``homepage`` is the project's Imbi page.
807
+
808
+ The expected value is ``ctx.project_ui_url``, the same deep link the
809
+ lifecycle plugin writes on create / update. It is ``None`` when the
810
+ API has no ``IMBI_UI_URL`` configured, in which case there is
811
+ nothing to compare against and no repair to offer.
812
+ """
813
+ expected = ctx.project_ui_url
814
+ actual = _repo_homepage(body)
815
+ if expected is None:
816
+ return _item(
817
+ 'homepage-url-match',
818
+ 'Homepage URL match',
819
+ 'warn',
820
+ 'Cannot verify the repository homepage: IMBI_UI_URL is not '
821
+ 'configured on the API, so the project has no canonical '
822
+ 'Imbi page URL.',
823
+ )
824
+ if actual == expected:
825
+ return _item(
826
+ 'homepage-url-match',
827
+ 'Homepage URL match',
828
+ 'pass',
829
+ f"Repository homepage {actual!r} matches the project's Imbi page.",
830
+ )
831
+ offer = RemediationOffer(
832
+ id=_REPAIR_HOMEPAGE,
833
+ label="Set the repository homepage to the project's Imbi page",
834
+ )
835
+ if not actual:
836
+ return _item(
837
+ 'homepage-url-match',
838
+ 'Homepage URL match',
839
+ 'warn',
840
+ 'The repository has no homepage set. Use the Fix action to '
841
+ f"point it at the project's Imbi page {expected!r}.",
842
+ offer,
843
+ )
844
+ return _item(
845
+ 'homepage-url-match',
846
+ 'Homepage URL match',
847
+ 'fail',
848
+ f"Repository homepage {actual!r} does not match the project's "
849
+ f'Imbi page {expected!r}. Use the Fix action to update it.',
850
+ offer,
851
+ )
852
+
853
+
854
+ async def _repair_homepage(
855
+ ctx: PluginContext,
856
+ body: dict[str, typing.Any],
857
+ api_base: str,
858
+ headers: dict[str, str],
859
+ ) -> RemediationResult:
860
+ """PATCH the repository ``homepage`` to the project's Imbi page.
861
+
862
+ Unlike the edge / link repairs, which write back into Imbi, this
863
+ one writes to GitHub -- the same ``PATCH /repos/{owner}/{repo}``
864
+ the lifecycle plugin uses on update. ``body`` is the freshly
865
+ fetched repository, so ``full_name`` reflects any rename or
866
+ transfer. Idempotent: ``noop`` when GitHub already matches.
867
+ """
868
+ expected = ctx.project_ui_url
869
+ if expected is None:
870
+ return RemediationResult(
871
+ status='failed',
872
+ message=(
873
+ 'IMBI_UI_URL is not configured on the API, so there is no '
874
+ 'Imbi page URL to write as the repository homepage.'
875
+ ),
876
+ )
877
+ if _repo_homepage(body) == expected:
878
+ return RemediationResult(
879
+ status='noop',
880
+ message='Repository homepage already matches the Imbi page.',
881
+ )
882
+ full_name = body.get('full_name')
883
+ if not isinstance(full_name, str) or not full_name:
884
+ return RemediationResult(
885
+ status='failed',
886
+ message='GitHub response did not include a full_name.',
887
+ )
888
+ patch_url = f'{api_base}/repos/{full_name}'
889
+ try:
890
+ async with httpx.AsyncClient(
891
+ headers=headers,
892
+ timeout=_TIMEOUT,
893
+ event_hooks={'response': [_raise_on_401]},
894
+ ) as client:
895
+ resp = await client.patch(patch_url, json={'homepage': expected})
896
+ except httpx.TransportError as exc:
897
+ return RemediationResult(
898
+ status='failed',
899
+ message=f'Transport error patching {patch_url!r}: {exc}',
900
+ )
901
+ if not resp.is_success:
902
+ return RemediationResult(
903
+ status='failed',
904
+ message=f'HTTP {resp.status_code} patching {patch_url!r}.',
905
+ )
906
+ return RemediationResult(
907
+ status='fixed',
908
+ message=f'Set the repository homepage to {expected}.',
909
+ )
@@ -410,8 +410,9 @@ class GitHubPlugin(Plugin):
410
410
  description=(
411
411
  'Validate the GitHub repository link (EXISTS_IN edge) '
412
412
  'against the live GitHub API — identifier, canonical '
413
- 'URL shape, and dashboard / github-repository links — '
414
- 'and offer one-click repairs.'
413
+ 'URL shape, dashboard / github-repository links, and '
414
+ "the repository homepage pointing at the project's "
415
+ 'Imbi page — and offer one-click repairs.'
415
416
  ),
416
417
  handler=GitHubDoctor,
417
418
  ),
@@ -1,5 +1,6 @@
1
1
  """Tests for the GitHub-native project-doctor analysis plugin."""
2
2
 
3
+ import json
3
4
  import unittest
4
5
 
5
6
  import httpx
@@ -20,6 +21,7 @@ from imbi.plugins.github import _app_auth
20
21
  from imbi.plugins.github.doctor import (
21
22
  _REPAIR_EDGE,
22
23
  _REPAIR_GITHUB_LINK,
24
+ _REPAIR_HOMEPAGE,
23
25
  GitHubDoctor,
24
26
  )
25
27
 
@@ -27,7 +29,15 @@ _HOST = 'aweber.ghe.com'
27
29
  _API_BASE = 'https://api.aweber.ghe.com'
28
30
  _CANONICAL = f'{_API_BASE}/repositories/134741'
29
31
  _DASHBOARD = f'https://{_HOST}/aweber/demo'
30
- _REPO_PAYLOAD = {'id': 134741, 'html_url': _DASHBOARD, 'name': 'demo'}
32
+ _UI_URL = 'https://imbi.example.com/projects/p'
33
+ _REPO_PAYLOAD = {
34
+ 'id': 134741,
35
+ 'html_url': _DASHBOARD,
36
+ 'name': 'demo',
37
+ 'full_name': 'aweber/demo',
38
+ 'homepage': _UI_URL,
39
+ }
40
+ _PATCH_URL = f'{_API_BASE}/repos/aweber/demo'
31
41
 
32
42
  _TPS_SLUG = 'aweber-github'
33
43
  _CREDS = {'access_token': 'gho_test'}
@@ -53,6 +63,7 @@ def _ctx(
53
63
  connections: list[ServiceConnection] | None = None,
54
64
  links: dict[str, str] | None = None,
55
65
  options: dict[str, str] | None = None,
66
+ project_ui_url: str | None = _UI_URL,
56
67
  ) -> PluginContext:
57
68
  if connections is None:
58
69
  connections = [
@@ -79,6 +90,7 @@ def _ctx(
79
90
  ),
80
91
  service_connections=connections,
81
92
  project_links=links if links is not None else default_links,
93
+ project_ui_url=project_ui_url,
82
94
  )
83
95
 
84
96
 
@@ -143,7 +155,7 @@ class AnalyzeTestCase(unittest.IsolatedAsyncioTestCase):
143
155
  plugin = GitHubDoctor()
144
156
  results = await plugin.analyze(_ctx(), _CREDS)
145
157
  by = _by_slug(results)
146
- self.assertEqual(len(results), 8)
158
+ self.assertEqual(len(results), 9)
147
159
  self.assertEqual(by['exists-in'].status, 'pass')
148
160
  self.assertEqual(by['identifier-verified'].status, 'pass')
149
161
  self.assertEqual(by['canonical-url'].status, 'pass')
@@ -152,6 +164,7 @@ class AnalyzeTestCase(unittest.IsolatedAsyncioTestCase):
152
164
  self.assertEqual(by['canonical-url-shape'].status, 'pass')
153
165
  self.assertEqual(by['dashboard-url-match'].status, 'pass')
154
166
  self.assertEqual(by['github-repository-link-match'].status, 'pass')
167
+ self.assertEqual(by['homepage-url-match'].status, 'pass')
155
168
 
156
169
  @respx.mock
157
170
  async def test_identifier_not_integer(self) -> None:
@@ -324,6 +337,52 @@ class AnalyzeTestCase(unittest.IsolatedAsyncioTestCase):
324
337
  'pass',
325
338
  )
326
339
 
340
+ @respx.mock
341
+ async def test_homepage_mismatch(self) -> None:
342
+ respx.get(_CANONICAL).mock(
343
+ return_value=httpx.Response(
344
+ 200,
345
+ json={**_REPO_PAYLOAD, 'homepage': 'https://example.com'},
346
+ )
347
+ )
348
+ results = await GitHubDoctor().analyze(_ctx(), _CREDS)
349
+ item = _by_slug(results)['homepage-url-match']
350
+ self.assertEqual(item.status, 'fail')
351
+ self.assertIn(_UI_URL, item.description)
352
+ assert item.remediation is not None
353
+ self.assertEqual(item.remediation.id, _REPAIR_HOMEPAGE)
354
+
355
+ @respx.mock
356
+ async def test_homepage_unset(self) -> None:
357
+ # GitHub reports an unset homepage as null (or '' on some
358
+ # flavors); both are a soft finding with the repair offered.
359
+ for unset in (None, ''):
360
+ with self.subTest(homepage=unset):
361
+ respx.get(_CANONICAL).mock(
362
+ return_value=httpx.Response(
363
+ 200, json={**_REPO_PAYLOAD, 'homepage': unset}
364
+ )
365
+ )
366
+ results = await GitHubDoctor().analyze(_ctx(), _CREDS)
367
+ item = _by_slug(results)['homepage-url-match']
368
+ self.assertEqual(item.status, 'warn')
369
+ assert item.remediation is not None
370
+ self.assertEqual(item.remediation.id, _REPAIR_HOMEPAGE)
371
+
372
+ @respx.mock
373
+ async def test_homepage_unverifiable_without_ui_url(self) -> None:
374
+ # No IMBI_UI_URL on the API: nothing to compare against, and no
375
+ # repair is offered because there is nothing to write.
376
+ respx.get(_CANONICAL).mock(
377
+ return_value=httpx.Response(200, json=_REPO_PAYLOAD)
378
+ )
379
+ results = await GitHubDoctor().analyze(
380
+ _ctx(project_ui_url=None), _CREDS
381
+ )
382
+ item = _by_slug(results)['homepage-url-match']
383
+ self.assertEqual(item.status, 'warn')
384
+ self.assertIsNone(item.remediation)
385
+
327
386
  @respx.mock
328
387
  async def test_canonical_fetch_fails_401_no_token(self) -> None:
329
388
  respx.get(_CANONICAL).mock(return_value=httpx.Response(401))
@@ -614,6 +673,107 @@ class RemediateTestCase(unittest.IsolatedAsyncioTestCase):
614
673
  self.assertEqual(result.status, 'noop')
615
674
  self.assertIsNone(ctx.link_writeback)
616
675
 
676
+ @respx.mock
677
+ async def test_homepage_repair_patches_github(self) -> None:
678
+ respx.get(_CANONICAL).mock(
679
+ return_value=httpx.Response(
680
+ 200, json={**_REPO_PAYLOAD, 'homepage': ''}
681
+ )
682
+ )
683
+ patch = respx.patch(_PATCH_URL).mock(
684
+ return_value=httpx.Response(200, json=_REPO_PAYLOAD)
685
+ )
686
+ ctx = _ctx()
687
+ result = await GitHubDoctor().remediate(ctx, _CREDS, _REPAIR_HOMEPAGE)
688
+ self.assertEqual(result.status, 'fixed')
689
+ self.assertTrue(patch.called)
690
+ self.assertEqual(
691
+ json.loads(patch.calls.last.request.content),
692
+ {'homepage': _UI_URL},
693
+ )
694
+ # Writes to GitHub, not back into Imbi.
695
+ self.assertIsNone(ctx.link_writeback)
696
+ self.assertIsNone(ctx.service_writeback)
697
+
698
+ @respx.mock
699
+ async def test_homepage_repair_noop_when_correct(self) -> None:
700
+ respx.get(_CANONICAL).mock(
701
+ return_value=httpx.Response(200, json=_REPO_PAYLOAD)
702
+ )
703
+ patch = respx.patch(_PATCH_URL)
704
+ result = await GitHubDoctor().remediate(
705
+ _ctx(), _CREDS, _REPAIR_HOMEPAGE
706
+ )
707
+ self.assertEqual(result.status, 'noop')
708
+ self.assertFalse(patch.called)
709
+
710
+ async def test_homepage_repair_no_connection_failed(self) -> None:
711
+ result = await GitHubDoctor().remediate(
712
+ _ctx(connections=[]), _CREDS, _REPAIR_HOMEPAGE
713
+ )
714
+ self.assertEqual(result.status, 'failed')
715
+
716
+ @respx.mock
717
+ async def test_homepage_repair_without_ui_url_failed(self) -> None:
718
+ respx.get(_CANONICAL).mock(
719
+ return_value=httpx.Response(
720
+ 200, json={**_REPO_PAYLOAD, 'homepage': ''}
721
+ )
722
+ )
723
+ result = await GitHubDoctor().remediate(
724
+ _ctx(project_ui_url=None), _CREDS, _REPAIR_HOMEPAGE
725
+ )
726
+ self.assertEqual(result.status, 'failed')
727
+
728
+ @respx.mock
729
+ async def test_homepage_repair_missing_full_name_failed(self) -> None:
730
+ payload = {**_REPO_PAYLOAD, 'homepage': ''}
731
+ del payload['full_name']
732
+ respx.get(_CANONICAL).mock(
733
+ return_value=httpx.Response(200, json=payload)
734
+ )
735
+ result = await GitHubDoctor().remediate(
736
+ _ctx(), _CREDS, _REPAIR_HOMEPAGE
737
+ )
738
+ self.assertEqual(result.status, 'failed')
739
+
740
+ @respx.mock
741
+ async def test_homepage_repair_patch_rejected_failed(self) -> None:
742
+ respx.get(_CANONICAL).mock(
743
+ return_value=httpx.Response(
744
+ 200, json={**_REPO_PAYLOAD, 'homepage': ''}
745
+ )
746
+ )
747
+ respx.patch(_PATCH_URL).mock(return_value=httpx.Response(403))
748
+ result = await GitHubDoctor().remediate(
749
+ _ctx(), _CREDS, _REPAIR_HOMEPAGE
750
+ )
751
+ self.assertEqual(result.status, 'failed')
752
+
753
+ @respx.mock
754
+ async def test_homepage_repair_patch_401_propagates(self) -> None:
755
+ respx.get(_CANONICAL).mock(
756
+ return_value=httpx.Response(
757
+ 200, json={**_REPO_PAYLOAD, 'homepage': ''}
758
+ )
759
+ )
760
+ respx.patch(_PATCH_URL).mock(return_value=httpx.Response(401))
761
+ with self.assertRaises(PluginAuthenticationFailed):
762
+ await GitHubDoctor().remediate(_ctx(), _CREDS, _REPAIR_HOMEPAGE)
763
+
764
+ @respx.mock
765
+ async def test_homepage_repair_transport_error_failed(self) -> None:
766
+ respx.get(_CANONICAL).mock(
767
+ return_value=httpx.Response(
768
+ 200, json={**_REPO_PAYLOAD, 'homepage': ''}
769
+ )
770
+ )
771
+ respx.patch(_PATCH_URL).mock(side_effect=httpx.ConnectError('down'))
772
+ result = await GitHubDoctor().remediate(
773
+ _ctx(), _CREDS, _REPAIR_HOMEPAGE
774
+ )
775
+ self.assertEqual(result.status, 'failed')
776
+
617
777
  @respx.mock
618
778
  async def test_401_propagates_for_identity_retry(self) -> None:
619
779
  respx.get(_CANONICAL).mock(return_value=httpx.Response(401))