From 27ca91234fa7e1965ed0dd3b40dcba427dae3e41 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 25 Apr 2026 15:46:02 -0400 Subject: [PATCH] feat(ext/sw): GiteaHost.putBlob with Git Data API fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same shape as GitHubHost (commit dc660c4) — Gitea v1 has /api/v1/ prefix, otherwise the endpoint shapes are identical. 2 new tests; total 5 git-host tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../service-worker/__tests__/git-host.test.ts | 53 +++++++++++ extension/src/service-worker/gitea.ts | 88 ++++++++++++++++++- 2 files changed, 138 insertions(+), 3 deletions(-) diff --git a/extension/src/service-worker/__tests__/git-host.test.ts b/extension/src/service-worker/__tests__/git-host.test.ts index 5aae457..c1274e5 100644 --- a/extension/src/service-worker/__tests__/git-host.test.ts +++ b/extension/src/service-worker/__tests__/git-host.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { GitHubHost } from '../github'; +import { GiteaHost } from '../gitea'; import { BLOB_THRESHOLD_BYTES } from '../git-host'; const REPO = 'alee/test-vault'; @@ -70,3 +71,55 @@ describe('GitHubHost.putBlob', () => { await expect(host.putBlob('attachments/big.bin', big, 'add big')).rejects.toThrow(/422|too big/); }); }); + +describe('GiteaHost.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: POST contents → 201 created + fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({}) } as Response); + + const host = new GiteaHost('https://git.example.com', REPO, TOKEN); + const small = new Uint8Array(BLOB_THRESHOLD_BYTES); // exactly threshold + await host.putBlob('attachments/abc.bin', small, 'add abc'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1][0]).toContain('/contents/'); + expect(fetchMock.mock.calls[1][1]?.method).toBe('POST'); + }); + + it('uses Git Data API fallback when content exceeds threshold', async () => { + const fetchMock = setupFetch(); + // 1. POST /api/v1/.../git/blobs → blob SHA + fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'blobsha111' }) } as Response); + // 2. GET /api/v1/.../git/refs/heads/main → ref { object: { sha: 'commitsha' } } + fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ object: { sha: 'commitsha111' } }) } as Response); + // 3. GET /api/v1/.../git/commits/commitsha111 → commit { tree: { sha: 'treesha' } } + fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ tree: { sha: 'treesha111' } }) } as Response); + // 4. POST /api/v1/.../git/trees → new tree { sha: 'newtreesha' } + fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'newtreesha111' }) } as Response); + // 5. POST /api/v1/.../git/commits → new commit { sha: 'newcommitsha' } + fetchMock.mockResolvedValueOnce({ ok: true, status: 201, json: async () => ({ sha: 'newcommitsha111' }) } as Response); + // 6. PATCH /api/v1/.../git/refs/heads/main → updated + fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) } as Response); + + const host = new GiteaHost('https://git.example.com', 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); + // Gitea uses /api/v1/ prefix + expect(fetchMock.mock.calls[0][0]).toContain('/api/v1/repos/'); + expect(fetchMock.mock.calls[0][0]).toContain('/git/blobs'); + expect(fetchMock.mock.calls[1][0]).toContain('/git/refs/heads/'); + expect(fetchMock.mock.calls[2][0]).toContain('/git/commits/'); + 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'); + }); +}); diff --git a/extension/src/service-worker/gitea.ts b/extension/src/service-worker/gitea.ts index 7344f2c..ddceb00 100644 --- a/extension/src/service-worker/gitea.ts +++ b/extension/src/service-worker/gitea.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'; /// Gitea Contents API implementation. /// @@ -15,12 +15,15 @@ import { uint8ArrayToBase64, base64ToUint8Array } from './git-host'; export class GiteaHost implements GitHost { private baseUrl: string; + private gitApiBase: string; + private branch: string = 'main'; private headers: Record; constructor(hostUrl: string, repoPath: string, apiToken: string) { // Remove trailing slash from hostUrl const host = hostUrl.replace(/\/+$/, ''); this.baseUrl = `${host}/api/v1/repos/${repoPath}/contents`; + this.gitApiBase = `${host}/api/v1/repos/${repoPath}/git`; this.headers = { 'Authorization': `token ${apiToken}`, 'Content-Type': 'application/json', @@ -112,8 +115,87 @@ export class GiteaHost implements GitHost { return json.map((item: { name: string }) => item.name); } - async putBlob(_path: string, _content: Uint8Array, _message: string): Promise { - throw new Error(`GiteaHost.putBlob not implemented — Task 4`); + async putBlob(path: string, content: Uint8Array, message: string): Promise { + if (content.length <= BLOB_THRESHOLD_BYTES) { + await this.writeFile(path, content, message); + return path; + } + + // Git Data API fallback (Gitea v1; same shape as GitHub). + // 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(`Gitea 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) { + const text = await refResp.text(); + throw new Error(`Gitea putBlob get-ref ${this.branch}: ${refResp.status} ${text}`); + } + 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) { + const text = await commitResp.text(); + throw new Error(`Gitea putBlob get-commit ${commitSha}: ${commitResp.status} ${text}`); + } + 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(`Gitea 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(`Gitea 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(`Gitea putBlob update-ref: ${updateRefResp.status} ${text}`); + } + + return path; } async getBlob(path: string): Promise {