feat(ext/sw): GitHost.lastCommit() for vault-presence metadata

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-27 17:48:24 -04:00
parent 7588a75bdc
commit 2c94dfaf90
4 changed files with 103 additions and 2 deletions

View File

@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GiteaHost } from '../gitea';
import { GitHubHost } from '../github';
describe('lastCommit (Gitea)', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
it('returns commit metadata when API succeeds', async () => {
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify([
{ sha: 'abc1234567', commit: { author: { name: 'Alice', date: '2026-04-20T12:00:00Z' } } },
]), { status: 200 }));
const host = new GiteaHost('https://git.example.com', 'user/vault', 'tok');
const result = await host.lastCommit('manifest.enc');
expect(result).toEqual({ sha: 'abc1234', author: 'Alice', date: '2026-04-20T12:00:00Z' });
});
it('returns null on 404', async () => {
fetchSpy.mockResolvedValueOnce(new Response('', { status: 404 }));
const host = new GiteaHost('https://git.example.com', 'user/vault', 'tok');
expect(await host.lastCommit('manifest.enc')).toBeNull();
});
it('returns null on network error', async () => {
fetchSpy.mockRejectedValueOnce(new Error('network'));
const host = new GiteaHost('https://git.example.com', 'user/vault', 'tok');
expect(await host.lastCommit('manifest.enc')).toBeNull();
});
});
describe('lastCommit (GitHub)', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
it('returns commit metadata when API succeeds', async () => {
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify([
{ sha: 'def4567890', commit: { author: { name: 'Bob', date: '2026-04-22T15:00:00Z' } } },
]), { status: 200 }));
const host = new GitHubHost('user/vault', 'tok');
const result = await host.lastCommit('manifest.enc');
expect(result).toEqual({ sha: 'def4567', author: 'Bob', date: '2026-04-22T15:00:00Z' });
});
it('returns null when commits list is empty', async () => {
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 }));
const host = new GitHubHost('user/vault', 'tok');
expect(await host.lastCommit('manifest.enc')).toBeNull();
});
});

View File

@@ -17,6 +17,11 @@ export interface GitHost {
/// List file names in a directory (non-recursive).
listDir(path: string): Promise<string[]>;
/// Best-effort: returns metadata for the most recent commit touching `path`.
/// Returns null if the path has no commits, the API fails, or the host
/// doesn't support the lookup. Callers must tolerate null.
lastCommit(path: string): Promise<{ sha: string; author: string; date: string } | null>;
/// Write an opaque binary blob to the repo. Optimized for large
/// attachments — implementations switch from Contents API to Git
/// Data API when content exceeds BLOB_THRESHOLD_BYTES.

View File

@@ -16,14 +16,17 @@ import { uint8ArrayToBase64, base64ToUint8Array, BLOB_THRESHOLD_BYTES } from './
export class GiteaHost implements GitHost {
private baseUrl: string;
private gitApiBase: string;
private commitsUrl: string;
private branch: string = 'main';
private headers: Record<string, string>;
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`;
const apiUrl = `${host}/api/v1`;
this.baseUrl = `${apiUrl}/repos/${repoPath}/contents`;
this.gitApiBase = `${apiUrl}/repos/${repoPath}/git`;
this.commitsUrl = `${apiUrl}/repos/${repoPath}/commits`;
this.headers = {
'Authorization': `token ${apiToken}`,
'Content-Type': 'application/json',
@@ -115,6 +118,24 @@ export class GiteaHost implements GitHost {
return json.map((item: { name: string }) => item.name);
}
async lastCommit(path: string): Promise<{ sha: string; author: string; date: string } | null> {
try {
const url = `${this.commitsUrl}?path=${encodeURIComponent(path)}&limit=1`;
const resp = await fetch(url, { headers: this.headers });
if (!resp.ok) return null;
const json = await resp.json();
if (!Array.isArray(json) || json.length === 0) return null;
const c = json[0];
return {
sha: String(c.sha).slice(0, 7),
author: c.commit?.author?.name ?? 'unknown',
date: c.commit?.author?.date ?? '',
};
} catch {
return null;
}
}
async putBlob(path: string, content: Uint8Array, message: string): Promise<string> {
if (content.length <= BLOB_THRESHOLD_BYTES) {
await this.writeFile(path, content, message);

View File

@@ -14,12 +14,14 @@ import { uint8ArrayToBase64, base64ToUint8Array, BLOB_THRESHOLD_BYTES } from './
export class GitHubHost implements GitHost {
private baseUrl: string;
private gitApiBase: string;
private commitsUrl: 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.commitsUrl = `https://api.github.com/repos/${repoPath}/commits`;
this.headers = {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
@@ -109,6 +111,24 @@ export class GitHubHost implements GitHost {
return json.map((item: { name: string }) => item.name);
}
async lastCommit(path: string): Promise<{ sha: string; author: string; date: string } | null> {
try {
const url = `${this.commitsUrl}?path=${encodeURIComponent(path)}&per_page=1`;
const resp = await fetch(url, { headers: this.headers });
if (!resp.ok) return null;
const json = await resp.json();
if (!Array.isArray(json) || json.length === 0) return null;
const c = json[0];
return {
sha: String(c.sha).slice(0, 7),
author: c.commit?.author?.name ?? 'unknown',
date: c.commit?.author?.date ?? '',
};
} catch {
return null;
}
}
async putBlob(path: string, content: Uint8Array, message: string): Promise<string> {
if (content.length <= BLOB_THRESHOLD_BYTES) {
// Contents API path — same as writeFile