Files
relicario/extension/src/service-worker/github.ts
adlee-was-taken 7cf7960aff 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>
2026-04-12 09:42:02 -04:00

109 lines
3.4 KiB
TypeScript

import type { GitHost } from './git-host';
import { uint8ArrayToBase64, base64ToUint8Array } from './git-host';
/// GitHub Contents API implementation.
///
/// Endpoints:
/// GET https://api.github.com/repos/{repoPath}/contents/{path}
/// PUT https://api.github.com/repos/{repoPath}/contents/{path} (create/update)
/// DELETE https://api.github.com/repos/{repoPath}/contents/{path}
///
/// Auth: `Bearer {apiToken}` header.
/// Content is base64-encoded. Updates and deletes require the current file SHA.
export class GitHubHost implements GitHost {
private baseUrl: string;
private headers: Record<string, string>;
constructor(repoPath: string, apiToken: string) {
this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`;
this.headers = {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
'Accept': 'application/vnd.github.v3+json',
'X-GitHub-Api-Version': '2022-11-28',
};
}
async readFile(path: string): Promise<Uint8Array> {
const resp = await fetch(`${this.baseUrl}/${path}`, {
headers: this.headers,
});
if (!resp.ok) {
throw new Error(`GitHub readFile ${path}: ${resp.status} ${resp.statusText}`);
}
const json = await resp.json();
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, unknown> = { content: b64, message };
if (sha) {
body.sha = sha;
}
const resp = await fetch(`${this.baseUrl}/${path}`, {
method: 'PUT',
headers: this.headers,
body: JSON.stringify(body),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`GitHub writeFile ${path}: ${resp.status} ${text}`);
}
}
async deleteFile(path: string, message: string): Promise<void> {
const existing = await fetch(`${this.baseUrl}/${path}`, {
headers: this.headers,
});
if (!existing.ok) {
throw new Error(`GitHub 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(`GitHub 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(`GitHub listDir ${path}: ${resp.status} ${resp.statusText}`);
}
const json = await resp.json();
if (!Array.isArray(json)) return [];
return json.map((item: { name: string }) => item.name);
}
}