mirror of
https://github.com/HeyPuter/builder.git
synced 2026-11-13 18:33:58 -10:00
209 lines
11 KiB
JavaScript
209 lines
11 KiB
JavaScript
// An in-memory GitHub for the sync tests: real git blob hashes (the engine
|
|
// checks them), trees as flat path maps, commits with parents, branches,
|
|
// compare, the REST endpoints github-sync.js calls, and GraphQL updateRefs
|
|
// with a real compare-and-swap. Every request is logged, and a test can push
|
|
// to a branch directly the way a developer would.
|
|
|
|
import crypto from 'node:crypto';
|
|
|
|
const enc = (s) => new TextEncoder().encode(s);
|
|
|
|
export function makeGitHub(opts = {}) {
|
|
const token = opts.token || 'github_pat_' + 'A'.repeat(30);
|
|
const login = opts.login || 'alice-gh';
|
|
const blobs = new Map();
|
|
const trees = new Map();
|
|
const commits = new Map();
|
|
const repos = new Map();
|
|
const log = [];
|
|
let seq = 1;
|
|
const hooks = { beforeCommit: null, corruptInline: false };
|
|
|
|
const sha1 = (buf) => crypto.createHash('sha1').update(buf).digest('hex');
|
|
function putBlob(bytes) {
|
|
const b = Buffer.from(bytes);
|
|
const sha = sha1(Buffer.concat([Buffer.from(`blob ${b.length}\0`), b]));
|
|
blobs.set(sha, new Uint8Array(b));
|
|
return sha;
|
|
}
|
|
function putTree(flat) {
|
|
const key = JSON.stringify([...flat.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)));
|
|
const sha = sha1(Buffer.from('tree:' + key));
|
|
trees.set(sha, new Map(flat));
|
|
return sha;
|
|
}
|
|
function putCommit(tree, parents, message) {
|
|
const sha = sha1(Buffer.from(`commit:${tree}:${parents.join(',')}:${message}:${seq++}`));
|
|
commits.set(sha, { tree, parents, message });
|
|
return sha;
|
|
}
|
|
function ancestors(sha) {
|
|
const out = new Set();
|
|
const stack = [sha];
|
|
while (stack.length) {
|
|
const s = stack.pop();
|
|
if (!s || out.has(s) || !commits.has(s)) continue;
|
|
out.add(s);
|
|
stack.push(...commits.get(s).parents);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function createRepo(owner, name, o = {}) {
|
|
const id = 1000 + repos.size;
|
|
const repo = { id, node_id: 'R_' + id, owner, name, private: o.private !== false, default_branch: 'main', refs: new Map(), archived: false };
|
|
repos.set(owner + '/' + name, repo);
|
|
if (o.autoInit) {
|
|
const tree = putTree(new Map([['README.md', { mode: '100644', sha: putBlob(enc(`# ${name}\n`)) }]]));
|
|
repo.refs.set('main', putCommit(tree, [], 'Initial commit'));
|
|
}
|
|
return repo;
|
|
}
|
|
|
|
// A developer's push: changes { path: text | Uint8Array | null } on top
|
|
// of the branch head. Returns the new commit.
|
|
function push(owner, name, branch, changes, message) {
|
|
const repo = repos.get(owner + '/' + name);
|
|
const head = repo.refs.get(branch);
|
|
const flat = new Map(head ? trees.get(commits.get(head).tree) : []);
|
|
for (const [path, value] of Object.entries(changes)) {
|
|
if (value === null) flat.delete(path);
|
|
else flat.set(path, { mode: '100644', sha: putBlob(typeof value === 'string' ? enc(value) : value) });
|
|
}
|
|
const commit = putCommit(putTree(flat), head ? [head] : [], message || 'Developer change');
|
|
repo.refs.set(branch, commit);
|
|
return commit;
|
|
}
|
|
function forceBranch(owner, name, branch, commit) { repos.get(owner + '/' + name).refs.set(branch, commit); }
|
|
function headFiles(owner, name, branch) {
|
|
const repo = repos.get(owner + '/' + name);
|
|
const head = repo.refs.get(branch);
|
|
if (!head) return null;
|
|
const out = {};
|
|
for (const [path, e] of trees.get(commits.get(head).tree)) out[path] = new TextDecoder().decode(blobs.get(e.sha));
|
|
return out;
|
|
}
|
|
function headCommit(owner, name, branch) {
|
|
const c = repos.get(owner + '/' + name).refs.get(branch);
|
|
return c ? Object.assign({ sha: c }, commits.get(c)) : null;
|
|
}
|
|
|
|
function listing(treeSha) {
|
|
const flat = trees.get(treeSha);
|
|
const folders = new Set();
|
|
const out = [];
|
|
for (const [path, e] of flat) {
|
|
const parts = path.split('/');
|
|
for (let i = 1; i < parts.length; i++) folders.add(parts.slice(0, i).join('/'));
|
|
out.push({ path, mode: e.mode, type: e.mode === '160000' ? 'commit' : 'blob', sha: e.sha, size: e.mode === '160000' ? undefined : blobs.get(e.sha)?.length });
|
|
}
|
|
for (const f of folders) out.push({ path: f, mode: '040000', type: 'tree', sha: sha1(Buffer.from('folder:' + f)) });
|
|
return out.sort((a, b) => (a.path < b.path ? -1 : 1));
|
|
}
|
|
|
|
function respond(status, data, headers = {}) {
|
|
const h = new Map(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), String(v)]));
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
statusText: String(status),
|
|
headers: { get: (n) => (h.has(n.toLowerCase()) ? h.get(n.toLowerCase()) : null), forEach: (fn) => h.forEach((v, k) => fn(v, k)) },
|
|
json: async () => (data instanceof Uint8Array ? JSON.parse(new TextDecoder().decode(data)) : data),
|
|
arrayBuffer: async () => (data instanceof Uint8Array ? data.slice().buffer : enc(JSON.stringify(data)).buffer),
|
|
};
|
|
}
|
|
const err = (status, message) => respond(status, { message });
|
|
const repoData = (r) => ({ id: r.id, node_id: r.node_id, name: r.name, owner: { login: r.owner }, private: r.private, default_branch: r.default_branch, html_url: `https://github.com/${r.owner}/${r.name}`, archived: r.archived });
|
|
|
|
async function fetch(url, init = {}) {
|
|
const u = new URL(url);
|
|
const method = (init.method || 'GET').toUpperCase();
|
|
const path = decodeURIComponent(u.pathname);
|
|
const headers = init.headers || {};
|
|
const body = init.body ? JSON.parse(init.body) : null;
|
|
log.push({ method, path });
|
|
if (opts.fail && opts.fail(method, path)) throw new TypeError('Failed to fetch');
|
|
if (headers.Authorization !== 'Bearer ' + token) return err(401, 'Bad credentials');
|
|
let m;
|
|
if (method === 'GET' && path === '/user') return respond(200, { login, id: 7 });
|
|
if (method === 'GET' && path === '/user/repos') return respond(200, [...repos.values()].map(repoData));
|
|
if (method === 'POST' && path === '/user/repos') return respond(201, repoData(createRepo(login, body.name, { private: body.private, autoInit: body.auto_init })));
|
|
if (method === 'POST' && path === '/graphql') {
|
|
const up = body.variables.updates[0];
|
|
const repo = [...repos.values()].find((r) => r.node_id === body.variables.repo);
|
|
const branch = up.name.replace('refs/heads/', '');
|
|
const cur = repo.refs.get(branch);
|
|
if (cur !== up.beforeOid) return respond(200, { data: null, errors: [{ type: 'STALE_DATA', message: `Expected ${up.name} to point to ${up.beforeOid} but it was ${cur}` }] });
|
|
if (!commits.has(up.afterOid)) return respond(200, { data: null, errors: [{ type: 'NOT_FOUND', message: 'Could not resolve to a commit' }] });
|
|
repo.refs.set(branch, up.afterOid);
|
|
return respond(200, { data: { updateRefs: { clientMutationId: null } } });
|
|
}
|
|
if (!(m = /^\/repos\/([^/]+)\/([^/]+)(\/.*)?$/.exec(path))) return err(404, 'Not Found');
|
|
const repo = repos.get(m[1] + '/' + m[2]);
|
|
if (!repo) return err(404, 'Not Found');
|
|
const rest = m[3] || '';
|
|
if (method === 'GET' && rest === '') return respond(200, repoData(repo));
|
|
if (method === 'GET' && rest === '/branches') return respond(200, [...repo.refs.keys()].map((name) => ({ name })));
|
|
if (method === 'GET' && (m = /^\/branches\/(.+)$/.exec(rest))) {
|
|
const c = repo.refs.get(m[1]);
|
|
if (!c) return err(404, 'Branch not found');
|
|
return respond(200, { name: m[1], commit: { sha: c, commit: { tree: { sha: commits.get(c).tree } } }, protected: false }, { ETag: '"' + c + '"' });
|
|
}
|
|
if (method === 'GET' && (m = /^\/git\/trees\/([0-9a-f]+)$/.exec(rest))) {
|
|
if (!trees.has(m[1])) return err(404, 'Not Found');
|
|
return respond(200, { sha: m[1], tree: listing(m[1]), truncated: false });
|
|
}
|
|
if (method === 'GET' && (m = /^\/git\/blobs\/([0-9a-f]+)$/.exec(rest))) {
|
|
if (!blobs.has(m[1])) return err(404, 'Not Found');
|
|
return respond(200, blobs.get(m[1]));
|
|
}
|
|
if (method === 'POST' && rest === '/git/blobs') return respond(201, { sha: putBlob(Buffer.from(body.content, 'base64')) });
|
|
if (method === 'POST' && rest === '/git/trees') {
|
|
const flat = new Map(body.base_tree ? trees.get(body.base_tree) : []);
|
|
for (const e of body.tree) {
|
|
if (e.sha === null) {
|
|
if (!flat.has(e.path)) return err(422, 'tree.path not found');
|
|
flat.delete(e.path);
|
|
} else if (typeof e.content === 'string') {
|
|
// GitHub writes the blob for inline content itself.
|
|
// hooks.corruptInline: it stores something else, once.
|
|
let content = e.content;
|
|
if (hooks.corruptInline) { hooks.corruptInline = false; content += '\n'; }
|
|
flat.set(e.path, { mode: e.mode, sha: putBlob(Buffer.from(content, 'utf8')) });
|
|
} else {
|
|
if (!blobs.has(e.sha)) return err(422, 'tree.sha ' + e.sha + ' is not a valid blob');
|
|
flat.set(e.path, { mode: e.mode, sha: e.sha });
|
|
}
|
|
}
|
|
return respond(201, { sha: putTree(flat) });
|
|
}
|
|
if (method === 'POST' && rest === '/git/commits') {
|
|
if (hooks.beforeCommit) { const h = hooks.beforeCommit; hooks.beforeCommit = null; h(); }
|
|
return respond(201, { sha: putCommit(body.tree, body.parents, body.message) });
|
|
}
|
|
if (method === 'GET' && (m = /^\/compare\/([0-9a-f]+)\.\.\.([0-9a-f]+)$/.exec(rest))) {
|
|
const [base, head] = [m[1], m[2]];
|
|
if (!commits.has(base) || !commits.has(head)) return err(404, 'Not Found');
|
|
const headAnc = ancestors(head);
|
|
const baseAnc = ancestors(base);
|
|
let status = 'diverged';
|
|
if (base === head) status = 'identical';
|
|
else if (headAnc.has(base)) status = 'ahead';
|
|
else if (baseAnc.has(head)) status = 'behind';
|
|
const list = [...headAnc].filter((c) => !baseAnc.has(c)).map((sha) => ({ sha, commit: { message: commits.get(sha).message } }));
|
|
return respond(200, { status, commits: list });
|
|
}
|
|
if (method === 'PUT' && (m = /^\/contents\/(.+)$/.exec(rest))) {
|
|
const branch = body.branch || repo.default_branch;
|
|
const head = repo.refs.get(branch);
|
|
const flat = new Map(head ? trees.get(commits.get(head).tree) : []);
|
|
flat.set(m[1], { mode: '100644', sha: putBlob(Buffer.from(body.content, 'base64')) });
|
|
repo.refs.set(branch, putCommit(putTree(flat), head ? [head] : [], body.message));
|
|
return respond(201, {});
|
|
}
|
|
return err(404, 'Not Found');
|
|
}
|
|
|
|
return { fetch, token, login, log, hooks, createRepo, push, forceBranch, headFiles, headCommit, repos, commits };
|
|
}
|