feat: add git API layer with Gitea and GitHub implementations

GitHost interface for reading/writing vault files via REST API.
Gitea and GitHub implementations handle base64 content encoding,
SHA-based updates, and directory listing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-12 09:42:02 -04:00
parent 71f7bf9797
commit 7cf7960aff
3 changed files with 276 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
import type { GitHost } from './git-host';
import { uint8ArrayToBase64, base64ToUint8Array } from './git-host';
/// Gitea Contents API implementation.
///
/// Endpoints:
/// GET {hostUrl}/api/v1/repos/{repoPath}/contents/{path}
/// POST {hostUrl}/api/v1/repos/{repoPath}/contents/{path} (create)
/// PUT {hostUrl}/api/v1/repos/{repoPath}/contents/{path} (update)
/// DELETE {hostUrl}/api/v1/repos/{repoPath}/contents/{path}
///
/// Auth: `token {apiToken}` header.
/// Content is base64-encoded in both request and response bodies.
/// Updates and deletes require the current file SHA.
export class GiteaHost implements GitHost {
private baseUrl: string;
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.headers = {
'Authorization': `token ${apiToken}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
}
async readFile(path: string): Promise<Uint8Array> {
const resp = await fetch(`${this.baseUrl}/${path}`, {
headers: this.headers,
});
if (!resp.ok) {
throw new Error(`Gitea readFile ${path}: ${resp.status} ${resp.statusText}`);
}
const json = await resp.json();
// Gitea returns base64 content with possible newlines
const clean = (json.content as string).replace(/\n/g, '');
return base64ToUint8Array(clean);
}
async writeFile(path: string, content: Uint8Array, message: string): Promise<void> {
const b64 = uint8ArrayToBase64(content);
// Try to get the current SHA for an update; if 404 it's a create.
let sha: string | null = null;
try {
const existing = await fetch(`${this.baseUrl}/${path}`, {
headers: this.headers,
});
if (existing.ok) {
const json = await existing.json();
sha = json.sha as string;
}
} catch {
// File does not exist — will create.
}
const body: Record<string, string> = { content: b64, message };
if (sha) {
body.sha = sha;
}
const method = sha ? 'PUT' : 'POST';
const resp = await fetch(`${this.baseUrl}/${path}`, {
method,
headers: this.headers,
body: JSON.stringify(body),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`Gitea writeFile ${path}: ${resp.status} ${text}`);
}
}
async deleteFile(path: string, message: string): Promise<void> {
// Need the current SHA to delete.
const existing = await fetch(`${this.baseUrl}/${path}`, {
headers: this.headers,
});
if (!existing.ok) {
throw new Error(`Gitea deleteFile ${path}: file not found (${existing.status})`);
}
const json = await existing.json();
const sha = json.sha as string;
const resp = await fetch(`${this.baseUrl}/${path}`, {
method: 'DELETE',
headers: this.headers,
body: JSON.stringify({ message, sha }),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`Gitea deleteFile ${path}: ${resp.status} ${text}`);
}
}
async listDir(path: string): Promise<string[]> {
const resp = await fetch(`${this.baseUrl}/${path}`, {
headers: this.headers,
});
if (!resp.ok) {
if (resp.status === 404) return [];
throw new Error(`Gitea listDir ${path}: ${resp.status} ${resp.statusText}`);
}
const json = await resp.json();
if (!Array.isArray(json)) return [];
return json.map((item: { name: string }) => item.name);
}
}