repos.ts

10.9 KB · raw

1import type { Context, Hono } from "hono";2import { syncAll } from "../bucket/sync.ts";3import { MAX_CHARS } from "../issues/spam.ts";4import { operatorIssues } from "../issues/store.ts";5import { languageLines, repoStats } from "../mirror/queries.ts";6import { openMirror, quarantinedPaths } from "../mirror/store.ts";7import { html } from "../pages/html.ts";8import { formatDate, plural } from "../pages/layout.ts";9import {10	type OperatorRelease,11	operatorReleases,12	setRelease,13	visibleReleases,14} from "../releases/releases.ts";15import { type RepoText, repoText, setRepoText } from "../server/db.ts";16import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";17import { type AdminRepo, allRepos, findRepo, type RepoEdit, updateRepo } from "./store.ts";18import { LIMITS } from "./text.ts";1920const MAX_FIELD = 500;21const HTTP_URL = /^https?:\/\/[^\s]+$/i;22const MAX_LANGUAGES = 5;23const MAX_LANGUAGE = 30;2425/** Shown languages as typed, comma-separated; blanks between commas are dropped. */26function languageNames(value: string): string[] {27	return value28		.split(",")29		.map((name) => name.trim())30		.filter((name) => name !== "");31}3233/** §24's Repos section: visibility (§20), card settings, texts, builds, sync status and "sync now". */34export function registerRepos(app: Hono, ctx: AdminContext): void {35	app.get("/admin/repos", (c) => c.html(reposPage(c, ctx, "")));3637	app.post("/admin/sync", async (c) => {38		await syncAll(ctx.sync);39		return c.redirect("/admin/repos", 303);40	});4142	app.post("/admin/repos/:repo", async (c) => {43		const repo = findRepo(ctx.kepr, c.req.param("repo"));44		if (!repo) return c.notFound();45		const fields = await textFields(c);46		const text = (name: string) => (fields[name] ?? "").trim();47		const languages = languageNames(fields.languages ?? "");48		const edit: RepoEdit = {49			public: fields.public === "1" ? 1 : 0,50			category: text("category"),51			homepage: text("homepage"),52			// Joined with the separator `publicRepo` splits on, so a name never contains it.53			languages: languages.join(", "),54			issues_paused: fields.issues_paused === "1" ? 1 : 0,55		};56		const refuse = (error: string) =>57			c.html(reposPage(c, ctx, `${repo.name}: ${error}`, repo.name), 400);58		const texts = (["description", "details"] as const).map((field): [RepoText, string] => [59			field,60			(fields[field] ?? "").replace(/\r\n/g, "\n").trim(),61		]);62		const long = texts.find(([field, value]) => value.length > LIMITS[field]);63		if (long) {64			const [field] = long;65			return refuse(`Keep the ${field} under ${LIMITS[field].toLocaleString("en-US")} characters.`);66		}67		if ([edit.category, edit.homepage, fields.languages ?? ""].some((v) => v.length > MAX_FIELD)) {68			return refuse(`keep each field under ${MAX_FIELD} characters.`);69		}70		if (edit.homepage !== "" && !HTTP_URL.test(edit.homepage)) {71			return refuse("the homepage must be an http or https address.");72		}73		if (languages.length > MAX_LANGUAGES) return refuse("up to five languages.");74		if (languages.some((name) => name.length > MAX_LANGUAGE)) {75			return refuse(`a language name is at most ${MAX_LANGUAGE} characters.`);76		}77		if (languages.some((name) => /\p{C}/u.test(name))) {78			return refuse("a language name is printable text.");79		}80		updateRepo(ctx.kepr, repo.name, edit);81		for (const [field, value] of texts) setRepoText(ctx.kepr, repo.name, field, value);82		return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303);83	});8485	// A build's displayed notes and hiding live in kepr.db; the record in the bucket is untouched.86	app.post("/admin/releases/:repo/:version", async (c) => {87		const repo = findRepo(ctx.kepr, c.req.param("repo"));88		const release =89			repo &&90			operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name).find(91				(r) => r.version === c.req.param("version"),92			);93		if (!repo || !release) return c.notFound();94		const fields = await textFields(c);95		const notes = (fields.notes ?? "").replace(/\r\n/g, "\n");96		if (notes.length > MAX_CHARS) {97			const limit = MAX_CHARS.toLocaleString("en-US");98			const error = `${repo.name} ${release.version}: notes are over ${limit} characters.`;99			return c.html(reposPage(c, ctx, error, repo.name), 400);100		}101		// Notes equal to the record's are no override, so a later record edit would show through.102		const override = notes === release.recordNotes ? null : notes;103		setRelease(ctx.kepr, repo.name, release.version, fields.hidden === "1", override);104		return c.redirect(105			`/admin/repos?repo=${repo.name}&saved=1#${repo.name}-${release.version}`,106			303,107		);108	});109}110111function reposPage(c: Context, ctx: AdminContext, error: string, chosen = c.req.query("repo")) {112	const repos = allRepos(ctx.kepr);113	const selected = repos.find((repo) => repo.name === chosen) ?? repos[0];114	const body = html`${notice(error)}115<p class="admin-intro">Choose a repository to manage its visibility and public page.</p>116<form method="post" action="/admin/sync">117${formField(c)}<button class="issues-submit" type="submit">sync now</button></form>118${repos.length === 0 && html`<p class="muted mono save-empty">no repos found in the bucket yet</p>`}119<div class="admin-repos">120<nav aria-label="repositories">121${repos.map(122	(repo) => html`<a class="issue-row" id="repo-${repo.name}"123href="/admin/repos?repo=${repo.name}#repo-editor"${selected?.name === repo.name ? html` aria-current="page"` : ""}>124<span>${repo.name}</span><span>${repo.public ? "public" : "private"}</span>125<span class="issue-meta mono">${repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`}</span>126${repo.last_sync_error && html`<span class="issues-error">${repo.last_sync_error}</span>`}</a>`,127)}128</nav>129${selected && repoForm(c, ctx, selected)}130</div>`;131	return adminPage(c, ctx, "repos", body);132}133134/** The selected repo at a glance: its numbers and languages (§24). */135function workspace(ctx: AdminContext, repo: AdminRepo) {136	const mirror = openMirror(ctx.mirrorDir, repo.name);137	const stats = repoStats(mirror);138	const releases = operatorReleases(ctx.kepr, mirror, repo.name);139	const downloads = (release: (typeof releases)[number]) =>140		release.downloads.reduce((sum, [, count]) => sum + count, 0);141	const open = operatorIssues(ctx.kepr, "open").filter((issue) => issue.repo === repo.name);142	const vital = (label: string, value: string | number) =>143		html`<div><strong>${String(value)}</strong><span class="mono">${label}</span></div>`;144	return html`<div class="admin-vitals">145${vital("saves", stats.saves)}146${vital("lines", stats.lines)}147${vital("newest version", visibleReleases(ctx.kepr, mirror, repo.name)[0]?.version ?? "none")}148${vital("open issues", open.length)}149${vital(150	"downloads",151	releases.reduce((sum, release) => sum + downloads(release), 0),152)}153</div>154<p class="issue-meta mono">detected languages:155${languageLines(mirror).map(156	([language, lines], i) =>157		html`${i > 0 ? " · " : " "}${language} <span class="muted">${plural(lines, "line")}</span>`,158)}</p>159`;160}161162function repoForm(c: Context, ctx: AdminContext, repo: AdminRepo) {163	const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name));164	const own = repoText(ctx.kepr, repo.name);165	const releases = operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name);166	const input = (name: keyof RepoEdit, label: string) =>167		html`<label class="issues-label" for="${repo.name}-${name}">${label}</label>168<input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;169	const synced =170		repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`;171	return html`<article class="issue-post" id="repo-editor">172<p class="issue-post-head mono">173<span class="issue-author">174${repo.public ? html`<a href="/${repo.name}">${repo.name}</a>` : repo.name}</span>175<span>${repo.public ? "public" : "private"}</span><span class="history-when">${synced}</span></p>176${workspace(ctx, repo)}177${178	repo.last_sync_error && html`<p class="issues-error mono">sync error: ${repo.last_sync_error}</p>`179}180${181	quarantined.length > 0 &&182	html`<p class="issues-error mono">${plural(quarantined.length, "path")} quarantined: ${quarantined.join(", ")}</p>`183}184<form class="issues-form" method="post" action="/admin/repos/${repo.name}">185${formField(c)}186<label class="issues-label">187<input type="checkbox" name="public" value="1"${repo.public ? html` checked` : ""}> public</label>188<label class="issues-label">189<input type="checkbox" name="issues_paused" value="1"${repo.issues_paused ? html` checked` : ""}>190pause new issues</label>191${input("category", "category")}192${input("homepage", "homepage")}193${input("languages", "shown languages")}194<p class="issue-meta mono">Up to five, separated by commas. Leave blank to show the detected one.</p>195${textArea(repo.name, "description", "description", 3, own.description)}196${textArea(repo.name, "details", "details", 12, own.details)}197<p class="issue-meta mono">Markdown. Leave either blank to use the README's.</p>198<button class="issues-submit" type="submit">save ${repo.name}</button>199</form>200${201	releases.length > 0 &&202	html`<section class="repo-section"><h2 class="section-label mono">builds</h2>203${releases.map((release) => releaseForm(c, repo.name, release))}</section>`204}205</article>`;206}207208function textArea(repo: string, name: RepoText, label: string, rows: number, value: string) {209	return html`<label class="issues-label" for="${repo}-${name}">${label}</label>210<textarea class="issues-textarea" id="${repo}-${name}" name="${name}" rows="${String(rows)}"211maxlength="${String(LIMITS[name])}">${value}</textarea>`;212}213214function releaseForm(c: Context, repo: string, release: OperatorRelease) {215	const total = release.downloads.reduce((sum, [, n]) => sum + n, 0);216	const id = `${repo}-${release.version}`;217	return html`<article class="issue-post" id="${id}">218<p class="issue-post-head mono">219${/* this row stays one line: splitting its spans changes the HTML that tests/console.test.ts pins */ ""}${html`<span class="issue-author">${release.version}</span>`}${html`<span>${release.hidden ? "hidden" : "shown"}</span>`}${html`<span>${plural(total, "download")}</span>`}${release.downloads.map(220	([platform, n]) => html`<span class="muted">${platform} ${n}</span>`,221)}222</p>223<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}">224${formField(c)}225<label class="issues-label" for="${id}-notes">notes226${release.notes !== release.recordNotes ? html` <span class="muted">(edited)</span>` : ""}</label>227<textarea class="issues-textarea" id="${id}-notes" name="notes" rows="3"228maxlength="${MAX_CHARS}">${release.notes}</textarea>229${230	// One line on purpose: Biome folds a hole-by-hole split back together, and breaking the231	// tag itself would put whitespace in the HTML that tests/console.test.ts pins.232	""233}<label class="issues-label">${html`<input type="checkbox"`}${html` name="hidden" value="1"`}${release.hidden ? html` checked` : ""}> hidden</label>234<button class="issues-submit" type="submit">save ${release.version}</button>235</form>236</article>`;237}