diff --git a/extension/src/service-worker/__tests__/git-host.test.ts b/extension/src/service-worker/__tests__/git-host.test.ts new file mode 100644 index 0000000..f177cfa --- /dev/null +++ b/extension/src/service-worker/__tests__/git-host.test.ts @@ -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 { + 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/); + }); +}); diff --git a/extension/src/service-worker/github.ts b/extension/src/service-worker/github.ts index 2e9df09..c3f2748 100644 --- a/extension/src/service-worker/github.ts +++ b/extension/src/service-worker/github.ts @@ -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; 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 { - throw new Error(`GitHubHost.putBlob not implemented — Task 3`); + 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 {