Init
This commit is contained in:
commit
0ad405c26e
78 changed files with 9127 additions and 0 deletions
133
src/lib/sync/github.ts
Normal file
133
src/lib/sync/github.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue