github.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  1. """GitHub backend — implements GitProviderBackend using the GitHub Git Data API."""
  2. import base64
  3. import json
  4. import logging
  5. import re
  6. from datetime import datetime, timezone
  7. import httpx
  8. from backend.app.services.git_providers.base import GitProviderBackend
  9. logger = logging.getLogger(__name__)
  10. class GitHubBackend(GitProviderBackend):
  11. """Backend for github.com using the GitHub Git Data API."""
  12. def get_api_base(self, repo_url: str) -> str:
  13. m = re.match(r"https?://([\w.\-]+(:\d+)?)/", repo_url)
  14. if m:
  15. host = m.group(1)
  16. return "https://api.github.com" if host == "github.com" else f"https://{host}/api/v3"
  17. m = re.match(r"git@([\w.\-]+):", repo_url)
  18. if m:
  19. host = m.group(1)
  20. return "https://api.github.com" if host == "github.com" else f"https://{host}/api/v3"
  21. return "https://api.github.com"
  22. def parse_repo_url(self, url: str) -> tuple[str, str]:
  23. """Return (owner, repo) from a Git HTTPS or SSH URL."""
  24. if not url or len(url) > 500:
  25. raise ValueError("Invalid Git URL: URL too long or empty")
  26. # HTTPS: https://<host>[:<port>]/<owner>/<repo>[.git][/]
  27. match = re.match(
  28. r"https://[\w.\-]+(:\d+)?/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$",
  29. url,
  30. )
  31. if match:
  32. return match.group(2), match.group(3).removesuffix(".git")
  33. # SSH: git@<host>:<owner>/<repo>[.git]
  34. match = re.match(
  35. r"git@[\w.\-]+:([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?$",
  36. url,
  37. )
  38. if match:
  39. return match.group(1), match.group(2).removesuffix(".git")
  40. raise ValueError(f"Cannot parse repository URL: {url}")
  41. async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
  42. """Test API access and push permission for the repository."""
  43. try:
  44. owner, repo = self.parse_repo_url(repo_url)
  45. api_base = self.get_api_base(repo_url)
  46. headers = self.get_headers(token)
  47. response = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
  48. if response.status_code == 401:
  49. return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
  50. if response.status_code == 404:
  51. return {
  52. "success": False,
  53. "message": "Repository not found. Check URL and token permissions.",
  54. "repo_name": None,
  55. "permissions": None,
  56. }
  57. if response.status_code != 200:
  58. return {
  59. "success": False,
  60. "message": f"API error: {response.status_code}",
  61. "repo_name": None,
  62. "permissions": None,
  63. }
  64. data = response.json()
  65. permissions = data.get("permissions", {})
  66. is_private = bool(data.get("private", False))
  67. if not permissions.get("push", False):
  68. return {
  69. "success": False,
  70. "message": "Token does not have push permission to this repository",
  71. "repo_name": data.get("full_name"),
  72. "permissions": permissions,
  73. "is_private": is_private,
  74. }
  75. return {
  76. "success": True,
  77. "message": "Connection successful",
  78. "repo_name": data.get("full_name"),
  79. "permissions": permissions,
  80. "is_private": is_private,
  81. }
  82. except Exception as e:
  83. logger.exception("Git connection test failed")
  84. detail = str(e)[:200]
  85. message = (
  86. f"Connection failed: {type(e).__name__}: {detail}"
  87. if detail
  88. else f"Connection failed: {type(e).__name__}"
  89. )
  90. return {
  91. "success": False,
  92. "message": message,
  93. "repo_name": None,
  94. "permissions": None,
  95. "is_private": None,
  96. }
  97. async def list_commits(
  98. self,
  99. repo_url: str,
  100. token: str,
  101. branch: str,
  102. client: httpx.AsyncClient,
  103. limit: int = 20,
  104. ) -> dict:
  105. """List recent commits on ``branch`` via the repo commits API."""
  106. try:
  107. owner, repo = self.parse_repo_url(repo_url)
  108. api_base = self.get_api_base(repo_url)
  109. headers = self.get_headers(token)
  110. # GitHub pages with ``per_page`` and ignores ``limit``; Gitea/Forgejo
  111. # do the reverse. Sending both lets GiteaBackend inherit this method
  112. # unchanged instead of duplicating it for one query parameter.
  113. response = await client.get(
  114. f"{api_base}/repos/{owner}/{repo}/commits",
  115. headers=headers,
  116. params={"sha": branch, "per_page": limit, "limit": limit},
  117. )
  118. if response.status_code == 404:
  119. return {
  120. "success": False,
  121. "message": (
  122. f"Branch '{branch}' not found, or the repository has no commits yet. "
  123. "Run a backup before restoring."
  124. ),
  125. "commits": [],
  126. }
  127. if response.status_code != 200:
  128. msg = f"Failed to list commits (HTTP {response.status_code}): {self._truncated_response_text(response)}"
  129. logger.warning("list_commits %s/%s: %s", owner, repo, msg)
  130. return {"success": False, "message": msg, "commits": []}
  131. try:
  132. data = response.json()
  133. except ValueError:
  134. return {"success": False, "message": "Non-JSON response listing commits", "commits": []}
  135. if not isinstance(data, list):
  136. return {"success": False, "message": "Unexpected shape listing commits", "commits": []}
  137. return {"success": True, "message": "OK", "commits": self._parse_commit_entries(data, limit)}
  138. except Exception as e:
  139. logger.exception("list_commits failed for %s branch=%s", repo_url, branch)
  140. return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commits": []}
  141. @staticmethod
  142. def _parse_commit_entries(data: list, limit: int) -> list[dict]:
  143. """Normalise GitHub/Gitea commit list entries to our flat shape."""
  144. commits = []
  145. for entry in data[:limit]:
  146. if not isinstance(entry, dict):
  147. continue
  148. sha = entry.get("sha")
  149. if not isinstance(sha, str) or not sha:
  150. continue
  151. commit = entry.get("commit") if isinstance(entry.get("commit"), dict) else {}
  152. author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
  153. commits.append(
  154. {
  155. "sha": sha,
  156. "message": commit.get("message") or "",
  157. "author": author.get("name") or "",
  158. "date": author.get("date") or "",
  159. }
  160. )
  161. return commits
  162. async def _blob_shas_at(
  163. self,
  164. client: httpx.AsyncClient,
  165. headers: dict,
  166. api_base: str,
  167. owner: str,
  168. repo: str,
  169. ref: str,
  170. ) -> tuple[dict[str, str] | None, str]:
  171. """Return ``({path: blob_sha}, "")`` at ``ref``, or ``(None, error_message)``.
  172. A commit SHA is a valid tree-ish for the trees API, so this resolves the
  173. commit's tree in one request rather than commit -> tree -> list.
  174. """
  175. response = await client.get(
  176. f"{api_base}/repos/{owner}/{repo}/git/trees/{ref}?recursive=1",
  177. headers=headers,
  178. )
  179. if response.status_code == 404:
  180. return None, f"Commit or tree '{ref}' not found in the repository"
  181. if response.status_code != 200:
  182. return None, f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}"
  183. try:
  184. data = response.json()
  185. except ValueError:
  186. return None, "Non-JSON response listing tree"
  187. # Same limit the push path guards against: a truncated listing would make
  188. # a restore silently skip categories that are actually in the backup.
  189. if data.get("truncated"):
  190. return None, (
  191. "Repository tree exceeds the API listing limit (truncated=true), so the backup "
  192. "contents cannot be enumerated reliably. Rotate the backup repository."
  193. )
  194. blobs: dict[str, str] = {}
  195. for item in data.get("tree", []):
  196. if not isinstance(item, dict) or item.get("type") != "blob":
  197. continue
  198. path, sha = item.get("path"), item.get("sha")
  199. if isinstance(path, str) and isinstance(sha, str) and path and sha:
  200. blobs[path] = sha
  201. return blobs, ""
  202. async def get_commit(self, repo_url: str, token: str, ref: str, client: httpx.AsyncClient) -> dict:
  203. """Read one commit's metadata directly, for refs outside the list window."""
  204. try:
  205. owner, repo = self.parse_repo_url(repo_url)
  206. api_base = self.get_api_base(repo_url)
  207. headers = self.get_headers(token)
  208. response = await client.get(f"{api_base}/repos/{owner}/{repo}/commits/{ref}", headers=headers)
  209. if response.status_code == 404:
  210. return {"success": False, "message": f"Commit '{ref}' not found in the repository", "commit": None}
  211. if response.status_code != 200:
  212. msg = f"Failed to read commit (HTTP {response.status_code}): {self._truncated_response_text(response)}"
  213. logger.warning("get_commit %s/%s ref=%s: %s", owner, repo, ref, msg)
  214. return {"success": False, "message": msg, "commit": None}
  215. try:
  216. data = response.json()
  217. except ValueError:
  218. return {"success": False, "message": "Non-JSON response reading commit", "commit": None}
  219. if not isinstance(data, dict):
  220. return {"success": False, "message": "Unexpected shape reading commit", "commit": None}
  221. # Same entry shape as list_commits, so callers can treat the two
  222. # interchangeably.
  223. parsed = self._parse_commit_entries([data], 1)
  224. if not parsed:
  225. return {"success": False, "message": "Commit response carried no SHA", "commit": None}
  226. return {"success": True, "message": "OK", "commit": parsed[0]}
  227. except Exception as e:
  228. logger.exception("get_commit failed for %s ref=%s", repo_url, ref)
  229. return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commit": None}
  230. async def list_tree(
  231. self,
  232. repo_url: str,
  233. token: str,
  234. ref: str,
  235. client: httpx.AsyncClient,
  236. ) -> dict:
  237. """List blob paths present at ``ref`` via the Git Data trees API."""
  238. try:
  239. owner, repo = self.parse_repo_url(repo_url)
  240. api_base = self.get_api_base(repo_url)
  241. headers = self.get_headers(token)
  242. blobs, error = await self._blob_shas_at(client, headers, api_base, owner, repo, ref)
  243. if blobs is None:
  244. logger.warning("list_tree %s/%s ref=%s: %s", owner, repo, ref, error)
  245. return {"success": False, "message": error, "paths": [], "blob_shas": {}}
  246. # The map is handed back so fetch_files does not GET the same
  247. # recursive tree a second time for the same ref.
  248. return {"success": True, "message": "OK", "paths": sorted(blobs), "blob_shas": blobs}
  249. except Exception as e:
  250. logger.exception("list_tree failed for %s ref=%s", repo_url, ref)
  251. return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "paths": [], "blob_shas": {}}
  252. async def fetch_files(
  253. self,
  254. repo_url: str,
  255. token: str,
  256. ref: str,
  257. paths: list[str],
  258. client: httpx.AsyncClient,
  259. blob_shas: dict[str, str] | None = None,
  260. ) -> dict:
  261. """Read ``paths`` at ``ref`` via the Git Data blobs API.
  262. The blobs API is used rather than the contents API because contents
  263. inlines only files up to 1 MB — an archive-heavy ``print_history.json``
  264. can exceed that, and it would come back with an empty body instead of an
  265. error.
  266. """
  267. try:
  268. owner, repo = self.parse_repo_url(repo_url)
  269. api_base = self.get_api_base(repo_url)
  270. headers = self.get_headers(token)
  271. blobs = blob_shas
  272. if blobs is None:
  273. blobs, error = await self._blob_shas_at(client, headers, api_base, owner, repo, ref)
  274. if blobs is None:
  275. logger.warning("fetch_files %s/%s ref=%s: %s", owner, repo, ref, error)
  276. return {"success": False, "message": error, "files": {}}
  277. files: dict[str, str] = {}
  278. for path in paths:
  279. sha = blobs.get(path)
  280. if sha is None:
  281. continue
  282. response = await client.get(f"{api_base}/repos/{owner}/{repo}/git/blobs/{sha}", headers=headers)
  283. if response.status_code != 200:
  284. msg = f"Failed to read {path} (HTTP {response.status_code}): {self._truncated_response_text(response)}"
  285. logger.warning("fetch_files %s/%s: %s", owner, repo, msg)
  286. return {"success": False, "message": msg, "files": {}}
  287. text, error = self._decode_blob(response, path)
  288. if text is None:
  289. logger.warning("fetch_files %s/%s: %s", owner, repo, error)
  290. return {"success": False, "message": error, "files": {}}
  291. files[path] = text
  292. return {"success": True, "message": "OK", "files": files}
  293. except Exception as e:
  294. logger.exception("fetch_files failed for %s ref=%s", repo_url, ref)
  295. return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "files": {}}
  296. def _decode_blob(self, response: httpx.Response, path: str) -> tuple[str | None, str]:
  297. """Decode a blob API response body to text, or return an error message."""
  298. try:
  299. data = response.json()
  300. except ValueError:
  301. return None, f"Non-JSON response reading {path}"
  302. if not isinstance(data, dict):
  303. return None, f"Unexpected shape reading {path}"
  304. content = data.get("content")
  305. if not isinstance(content, str):
  306. return None, f"Missing content reading {path}"
  307. encoding = data.get("encoding", "base64")
  308. try:
  309. if encoding == "base64":
  310. # Both providers wrap base64 payloads at 60 chars; b64decode
  311. # tolerates the newlines, but be explicit about it.
  312. return base64.b64decode(content).decode("utf-8"), ""
  313. if encoding in ("utf-8", "text", "plain"):
  314. return content, ""
  315. except (ValueError, UnicodeDecodeError) as e:
  316. return None, f"Could not decode {path}: {type(e).__name__}"
  317. return None, f"Unsupported blob encoding {encoding!r} reading {path}"
  318. async def push_files(
  319. self,
  320. repo_url: str,
  321. token: str,
  322. branch: str,
  323. files: dict,
  324. client: httpx.AsyncClient,
  325. _allow_branch_create: bool = True,
  326. ) -> dict:
  327. """Push files to the repository using the Git Data API."""
  328. try:
  329. owner, repo = self.parse_repo_url(repo_url)
  330. api_base = self.get_api_base(repo_url)
  331. headers = self.get_headers(token)
  332. ref_response = await client.get(f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{branch}", headers=headers)
  333. if ref_response.status_code == 404:
  334. if not _allow_branch_create:
  335. return {
  336. "status": "failed",
  337. "message": (
  338. f"Branch '{branch}' not found after creation — possible replication lag. "
  339. "The next scheduled backup will retry."
  340. ),
  341. }
  342. return await self._create_branch_and_push(
  343. client, headers, api_base, owner, repo, branch, files, repo_url, token
  344. )
  345. if ref_response.status_code != 200:
  346. msg = f"Failed to get branch ref (HTTP {ref_response.status_code}): {self._truncated_response_text(ref_response)}"
  347. logger.warning("push_files %s/%s: %s", owner, repo, msg)
  348. return {"status": "failed", "message": msg, "error": self._truncated_response_text(ref_response)}
  349. current_commit_sha, err = self._read_sha(ref_response, "object", "sha")
  350. if err:
  351. msg = f"Malformed ref response ({err}): {self._truncated_response_text(ref_response)}"
  352. logger.warning("push_files %s/%s: %s", owner, repo, msg)
  353. return {"status": "failed", "message": msg}
  354. commit_response = await client.get(
  355. f"{api_base}/repos/{owner}/{repo}/git/commits/{current_commit_sha}", headers=headers
  356. )
  357. if commit_response.status_code != 200:
  358. msg = f"Failed to get current commit (HTTP {commit_response.status_code}): {self._truncated_response_text(commit_response)}"
  359. logger.warning("push_files %s/%s: %s", owner, repo, msg)
  360. return {"status": "failed", "message": msg}
  361. current_tree_sha, err = self._read_sha(commit_response, "tree", "sha")
  362. if err:
  363. msg = f"Malformed commit response ({err}): {self._truncated_response_text(commit_response)}"
  364. logger.warning("push_files %s/%s: %s", owner, repo, msg)
  365. return {"status": "failed", "message": msg}
  366. tree_response = await client.get(
  367. f"{api_base}/repos/{owner}/{repo}/git/trees/{current_tree_sha}?recursive=1", headers=headers
  368. )
  369. if tree_response.status_code != 200:
  370. msg = f"Failed to list existing tree (HTTP {tree_response.status_code}): {self._truncated_response_text(tree_response)}"
  371. logger.warning("push_files %s/%s: %s", owner, repo, msg)
  372. return {"status": "failed", "message": msg, "error": self._truncated_response_text(tree_response)}
  373. tree_data = tree_response.json()
  374. # GitHub's tree API truncates >7MB / >100k entries. A truncated tree
  375. # listing makes the SHA-equality dedup miss and every file gets
  376. # re-uploaded as a new blob each run — silent churn until someone
  377. # notices the bloated history. Fail loudly so the user rotates the
  378. # backup repo.
  379. if tree_data.get("truncated"):
  380. msg = (
  381. "Repository tree exceeds the GitHub API listing limit (truncated=true). "
  382. "Rotate the backup repository to avoid silent file-by-file churn on every backup."
  383. )
  384. logger.warning("push_files %s/%s: %s", owner, repo, msg)
  385. return {"status": "failed", "message": msg}
  386. existing_files: dict[str, str] = {}
  387. for item in tree_data.get("tree", []):
  388. if item.get("type") != "blob":
  389. continue
  390. path, sha = item.get("path"), item.get("sha")
  391. if not path or not sha:
  392. logger.warning("push_files: skipping malformed tree entry: %s", item)
  393. continue
  394. existing_files[path] = sha
  395. tree_items = []
  396. files_changed = 0
  397. for path, content in files.items():
  398. content_str = json.dumps(content, indent=2, default=str)
  399. content_bytes = content_str.encode("utf-8")
  400. content_sha = self._blob_sha(content_bytes)
  401. if path in existing_files and existing_files[path] == content_sha:
  402. continue
  403. blob_response = await client.post(
  404. f"{api_base}/repos/{owner}/{repo}/git/blobs",
  405. headers=headers,
  406. json={"content": base64.b64encode(content_bytes).decode(), "encoding": "base64"},
  407. )
  408. if blob_response.status_code == 404:
  409. msg = "GitHub API returned 404 for POST /git/blobs — check repository visibility and token scope"
  410. logger.warning("push_files %s/%s: %s", owner, repo, msg)
  411. return {"status": "failed", "message": msg}
  412. if blob_response.status_code != 201:
  413. msg = f"Failed to create blob for {path} (HTTP {blob_response.status_code}): {self._truncated_response_text(blob_response)}"
  414. logger.warning("push_files %s/%s: %s", owner, repo, msg)
  415. return {"status": "failed", "message": msg}
  416. blob_sha, err = self._read_sha(blob_response, "sha")
  417. if err:
  418. msg = f"Malformed blob response for {path} ({err}): {self._truncated_response_text(blob_response)}"
  419. logger.warning("push_files %s/%s: %s", owner, repo, msg)
  420. return {"status": "failed", "message": msg}
  421. tree_items.append({"path": path, "mode": "100644", "type": "blob", "sha": blob_sha})
  422. files_changed += 1
  423. if not tree_items:
  424. return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0}
  425. tree_response = await client.post(
  426. f"{api_base}/repos/{owner}/{repo}/git/trees",
  427. headers=headers,
  428. json={"base_tree": current_tree_sha, "tree": tree_items},
  429. )
  430. if tree_response.status_code != 201:
  431. msg = f"Failed to create tree (HTTP {tree_response.status_code}): {self._truncated_response_text(tree_response)}"
  432. logger.warning("push_files %s/%s: %s", owner, repo, msg)
  433. return {"status": "failed", "message": msg}
  434. new_tree_sha, err = self._read_sha(tree_response, "sha")
  435. if err:
  436. msg = f"Malformed tree-create response ({err}): {self._truncated_response_text(tree_response)}"
  437. logger.warning("push_files %s/%s: %s", owner, repo, msg)
  438. return {"status": "failed", "message": msg}
  439. commit_message = f"Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
  440. commit_response = await client.post(
  441. f"{api_base}/repos/{owner}/{repo}/git/commits",
  442. headers=headers,
  443. json={"message": commit_message, "tree": new_tree_sha, "parents": [current_commit_sha]},
  444. )
  445. if commit_response.status_code != 201:
  446. msg = f"Failed to create commit (HTTP {commit_response.status_code}): {self._truncated_response_text(commit_response)}"
  447. logger.warning("push_files %s/%s: %s", owner, repo, msg)
  448. return {"status": "failed", "message": msg}
  449. new_commit_sha, err = self._read_sha(commit_response, "sha")
  450. if err:
  451. msg = f"Malformed commit-create response ({err}): {self._truncated_response_text(commit_response)}"
  452. logger.warning("push_files %s/%s: %s", owner, repo, msg)
  453. return {"status": "failed", "message": msg}
  454. ref_update = await client.patch(
  455. f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{branch}",
  456. headers=headers,
  457. json={"sha": new_commit_sha},
  458. )
  459. if ref_update.status_code != 200:
  460. msg = f"Failed to update branch (HTTP {ref_update.status_code}): {self._truncated_response_text(ref_update)}"
  461. logger.warning("push_files %s/%s: %s", owner, repo, msg)
  462. return {"status": "failed", "message": msg}
  463. return {
  464. "status": "success",
  465. "message": f"Backup successful - {files_changed} files updated",
  466. "commit_sha": new_commit_sha,
  467. "files_changed": files_changed,
  468. }
  469. except Exception as e:
  470. logger.exception("push_files failed for %s branch=%s", repo_url, branch)
  471. return {"status": "failed", "message": str(e), "error": str(e)}
  472. async def _create_branch_and_push(
  473. self,
  474. client: httpx.AsyncClient,
  475. headers: dict,
  476. api_base: str,
  477. owner: str,
  478. repo: str,
  479. branch: str,
  480. files: dict,
  481. repo_url: str,
  482. token: str,
  483. ) -> dict:
  484. """Create branch (from default branch or as initial commit) then push."""
  485. try:
  486. repo_response = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
  487. if repo_response.status_code != 200:
  488. msg = f"Failed to get repo info (HTTP {repo_response.status_code}): {self._truncated_response_text(repo_response)}"
  489. logger.warning("_create_branch_and_push %s/%s: %s", owner, repo, msg)
  490. return {"status": "failed", "message": msg}
  491. try:
  492. default_branch = repo_response.json().get("default_branch", "main")
  493. except ValueError:
  494. msg = f"Malformed repo-info response (non-JSON body): {self._truncated_response_text(repo_response)}"
  495. logger.warning("_create_branch_and_push %s/%s: %s", owner, repo, msg)
  496. return {"status": "failed", "message": msg}
  497. ref_response = await client.get(
  498. f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{default_branch}", headers=headers
  499. )
  500. if ref_response.status_code != 200:
  501. return await self._create_initial_commit(client, headers, api_base, owner, repo, branch, files)
  502. base_sha, err = self._read_sha(ref_response, "object", "sha")
  503. if err:
  504. msg = f"Malformed default-branch ref response ({err}): {self._truncated_response_text(ref_response)}"
  505. logger.warning("_create_branch_and_push %s/%s: %s", owner, repo, msg)
  506. return {"status": "failed", "message": msg}
  507. create_ref = await client.post(
  508. f"{api_base}/repos/{owner}/{repo}/git/refs",
  509. headers=headers,
  510. json={"ref": f"refs/heads/{branch}", "sha": base_sha},
  511. )
  512. if create_ref.status_code != 201:
  513. msg = f"Failed to create branch '{branch}' (HTTP {create_ref.status_code}): {self._truncated_response_text(create_ref)}"
  514. logger.warning("_create_branch_and_push %s/%s: %s", owner, repo, msg)
  515. return {"status": "failed", "message": msg}
  516. logger.info("Re-entering push_files after branch create %s/%s -> %s", owner, repo, branch)
  517. return await self.push_files(repo_url, token, branch, files, client, _allow_branch_create=False)
  518. except Exception as e:
  519. logger.exception("_create_branch_and_push failed for %s/%s branch=%s", owner, repo, branch)
  520. return {"status": "failed", "message": str(e), "error": str(e)}
  521. async def _create_initial_commit(
  522. self,
  523. client: httpx.AsyncClient,
  524. headers: dict,
  525. api_base: str,
  526. owner: str,
  527. repo: str,
  528. branch: str,
  529. files: dict,
  530. ) -> dict:
  531. """Create the first commit in an empty repository."""
  532. try:
  533. tree_items = []
  534. for path, content in files.items():
  535. content_str = json.dumps(content, indent=2, default=str)
  536. blob_response = await client.post(
  537. f"{api_base}/repos/{owner}/{repo}/git/blobs",
  538. headers=headers,
  539. json={"content": base64.b64encode(content_str.encode()).decode(), "encoding": "base64"},
  540. )
  541. if blob_response.status_code == 404:
  542. msg = "GitHub API returned 404 for POST /git/blobs — check repository visibility and token scope"
  543. logger.warning("_create_initial_commit %s/%s: %s", owner, repo, msg)
  544. return {"status": "failed", "message": msg}
  545. if blob_response.status_code != 201:
  546. msg = f"Failed to create blob for {path} (HTTP {blob_response.status_code}): {self._truncated_response_text(blob_response)}"
  547. logger.warning("_create_initial_commit %s/%s: %s", owner, repo, msg)
  548. return {"status": "failed", "message": msg}
  549. blob_sha, err = self._read_sha(blob_response, "sha")
  550. if err:
  551. msg = f"Malformed blob response for {path} ({err}): {self._truncated_response_text(blob_response)}"
  552. logger.warning("_create_initial_commit %s/%s: %s", owner, repo, msg)
  553. return {"status": "failed", "message": msg}
  554. tree_items.append({"path": path, "mode": "100644", "type": "blob", "sha": blob_sha})
  555. tree_response = await client.post(
  556. f"{api_base}/repos/{owner}/{repo}/git/trees",
  557. headers=headers,
  558. json={"tree": tree_items},
  559. )
  560. if tree_response.status_code != 201:
  561. msg = f"Failed to create tree (HTTP {tree_response.status_code}): {self._truncated_response_text(tree_response)}"
  562. logger.warning("_create_initial_commit %s/%s: %s", owner, repo, msg)
  563. return {"status": "failed", "message": msg}
  564. tree_sha, err = self._read_sha(tree_response, "sha")
  565. if err:
  566. msg = f"Malformed tree-create response ({err}): {self._truncated_response_text(tree_response)}"
  567. logger.warning("_create_initial_commit %s/%s: %s", owner, repo, msg)
  568. return {"status": "failed", "message": msg}
  569. commit_response = await client.post(
  570. f"{api_base}/repos/{owner}/{repo}/git/commits",
  571. headers=headers,
  572. json={
  573. "message": f"Initial Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
  574. "tree": tree_sha,
  575. },
  576. )
  577. if commit_response.status_code != 201:
  578. msg = f"Failed to create commit (HTTP {commit_response.status_code}): {self._truncated_response_text(commit_response)}"
  579. logger.warning("_create_initial_commit %s/%s: %s", owner, repo, msg)
  580. return {"status": "failed", "message": msg}
  581. commit_sha, err = self._read_sha(commit_response, "sha")
  582. if err:
  583. msg = f"Malformed commit-create response ({err}): {self._truncated_response_text(commit_response)}"
  584. logger.warning("_create_initial_commit %s/%s: %s", owner, repo, msg)
  585. return {"status": "failed", "message": msg}
  586. ref_response = await client.post(
  587. f"{api_base}/repos/{owner}/{repo}/git/refs",
  588. headers=headers,
  589. json={"ref": f"refs/heads/{branch}", "sha": commit_sha},
  590. )
  591. if ref_response.status_code != 201:
  592. msg = f"Failed to create branch ref (HTTP {ref_response.status_code}): {self._truncated_response_text(ref_response)}"
  593. logger.warning("_create_initial_commit %s/%s: %s", owner, repo, msg)
  594. return {"status": "failed", "message": msg}
  595. return {
  596. "status": "success",
  597. "message": f"Initial backup created - {len(files)} files",
  598. "commit_sha": commit_sha,
  599. "files_changed": len(files),
  600. }
  601. except Exception as e:
  602. logger.exception("_create_initial_commit failed for %s/%s branch=%s", owner, repo, branch)
  603. return {"status": "failed", "message": str(e), "error": str(e)}