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:
54
extension/src/service-worker/git-host.ts
Normal file
54
extension/src/service-worker/git-host.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/// Abstract interface for reading/writing vault files on a git host.
|
||||
///
|
||||
/// Both Gitea and GitHub expose a "repo contents" REST API that lets us
|
||||
/// read, write, and delete individual files without cloning the repo.
|
||||
/// This interface captures just the operations the vault needs.
|
||||
|
||||
export interface GitHost {
|
||||
/// Read a single file from the repo, returning its raw bytes.
|
||||
readFile(path: string): Promise<Uint8Array>;
|
||||
|
||||
/// Create or update a file in the repo with a commit message.
|
||||
writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
|
||||
|
||||
/// Delete a file from the repo with a commit message.
|
||||
deleteFile(path: string, message: string): Promise<void>;
|
||||
|
||||
/// List file names in a directory (non-recursive).
|
||||
listDir(path: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
/// Convert a Uint8Array to a base64 string (works in service worker context).
|
||||
export function uint8ArrayToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/// Convert a base64 string to a Uint8Array.
|
||||
export function base64ToUint8Array(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// Factory function that returns the appropriate GitHost implementation.
|
||||
import { GiteaHost } from './gitea';
|
||||
import { GitHubHost } from './github';
|
||||
|
||||
export function createGitHost(
|
||||
hostType: 'gitea' | 'github',
|
||||
hostUrl: string,
|
||||
repoPath: string,
|
||||
apiToken: string,
|
||||
): GitHost {
|
||||
if (hostType === 'gitea') {
|
||||
return new GiteaHost(hostUrl, repoPath, apiToken);
|
||||
}
|
||||
return new GitHubHost(repoPath, apiToken);
|
||||
}
|
||||
114
extension/src/service-worker/gitea.ts
Normal file
114
extension/src/service-worker/gitea.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
108
extension/src/service-worker/github.ts
Normal file
108
extension/src/service-worker/github.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user