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