forgejo.py 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. """Forgejo backend — diverges from Gitea on token-scope validation (v15+)."""
  2. import logging
  3. import httpx
  4. from backend.app.services.git_providers.gitea import GiteaBackend
  5. logger = logging.getLogger(__name__)
  6. class ForgejoBackend(GiteaBackend):
  7. """Backend for Forgejo instances.
  8. Forgejo v15+ returns 404 (not 403) for private repositories when the token
  9. lacks repository scope, requiring a /user pre-check to distinguish bad tokens
  10. from inaccessible repos. test_connection is overridden to handle this.
  11. Other methods are inherited from GiteaBackend unchanged.
  12. """
  13. async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
  14. try:
  15. owner, repo = self.parse_repo_url(repo_url)
  16. api_base = self.get_api_base(repo_url)
  17. headers = self.get_headers(token)
  18. # Verify token validity before hitting the repo. On Forgejo v15+,
  19. # private repos return 404 (not 403) when the token lacks repo scope,
  20. # so we must distinguish "bad token" from "token OK but repo not visible".
  21. user_resp = await client.get(f"{api_base}/user", headers=headers)
  22. if user_resp.status_code == 401:
  23. return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
  24. if user_resp.status_code == 403:
  25. return {
  26. "success": False,
  27. "message": "Token has no read:user scope; cannot validate identity",
  28. "repo_name": None,
  29. "permissions": None,
  30. }
  31. if user_resp.status_code != 200:
  32. return {
  33. "success": False,
  34. "message": f"Forgejo API error on /user: {user_resp.status_code}",
  35. "repo_name": None,
  36. "permissions": None,
  37. }
  38. repo_resp = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
  39. if repo_resp.status_code == 404:
  40. return {
  41. "success": False,
  42. "message": (
  43. "Repository not found or token cannot access it. "
  44. "On Forgejo v15+, private repositories return 404 (not 403) "
  45. "when the token lacks repository scope."
  46. ),
  47. "repo_name": None,
  48. "permissions": None,
  49. }
  50. if repo_resp.status_code != 200:
  51. return {
  52. "success": False,
  53. "message": f"API error: {repo_resp.status_code}",
  54. "repo_name": None,
  55. "permissions": None,
  56. }
  57. data = repo_resp.json()
  58. permissions = data.get("permissions", {})
  59. is_private = bool(data.get("private", False))
  60. if not permissions.get("push", False):
  61. return {
  62. "success": False,
  63. "message": "Token does not have push permission to this repository",
  64. "repo_name": data.get("full_name"),
  65. "permissions": permissions,
  66. "is_private": is_private,
  67. }
  68. return {
  69. "success": True,
  70. "message": "Connection successful",
  71. "repo_name": data.get("full_name"),
  72. "permissions": permissions,
  73. "is_private": is_private,
  74. }
  75. except Exception as e:
  76. logger.exception("Forgejo connection test failed")
  77. detail = str(e)[:200]
  78. message = (
  79. f"Connection failed: {type(e).__name__}: {detail}"
  80. if detail
  81. else f"Connection failed: {type(e).__name__}"
  82. )
  83. return {
  84. "success": False,
  85. "message": message,
  86. "repo_name": None,
  87. "permissions": None,
  88. "is_private": None,
  89. }