feat(ext/sw): GitHost.lastCommit() for vault-presence metadata
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user