133 lines
4.4 KiB
TypeScript
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;
|
|
}
|