feat(ext/sw): GiteaHost.putBlob with Git Data API fallback
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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { GitHubHost } from '../github';
|
import { GitHubHost } from '../github';
|
||||||
|
import { GiteaHost } from '../gitea';
|
||||||
import { BLOB_THRESHOLD_BYTES } from '../git-host';
|
import { BLOB_THRESHOLD_BYTES } from '../git-host';
|
||||||
|
|
||||||
const REPO = 'alee/test-vault';
|
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/);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { GitHost } from './git-host';
|
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.
|
/// Gitea Contents API implementation.
|
||||||
///
|
///
|
||||||
@@ -15,12 +15,15 @@ import { uint8ArrayToBase64, base64ToUint8Array } from './git-host';
|
|||||||
|
|
||||||
export class GiteaHost implements GitHost {
|
export class GiteaHost implements GitHost {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
|
private gitApiBase: string;
|
||||||
|
private branch: string = 'main';
|
||||||
private headers: Record<string, string>;
|
private headers: Record<string, string>;
|
||||||
|
|
||||||
constructor(hostUrl: string, repoPath: string, apiToken: string) {
|
constructor(hostUrl: string, repoPath: string, apiToken: string) {
|
||||||
// Remove trailing slash from hostUrl
|
// Remove trailing slash from hostUrl
|
||||||
const host = hostUrl.replace(/\/+$/, '');
|
const host = hostUrl.replace(/\/+$/, '');
|
||||||
this.baseUrl = `${host}/api/v1/repos/${repoPath}/contents`;
|
this.baseUrl = `${host}/api/v1/repos/${repoPath}/contents`;
|
||||||
|
this.gitApiBase = `${host}/api/v1/repos/${repoPath}/git`;
|
||||||
this.headers = {
|
this.headers = {
|
||||||
'Authorization': `token ${apiToken}`,
|
'Authorization': `token ${apiToken}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -112,8 +115,87 @@ export class GiteaHost implements GitHost {
|
|||||||
return json.map((item: { name: string }) => item.name);
|
return json.map((item: { name: string }) => item.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async putBlob(_path: string, _content: Uint8Array, _message: string): Promise<string> {
|
async putBlob(path: string, content: Uint8Array, message: string): Promise<string> {
|
||||||
throw new Error(`GiteaHost.putBlob not implemented — Task 4`);
|
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<Uint8Array> {
|
async getBlob(path: string): Promise<Uint8Array> {
|
||||||
|
|||||||
Reference in New Issue
Block a user