Skip to content

Instantly share code, notes, and snippets.

@yne
Last active December 17, 2024 00:59
Show Gist options
  • Select an option

  • Save yne/f683005540d3d618d04c36c282a44035 to your computer and use it in GitHub Desktop.

Select an option

Save yne/f683005540d3d618d04c36c282a44035 to your computer and use it in GitHub Desktop.
Github API based appraise
<!DOCTYPE html>
<meta charset="utf-8">
<meta name="color-scheme" content="dark light">
<title>Peer Review Framework</title>
<link rel="stylesheet" href="style.css">
<link rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'><path d='M4,5C2,7,-1.5,4,1,1C4,-1.5,7,2,5,4L8,7L7,8M1.5,1.5C0,3,2,6,4,4C6,2,3,0,1.5,1.5'></path></svg>">
<nav>
<a href="#/">Home</a>
<a href="#/orgs">My Orgs</a>
<a href="#/repo">My Repos</a>
<a href="#/repo/google/git-appraise">Google/Appraise</a>
</nav>
<main></main>
<progress id="quota" value="1"></progress>
<script>
// @ts-check
const forgeBase = "https://github.com";
const refReviews = "notes/devtools/reviews";
const refDiscuss = "notes/devtools/discuss";
const cachePrefix = 'cache:'
/**
* @template {keyof HTMLElementTagNameMap} T
* @param {T} tagName
* @param {Partial<HTMLElementTagNameMap[T]>} props
* @param {(Node|String)[]} ch
* @returns {HTMLElementTagNameMap[T]}
*/
function el(tagName, props = {}, ch = [], attr = {}) {
const e = Object.assign(document.createElement(tagName), props);
for (const c of ch) typeof c == "string" ? e.appendChild(new Text(c)) : e.appendChild(c);
for (const a in attr) e.setAttribute(a, attr[a])
return e;
}
function tryParse(jsonOrBlob) { try { return JSON.parse(jsonOrBlob) } catch (_) { return jsonOrBlob } }
const api = async (url, opt) => {
const headers = { ...opt?.headers, Authorization: `Bearer ${localStorage.gheToken}` }
const res = await fetch(url.startsWith('http') ? url : `https://api.github.com/${url}`, { ...opt, headers });
Object.assign(document.getElementById("quota") || {}, {
ariaDescription: res.headers.get(`github-authentication-token-expiration`),
max: +(res.headers.get(`x-ratelimit-limit`) || 0),
value: +(res.headers.get(`x-ratelimit-remaining`) || 0), // q("used")
title: `Refill in ${(+(res.headers.get(`x-ratelimit-reset`) || 0) - (+new Date() / 1000)) / 60 | 0}min`
});
if (res.status == 401) throw (res);
const t = res.headers.get('link')?.match(/<(.*?)>; rel="next"/)
if (t) return [...tryParse(await res.text()), ...await api(t[1])]
return tryParse(await res.text())
};
api.auth = (token = "") => token ? localStorage.gheToken = token : localStorage.gheToken;
api.flush = () => Object.keys(localStorage).filter(e => e.startsWith(cachePrefix)).forEach(k => delete localStorage[k])
api.cached = async (url, opt) => tryParse(localStorage[cachePrefix + url] ? localStorage[cachePrefix + url] : tryParse(localStorage[cachePrefix + url] = JSON.stringify(await api(url, opt))))
async function findBlobInTree(tree_url, sha) {
const next = (await api.cached(tree_url))?.tree?.find(t => sha.startsWith(t.path));
return (next?.type == "tree") ? findBlobInTree(next?.url, sha.slice(next.path.length)) : next;
}
const loc2url = (l, com = "", pre = (pre, str) => str ? pre + str : '') => (com || l.commit)?.slice(0, 7) + pre("/", l.path) + pre("#", pre("L", l.range?.startLine) + pre('C', l.range?.startColumn) + pre("-L", l.range?.endLine) + pre("C", l.range?.endColumn))
const profile = (name) => name?.includes('@') ? `mailto:${name}` : `${forgeBase}/${name}`;
const prevent = (e, /**@type {function(HTMLFormElement)}*/cb) => {
const form = /** @type {HTMLFormElement} */(e.target);
e.preventDefault();
form.ariaBusy = "";
cb(form).then(e => dispatchEvent(new Event('hashchange'))).catch(e => form.ariaBusy = null);
}
const sha1 = async (text) => [...new Uint8Array(await crypto.subtle.digest('SHA-1', new TextEncoder().encode(text)))].map(n => n.toString(16).padStart(2, "0")).join('')
const input = (/**@type {Partial<HTMLInputElement>}*/props) => el("label", {}, [`${props.name}`, el("input", props)]);
const routes = {
"/": async () => {
const u = await api("user");
return [el("nav", {}, [
el("h1", {}, [el("a", { href: u.html_url, innerText: `${u.login}` }), ` (${u.name})`]),
el("img", { src: u.avatar_url, width: 0xFF }),
el("button", { type: "button", innerText: "Flush Cache", onclick: api.flush }),
el("button", { type: "button", innerText: "Flush Token", onclick() { api.auth(" "); dispatchEvent(new Event('hashchange')) } })
])]
},
"/repo": async () => [el('nav', {}, (await api("user/repos")).map(r => el("a", { href: `#/repo/${r.full_name}`, innerText: r.full_name })))],
"/orgs": async () => [el('nav', {}, (await api.cached("user/orgs")).map(o => el("a", { href: `#/orgs/${o.login}`, innerText: o.login })))],
"/orgs/(?<orgName>[^/]+)": async ({ orgName }) => [el('nav', {}, (await api.cached(`orgs/${orgName}/repos`)).map(e => el("a", { href: `#/repo/${e.full_name}`, innerText: e.full_name })))],
"/repo/(?<owner>[^/]+)/(?<repo>[^/]+)/(?<sha>[0-9a-fA-F]+)": async ({ owner, repo, sha }) => {
const discuss_ref = await api(`repos/${owner}/${repo}/git/refs/${refDiscuss}`);
const discuss_com = discuss_ref?.object ? await api.cached(discuss_ref.object.url) : undefined;
const form = (prev = "") => el("article", { className: "create" }, [el("form", {
onsubmit: (e) => prevent(e, async (form) => { // TODO: parse "path" into appraise {location}
const { description, path } = Object.fromEntries([...new FormData(form)]);
const user = await api("user");
const content = (prev ? prev + "\n" : "") + JSON.stringify({ timestamp: `${+new Date() / 1000 | 0}`, author: user.login, description, location: { path }, v: 0 })
const tree = await api(`repos/${owner}/${repo}/git/trees`, { method: "POST", body: JSON.stringify({ base_tree: discuss_com?.tree.sha, tree: [{ type: "blob", path: `${sha.slice(0, 2)}/${sha.slice(2)}`, mode: "100644", content }] }) });
const comm = await api(`repos/${owner}/${repo}/git/commits`, { method: "POST", body: JSON.stringify({ tree: tree.sha, message: "comment added from webapp", parents: discuss_com ? [discuss_com.sha] : [] }) });
const refs = await api(`repos/${owner}/${repo}/git/refs${discuss_com ? '/' + refDiscuss : ''}`, { method: discuss_com ? "PATCH" : "POST", body: JSON.stringify({ ref: `refs/${refDiscuss}`, sha: comm.sha }) });
})
}, [
input({ name: "path", size: 50, placeholder: `${forgeBase}/${owner}/${repo}/blob/0000000/path/to/file#L4C5-L5C6` }),
input({ name: "description", placeholder: "description", required: true }),
el("button", {}, ["Comment"])
])])
if (!discuss_com) return [el("nav", {}, [`Empty ${refDiscuss}`, form()])]
const commitInfo = el('article', { className: 'reviewCommit' }, [
el("a", { href: `#/repo/${owner}/${repo}` }, [`${owner}/${repo}`]),
`Last comment:`, el("a", { href: discuss_com.html_url, innerText: discuss_com.sha, ariaColCount: "7" }),
`at`, el('time', { innerText: discuss_com.committer.date, ariaColCount: "10" }),
`by`, el("a", { href: `${discuss_com.tree.url}`, innerText: discuss_com.committer.name })
])
const blob = await api.cached((await findBlobInTree(discuss_com.tree.url, sha)).url);
const md2dom = await import("./md2dom.mjs").then(mod => new mod.default()).catch(e => ({ parse: (md) => [el("code", { innerText: md })] }));
const discusses = await Promise.all(atob(blob.content).split('\n').filter(String).map(async line => ({ d: tryParse(line), sha1: await sha1(line) })));
return [commitInfo, form(atob(blob.content)), ...discusses.sort(({ d }) => d.timestamp).map(({ d, sha1 }) => el("article", { className: "discuss", id: sha1 }, [
el("header", {}, [
...d.timestamp ? [el("time", { innerText: new Date(1000 * (+d.timestamp || 0)).toISOString(), ariaColCount: "10" })] : [],
...d.author ? [el("a", { innerText: d.author, href: profile(d.author) })] : [],
...d.parent ? [el("a", { innerText: d.parent, href: d.parent, ariaColCount: "7" })] : [],
...d.original ? [el("a", { innerText: d.original, href: d.original, ariaColCount: "7" })] : [],
...d.location ? ["on", el("a", { href: `${forgeBase}/${owner}/${repo}/blob/${loc2url(d.location, sha)}`, title: JSON.stringify(d.location), innerText: loc2url(d.location, sha) })] : [],
...d.resolved ? [el("span", { innerText: '✔️', title: 'Resolved' })] : [],
]),
el("div", {}, [...md2dom.parse(d.description || '')]),
el("footer", {}, [
el("a", { href: "", innerText: "Comment" }),
el("a", { href: "", innerText: "Resolve" })
]),
]))]
},
"/repo/(?<owner>[^/]+)/(?<repo>[^/]+)": async ({ owner, repo }) => {
const reviews_ref = await api(`repos/${owner}/${repo}/git/refs/${refReviews}`);
const reviews_com = reviews_ref?.object ? await api.cached(reviews_ref.object.url) : undefined;
const form = (prevs) => el("article", { className: "create" }, [el("form", {
onsubmit: (e) => prevent(e, async (form) => {
const { baseCommit, reviewRef, targetRef, reviewers, description } = Object.fromEntries([...new FormData(form)]);
const prevReview = prevs?.find(prev => prev.blob.path == baseCommit);
const user = await api("user");
const content = (prevReview ? atob(prevReview.blob.content) + "\n" : "") + JSON.stringify({ timestamp: `${+new Date() / 1000 | 0}`, requester: user.login, baseCommit, reviewRef: reviewRef, targetRef, reviewers: new String(reviewers).split(','), description, v: 0 })
const tree = await api(`repos/${owner}/${repo}/git/trees`, { method: "POST", body: JSON.stringify({ base_tree: reviews_com?.tree.sha, tree: [{ type: "blob", path: `${baseCommit.slice(0, 2)}/${baseCommit.slice(2)}`, mode: "100644", content }] }) });
const comm = await api(`repos/${owner}/${repo}/git/commits`, { method: "POST", body: JSON.stringify({ tree: tree.sha, message: "review-request added from webapp", parents: reviews_com ? [reviews_com.sha] : [] }) });
const refs = await api(`repos/${owner}/${repo}/git/refs${reviews_com ? '/' + refReviews : ''}`, { method: reviews_com ? "PATCH" : "POST", body: JSON.stringify({ ref: `refs/${refReviews}`, sha: comm.sha }) });
})
}, [
input({ name: "baseCommit", pattern: "[0-9a-zA-Z]{40}", placeholder: "base commit SHA1", required: true, value: "17c37fd8532230ffdb3579b4c40bc6df76e2c784" }),
input({ name: "reviewRef", placeholder: "review branch", required: true }),
input({ name: "targetRef", placeholder: "target branch", required: true, value: `develop` }),
input({ name: "reviewers", placeholder: "comma (,) separated reviewer email", required: true }),
input({ name: "description", placeholder: "description", required: true }),
el("button", {}, ["Request a Review"])
])])
if (!reviews_com) return [el("nav", {}, [`Empty ${refReviews}`, form()])]
const commitInfo = el('article', { className: 'reviewCommit' }, [
el("a", { href: `#/repo/${owner}/${repo}` }, [`${owner}/${repo}`]),
`Last review:`, el("a", { href: reviews_com.html_url, innerText: reviews_com.sha, ariaColCount: "7" }),
`at`, el('time', { innerText: reviews_com.committer.date, ariaColCount: "10" }),
`by`, el("a", { href: `${reviews_com.tree.url}`, innerText: reviews_com.committer.name })
])
const reviews_tree = (await api.cached(`${reviews_com.tree.url}?recursive=1`)).tree.filter(obj => obj.type == 'blob').map(blob => ({ ...blob, path: blob.path.replaceAll(/\//g, '') }));//listBlobs(await fetchTree(reviews_com.tree.url));
const blobs = (await Promise.all(reviews_tree.map(async b => ({ ...b, ...await api.cached(b.url) })))).map(blob => atob(blob.content).split('\n').filter(String).map(tryParse).map(review => ({ review, blob }))).flat();
const md2dom = await import("./md2dom.mjs").then(mod => new mod.default()).catch(e => ({ parse: (md) => [el("code", {}, [md||''])] }));
const reviewsSorted = blobs
.sort((a, b) => (+b.review.timestamp || 0) - (+a.review.timestamp || 0))
.map(({ blob, review }) => el('article', { className: 'review' }, [
el("header", {}, [
el("time", { innerText: new Date(1000 * (+review.timestamp || 0)).toISOString(), ariaColCount: "10" }),
`on`, el("a", { href: `${forgeBase}/${owner}/${repo}/commit/${blob.path}`, ariaColCount: "7" }, [blob.path]),
el("a", { href: profile(review.requester), innerText: review.requester }),
`ask `, ...(review.reviewers || []).map(reviewer => el("a", { href: profile(reviewer), innerText: reviewer })),
`to review`, el("a", { href: `${forgeBase}/${owner}/${repo}/compare/${review.baseCommit || review.targetRef}..${review.reviewRef}` }, [`${review.baseCommit?.slice(0, 7) || review.targetRef?.replace(/^refs\/heads\//, '')}..${review.reviewRef?.replace(/^refs\//, '').replace(/\/head$/, '')}`]),
"🢥", el("a", { href: `${forgeBase}/${owner}/${repo}/tree/${review.targetRef}`, innerText: review.targetRef?.replace(/^refs\/heads\//, '') }),
]),
el("div", {}, [...md2dom.parse(review.description)]),
el("footer", {}, [
el("a", { href: `#/repo/${owner}/${repo}/${blob.path}` }, ["Open Review"])
])
]))
return [commitInfo, form(blobs), ...reviewsSorted]//8f3d6b67820f9a0ba1fa6d91cb31317660aff355 fd4109dbd9e1c239d8dde559bad523c6afeed5fb
}
};
const [main] = document.getElementsByTagName('main');
window.onhashchange = async function () {
for (const rule in routes) {
const match = new RegExp(`^${rule}$`).exec(location.hash.slice(1) || '/');
if (!match) continue;
document.querySelectorAll("a").forEach(e => {
e.ariaCurrent = new RegExp(`^${rule}$`).exec(new URL(e.href).hash.slice(1)) ? "page" : null
});
try {
return main.replaceChildren(...await routes[rule](match.groups));
} catch (e) {
if (e instanceof Response && e.status == 401) {
return main.replaceChildren(el("nav", {}, [
el("h1", { innerText: "Github Token" }), "Generate a token",
el("a", { href: `https://github.com/settings/tokens/new?scopes=repo,user:email&description=prf-${(new Date()).toISOString().slice(0, 7)}`, target: "_blank", innerText: "here" }),
"and, past it here:",
el("input", { placeholder: "ghp_xxxx", size: 40, value: api.auth(), oninput() { api.auth((/**@type {HTMLInputElement}*/(this)).value) } }),
el("button", { type: "button", innerText: "save", onclick() { dispatchEvent(new Event('hashchange')) } })
]));
}
console.error(e)
return main.replaceChildren(el("pre", { innerText: JSON.stringify(e, null, '\t') }));
}
}
main.replaceChildren(el("h1", { innerText: "404" }));
};
dispatchEvent(new Event('hashchange'));
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment