base.py 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
  1. """Abstract base class for Git hosting provider backends."""
  2. import hashlib
  3. from abc import ABC, abstractmethod
  4. import httpx
  5. class GitProviderBackend(ABC):
  6. """Abstract base for Git hosting provider API backends."""
  7. @staticmethod
  8. def _blob_sha(content_bytes: bytes) -> str:
  9. """Compute the git blob SHA for content_bytes (sha1("blob {len}\\0" + data))."""
  10. return hashlib.sha1(f"blob {len(content_bytes)}\0".encode() + content_bytes, usedforsecurity=False).hexdigest()
  11. @staticmethod
  12. def _truncated_response_text(response: httpx.Response, max_length: int = 200) -> str:
  13. """Return a bounded response body for errors surfaced to logs/UI."""
  14. text = response.text
  15. if len(text) <= max_length:
  16. return text
  17. return f"{text[: max_length - 3]}..."
  18. @staticmethod
  19. def _read_sha(response: httpx.Response, *path: str) -> tuple[str | None, str | None]:
  20. """Walk a JSON path to a string SHA value.
  21. Returns ``(sha, None)`` on success, ``(None, reason)`` if the body is
  22. not JSON, the path is missing, or the leaf is not a string. Callers
  23. use the reason to build a clear failure message instead of letting
  24. ``KeyError``/``JSONDecodeError`` bubble to the outer catch-all (which
  25. surfaces cryptic one-word strings like ``"'object'"`` to operators).
  26. """
  27. try:
  28. data = response.json()
  29. except ValueError:
  30. return None, "non-JSON response body"
  31. for key in path:
  32. if not isinstance(data, dict):
  33. return None, f"unexpected shape at key {key!r}"
  34. if key not in data:
  35. return None, f"missing key {key!r}"
  36. data = data[key]
  37. if not isinstance(data, str):
  38. return None, f"value at {'.'.join(path)} is not a string"
  39. return data, None
  40. def get_headers(self, token: str) -> dict:
  41. """Return HTTP headers for authenticated API requests."""
  42. return {
  43. "Authorization": f"token {token}",
  44. "Accept": "application/vnd.github.v3+json",
  45. "User-Agent": "Bambuddy-Backup",
  46. }
  47. @abstractmethod
  48. def parse_repo_url(self, url: str) -> tuple[str, str]:
  49. """Return (owner, repo) extracted from the repository URL."""
  50. @abstractmethod
  51. def get_api_base(self, repo_url: str) -> str:
  52. """Return the API base URL for this provider instance."""
  53. @abstractmethod
  54. async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
  55. """Test API connectivity and push permissions. Returns success/message/repo_name/permissions."""
  56. @abstractmethod
  57. async def push_files(
  58. self,
  59. repo_url: str,
  60. token: str,
  61. branch: str,
  62. files: dict,
  63. client: httpx.AsyncClient,
  64. ) -> dict:
  65. """Push files to the repository. Returns status/message/commit_sha/files_changed."""