import type { GitHost } from './git-host'; import { uint8ArrayToBase64, base64ToUint8Array, BLOB_THRESHOLD_BYTES } from './git-host'; /// GitHub Contents API implementation. /// /// Endpoints: /// GET https://api.github.com/repos/{repoPath}/contents/{path} /// PUT https://api.github.com/repos/{repoPath}/contents/{path} (create/update) /// DELETE https://api.github.com/repos/{repoPath}/contents/{path} /// /// Auth: `Bearer {apiToken}` header. /// Content is base64-encoded. Updates and deletes require the current file SHA. export class GitHubHost implements GitHost { private baseUrl: string; private gitApiBase: string; private branch: string = 'main'; private headers: Record; constructor(repoPath: string, apiToken: string) { this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`; this.gitApiBase = `https://api.github.com/repos/${repoPath}/git`; this.headers = { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json', 'Accept': 'application/vnd.github.v3+json', 'X-GitHub-Api-Version': '2022-11-28', }; } async readFile(path: string): Promise { const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers, }); if (!resp.ok) { throw new Error(`GitHub readFile ${path}: ${resp.status} ${resp.statusText}`); } const json = await resp.json(); const clean = (json.content as string).replace(/\n/g, ''); return base64ToUint8Array(clean); } async writeFile(path: string, content: Uint8Array, message: string): Promise { const b64 = uint8ArrayToBase64(content); // Try to get the current SHA for an update; if 404 it's a create. let sha: string | null = null; try { const existing = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers, }); if (existing.ok) { const json = await existing.json(); sha = json.sha as string; } } catch { // File does not exist — will create. } const body: Record = { content: b64, message }; if (sha) { body.sha = sha; } const resp = await fetch(`${this.baseUrl}/${path}`, { method: 'PUT', headers: this.headers, body: JSON.stringify(body), }); if (!resp.ok) { const text = await resp.text(); throw new Error(`GitHub writeFile ${path}: ${resp.status} ${text}`); } } async deleteFile(path: string, message: string): Promise { const existing = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers, }); if (!existing.ok) { throw new Error(`GitHub deleteFile ${path}: file not found (${existing.status})`); } const json = await existing.json(); const sha = json.sha as string; const resp = await fetch(`${this.baseUrl}/${path}`, { method: 'DELETE', headers: this.headers, body: JSON.stringify({ message, sha }), }); if (!resp.ok) { const text = await resp.text(); throw new Error(`GitHub deleteFile ${path}: ${resp.status} ${text}`); } } async listDir(path: string): Promise { const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers, }); if (!resp.ok) { if (resp.status === 404) return []; throw new Error(`GitHub listDir ${path}: ${resp.status} ${resp.statusText}`); } const json = await resp.json(); if (!Array.isArray(json)) return []; return json.map((item: { name: string }) => item.name); } async putBlob(path: string, content: Uint8Array, message: string): Promise { if (content.length <= BLOB_THRESHOLD_BYTES) { // Contents API path — same as writeFile await this.writeFile(path, content, message); return path; } // Git Data API fallback for large blobs. // 1. Create the blob. const blobResp = await fetch(`${this.gitApiBase}/blobs`, { method: 'POST', headers: this.headers, body: JSON.stringify({ content: uint8ArrayToBase64(content), encoding: 'base64', }), }); if (!blobResp.ok) { const text = await blobResp.text(); throw new Error(`GitHub putBlob create-blob ${path}: ${blobResp.status} ${text}`); } const { sha: blobSha } = await blobResp.json() as { sha: string }; // 2. Get current ref (branch tip commit SHA). const refResp = await fetch(`${this.gitApiBase}/refs/heads/${this.branch}`, { headers: this.headers }); if (!refResp.ok) { throw new Error(`GitHub putBlob get-ref ${this.branch}: ${refResp.status} ${refResp.statusText}`); } const { object: { sha: commitSha } } = await refResp.json() as { object: { sha: string } }; // 3. Get current commit's tree SHA. const commitResp = await fetch(`${this.gitApiBase}/commits/${commitSha}`, { headers: this.headers }); if (!commitResp.ok) { throw new Error(`GitHub putBlob get-commit ${commitSha}: ${commitResp.status} ${commitResp.statusText}`); } const { tree: { sha: baseTreeSha } } = await commitResp.json() as { tree: { sha: string } }; // 4. Create new tree adding the blob at `path` on top of base tree. const treeResp = await fetch(`${this.gitApiBase}/trees`, { method: 'POST', headers: this.headers, body: JSON.stringify({ base_tree: baseTreeSha, tree: [{ path, mode: '100644', type: 'blob', sha: blobSha }], }), }); if (!treeResp.ok) { const text = await treeResp.text(); throw new Error(`GitHub putBlob create-tree: ${treeResp.status} ${text}`); } const { sha: newTreeSha } = await treeResp.json() as { sha: string }; // 5. Create new commit pointing at the new tree. const newCommitResp = await fetch(`${this.gitApiBase}/commits`, { method: 'POST', headers: this.headers, body: JSON.stringify({ message, tree: newTreeSha, parents: [commitSha], }), }); if (!newCommitResp.ok) { const text = await newCommitResp.text(); throw new Error(`GitHub putBlob create-commit: ${newCommitResp.status} ${text}`); } const { sha: newCommitSha } = await newCommitResp.json() as { sha: string }; // 6. Fast-forward branch to the new commit. const updateRefResp = await fetch(`${this.gitApiBase}/refs/heads/${this.branch}`, { method: 'PATCH', headers: this.headers, body: JSON.stringify({ sha: newCommitSha, force: false }), }); if (!updateRefResp.ok) { const text = await updateRefResp.text(); throw new Error(`GitHub putBlob update-ref: ${updateRefResp.status} ${text}`); } return path; } async getBlob(path: string): Promise { return this.readFile(path); } async deleteBlob(path: string, message: string): Promise { return this.deleteFile(path, message); } }