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>
This commit is contained in:
adlee-was-taken
2026-04-25 15:36:10 -04:00
parent 511d533de0
commit 63fcfae72c
2 changed files with 155 additions and 3 deletions

View File

@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GitHubHost } from '../github';
import { BLOB_THRESHOLD_BYTES } from '../git-host';
const REPO = 'alee/test-vault';
const TOKEN = 'ghp_TEST';
function setupFetch(): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
describe('GitHubHost.putBlob', () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
it('uses Contents API when content is at threshold', async () => {
const fetchMock = setupFetch();
// First fetch: GET existing file → 404 (create path)
fetchMock.mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found' } as Response);
// Second fetch: PUT contents → 201 created
fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({}) } as Response);
const host = new GitHubHost(REPO, TOKEN);
const small = new Uint8Array(BLOB_THRESHOLD_BYTES); // exactly threshold
await host.putBlob('attachments/abc.bin', small, 'add abc');
expect(fetchMock).toHaveBeenCalledTimes(2);
const lastCall = fetchMock.mock.calls[1];
expect(lastCall[0]).toContain('/contents/'); // Contents API
expect(lastCall[1]?.method).toBe('PUT');
});
it('uses Git Data API fallback when content exceeds threshold', async () => {
const fetchMock = setupFetch();
// 1. POST /git/blobs → blob SHA
fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'blobsha111' }) } as Response);
// 2. GET /git/refs/heads/main → ref { object: { sha: 'commitsha' } }
fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ object: { sha: 'commitsha111' } }) } as Response);
// 3. GET /git/commits/commitsha111 → commit { tree: { sha: 'treesha' } }
fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ tree: { sha: 'treesha111' } }) } as Response);
// 4. POST /git/trees → new tree { sha: 'newtreesha' }
fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'newtreesha111' }) } as Response);
// 5. POST /git/commits → new commit { sha: 'newcommitsha' }
fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'newcommitsha111' }) } as Response);
// 6. PATCH /git/refs/heads/main → updated
fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) } as Response);
const host = new GitHubHost(REPO, TOKEN);
const big = new Uint8Array(BLOB_THRESHOLD_BYTES + 1); // one byte over
await host.putBlob('attachments/big.bin', big, 'add big');
expect(fetchMock).toHaveBeenCalledTimes(6);
expect(fetchMock.mock.calls[0][0]).toContain('/git/blobs');
expect(fetchMock.mock.calls[1][0]).toContain('/git/refs/heads/');
expect(fetchMock.mock.calls[3][0]).toContain('/git/trees');
expect(fetchMock.mock.calls[4][0]).toContain('/git/commits');
expect(fetchMock.mock.calls[5][1]?.method).toBe('PATCH');
});
it('throws when blob creation fails in fallback path', async () => {
const fetchMock = setupFetch();
fetchMock.mockResolvedValueOnce({ ok: false, status: 422, statusText: 'Unprocessable', text: async () => 'too big' } as Response);
const host = new GitHubHost(REPO, TOKEN);
const big = new Uint8Array(BLOB_THRESHOLD_BYTES + 1);
await expect(host.putBlob('attachments/big.bin', big, 'add big')).rejects.toThrow(/422|too big/);
});
});

View File

@@ -1,5 +1,5 @@
import type { GitHost } from './git-host';
import { uint8ArrayToBase64, base64ToUint8Array } from './git-host';
import { uint8ArrayToBase64, base64ToUint8Array, BLOB_THRESHOLD_BYTES } from './git-host';
/// GitHub Contents API implementation.
///
@@ -13,10 +13,13 @@ import { uint8ArrayToBase64, base64ToUint8Array } from './git-host';
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',
@@ -106,8 +109,86 @@ export class GitHubHost implements GitHost {
return json.map((item: { name: string }) => item.name);
}
async putBlob(_path: string, _content: Uint8Array, _message: string): Promise<string> {
throw new Error(`GitHubHost.putBlob not implemented — Task 3`);
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> {