Papure/src/lib/sync/github.ts
2026-09-26 19:13:02 +02:00

133 lines
4.4 KiB
TypeScript

// Minimal GitHub REST client (Git Data API) used for sync.
import type { GitHubSettings } from '$lib/state/settings.svelte';
export class GitHubError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message);
}
}
export interface TreeEntry {
path: string;
type: 'blob' | 'tree' | 'commit';
sha: string;
mode: string;
}
/** Git's blob sha for an empty file (used for folder `.gitkeep`s). */
export const EMPTY_BLOB_SHA = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391';
export class GitHub {
constructor(private s: GitHubSettings) {}
private async req<T>(method: string, path: string, body?: unknown): Promise<T> {
const url = path.startsWith('https://') ? path : `https://api.github.com/repos/${this.s.owner}/${this.s.repo}${path}`;
const res = await fetch(url, {
method,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${this.s.token}`,
'X-GitHub-Api-Version': '2022-11-28',
...(body ? { 'Content-Type': 'application/json' } : {})
},
body: body ? JSON.stringify(body) : undefined,
cache: 'no-store'
});
if (!res.ok) {
let msg = res.statusText;
try {
msg = (await res.json()).message ?? msg;
} catch {
/* not json */
}
throw new GitHubError(`GitHub ${method} ${path}: ${res.status} ${msg}`, res.status);
}
return res.status === 204 ? (undefined as T) : res.json();
}
async checkAccess() {
const repo = await this.req<{ permissions?: { push?: boolean }; default_branch: string }>('GET', '');
if (repo.permissions && !repo.permissions.push) throw new Error('The token has no push access to this repository.');
return repo;
}
/** Head commit sha of the branch, or null if the branch (or repo) is empty. */
async head(branch = this.s.branch): Promise<string | null> {
try {
const ref = await this.req<{ object: { sha: string } }>('GET', `/git/ref/heads/${enc(branch)}`);
return ref.object.sha;
} catch (e) {
if (e instanceof GitHubError && (e.status === 404 || e.status === 409)) return null;
throw e;
}
}
async commit(sha: string) {
return this.req<{ sha: string; tree: { sha: string } }>('GET', `/git/commits/${sha}`);
}
async tree(sha: string): Promise<TreeEntry[]> {
const t = await this.req<{ tree: TreeEntry[]; truncated: boolean }>('GET', `/git/trees/${sha}?recursive=1`);
if (t.truncated) console.warn('GitHub tree listing truncated; some files may be missing from sync');
return t.tree;
}
async blob(sha: string): Promise<Uint8Array> {
const b = await this.req<{ content: string; encoding: string }>('GET', `/git/blobs/${sha}`);
return fromBase64(b.content.replace(/\n/g, ''));
}
async createBlob(bytes: Uint8Array): Promise<string> {
const r = await this.req<{ sha: string }>('POST', '/git/blobs', { content: toBase64(bytes), encoding: 'base64' });
return r.sha;
}
async createTree(base: string | null, entries: { path: string; sha: string | null }[]): Promise<string> {
const r = await this.req<{ sha: string }>('POST', '/git/trees', {
...(base ? { base_tree: base } : {}),
tree: entries.map((e) => ({ path: e.path, mode: '100644', type: 'blob', sha: e.sha }))
});
return r.sha;
}
async createCommit(message: string, tree: string, parent: string | null): Promise<string> {
const r = await this.req<{ sha: string }>('POST', '/git/commits', { message, tree, parents: parent ? [parent] : [] });
return r.sha;
}
/** Move the branch to `sha`. Throws a 422 GitHubError if not a fast-forward. */
async updateRef(sha: string, create: boolean) {
if (create) await this.req('POST', '/git/refs', { ref: `refs/heads/${this.s.branch}`, sha });
else await this.req('PATCH', `/git/refs/heads/${enc(this.s.branch)}`, { sha, force: false });
}
/** The Git Data API refuses to work on a repo with no commits at all. */
async initEmptyRepo() {
await this.req('PUT', `/contents/.papure`, {
message: 'Initialise Papure vault',
content: toBase64(new TextEncoder().encode('Papure vault\n'))
});
}
}
const enc = (s: string) => s.split('/').map(encodeURIComponent).join('/');
export function toBase64(bytes: Uint8Array): string {
let bin = '';
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(bin);
}
export function fromBase64(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}