Files
relicario/extension/src/service-worker/github.ts
adlee-was-taken 63fcfae72c feat(ext/sw): GitHubHost.putBlob with Git Data API fallback
Blobs ≤ BLOB_THRESHOLD_BYTES (900 KB) take the Contents API path
(same as writeFile). Larger blobs use the Git Data API: POST blob,
GET ref + commit, POST tree (with base_tree), POST commit, PATCH ref.
Tests cover both paths plus error propagation.

getBlob/deleteBlob are thin wrappers over readFile/deleteFile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:36:10 -04:00

202 lines
6.8 KiB
TypeScript

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<string, string>;
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<Uint8Array> {
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<void> {
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<string, unknown> = { 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<void> {
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<string[]> {
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<string> {
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<Uint8Array> {
return this.readFile(path);
}
async deleteBlob(path: string, message: string): Promise<void> {
return this.deleteFile(path, message);
}
}