issues.ts

10.0 KB · raw

1import type { Context, Hono } from "hono";2import { MAX_CHARS } from "../issues/spam.ts";3import {4	addEntry,5	blockedReporters,6	blockReporter,7	type Entry,8	ISSUE_TYPES,9	type IssueType,10	type OperatorFilter,11	type OperatorIssue,12	operatorEntries,13	operatorIssue,14	operatorIssues,15	reporterBlocked,16	setClosed,17	setEntryHidden,18	setHidden,19	setType,20	unblockReporter,21} from "../issues/store.ts";22import { saveByPrefix } from "../mirror/queries.ts";23import { openMirror } from "../mirror/store.ts";24import { html } from "../pages/html.ts";25import { formatDate, relativeTime } from "../pages/layout.ts";26import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";27import { allRepos } from "./store.ts";2829const FILTERS: [filter: OperatorFilter, label: string][] = [30	["open", "open"],31	["awaiting", "awaiting reply"],32	["closed", "closed"],33	["hidden", "hidden"],34];35const SAVE_PREFIX = /^[0-9a-f]{6,12}$/;36const ISSUE_PATH = "/admin/issues/:repo/:n{[0-9]{1,9}}";37const unixNow = () => Math.floor(Date.now() / 1000);3839/** §24's Issues section: every repo's issues, and the operator's actions of §23. */40export function registerIssues(app: Hono, ctx: AdminContext): void {41	app.get("/admin/issues", (c) => {42		const filter = FILTERS.find(([f]) => f === c.req.query("filter"))?.[0] ?? "open";43		// An unknown repo or type narrows nothing, as the public list treats an unknown type.44		const repos = allRepos(ctx.kepr).map((repo) => repo.name);45		const repo = repos.find((name) => name === c.req.query("repo")) ?? "";46		const type = ISSUE_TYPES.find((t) => t === c.req.query("type")) ?? "";47		const words = (c.req.query("q") ?? "").trim();48		const narrowed = (f: OperatorFilter) =>49			operatorIssues(ctx.kepr, f).filter(50				(issue) =>51					(!repo || issue.repo === repo) &&52					(!type || issue.type === type) &&53					`${issue.title}\n${issue.body}`.toLowerCase().includes(words.toLowerCase()),54			);55		const tab = ([f, label]: (typeof FILTERS)[number]) => {56			const query = new URLSearchParams({ filter: f, repo, type, q: words });57			return html`<a class="issues-tab${f === filter ? " active" : ""}"58				href="/admin/issues?${query.toString()}">59${label} <span class="muted">${narrowed(f).length}</span></a>`;60		};61		const option = (value: string, selected: string) =>62			html`<option value="${value}"${value === selected ? html` selected` : ""}>${value}</option>`;63		const issues = narrowed(filter);64		const blocked = blockedReporters(ctx.kepr);65		const body = html`<p class="issues-bar mono">${FILTERS.map(tab)}</p>66${67	blocked.length > 0 &&68	html`<details class="issue-meta mono"><summary>blocked reporters ${String(blocked.length)}</summary>69${blocked.map(70	(b) => html`<form class="issue-row" method="post" action="/admin/issues">71${formField(c)}<input type="hidden" name="action" value="unblock">72<input type="hidden" name="block" value="${String(b.id)}">73<span>from <a href="/admin/issues/${b.repo}/${String(b.number)}">${b.repo} #${String(b.number)}</a>74${relativeTime(b.blocked_at)}</span>75<button class="issues-cancel mono" type="submit">unblock</button></form>`,76)}</details>`77}78<form class="issues-filter" method="get" action="/admin/issues">79<input type="hidden" name="filter" value="${filter}">80<select class="issues-input" name="repo" aria-label="repository">81<option value="">all repos</option>82${repos.map((name) => option(name, repo))}83</select>84<select class="issues-input" name="type" aria-label="issue type">85<option value="">all types</option>86${ISSUE_TYPES.map((value) => option(value, type))}87</select>88<input class="issues-input" name="q" type="search" aria-label="search titles and text"89placeholder="search titles and text" value="${words}">90<button class="issues-submit" type="submit">filter</button>91</form>92<section class="repo-section">93${94	issues.length > 095		? issues.map(96				(issue) =>97					html`<a class="issue-row" href="/admin/issues/${issue.repo}/${issue.number}">98<span class="issue-title">${issue.title}${99						issue.awaiting && !issue.hidden && issue.status === "open"100							? html` <span class="issue-badge">awaiting reply</span>`101							: ""102					}</span>103<span class="issue-meta mono">${issue.repo} #${issue.number} · ${issue.type} ·104${issue.status} · ${relativeTime(issue.filed_at)}</span></a>`,105			)106		: html`<p class="muted mono save-empty">no issues here</p>`107}108</section>`;109		return c.html(adminPage(c, ctx, "issues", body));110	});111112	app.post("/admin/issues", async (c) => {113		const fields = await textFields(c);114		if (fields.action !== "unblock" || !unblockReporter(ctx.kepr, Number(fields.block))) {115			return c.notFound();116		}117		return c.redirect("/admin/issues", 303);118	});119120	app.get(ISSUE_PATH, (c) => {121		const issue = operatorIssue(ctx.kepr, c.req.param("repo"), Number(c.req.param("n")));122		return issue ? c.html(issuePage(c, ctx, issue, "")) : c.notFound();123	});124125	app.post(ISSUE_PATH, async (c) => {126		const issue = operatorIssue(ctx.kepr, c.req.param("repo"), Number(c.req.param("n")));127		if (!issue) return c.notFound();128		const fields = await textFields(c);129		const refuse = (error: string) => c.html(issuePage(c, ctx, issue, error), 400);130		switch (fields.action) {131			case "reply": {132				const body = (fields.body ?? "").trim();133				if (body === "" || body.length > MAX_CHARS) {134					return refuse(`A reply is 1 to ${MAX_CHARS.toLocaleString("en-US")} characters.`);135				}136				addEntry(ctx.kepr, issue.id, "operator", body, unixNow());137				break;138			}139			case "close": {140				const prefix = (fields.fixed_in ?? "").trim();141				if (prefix === "") {142					setClosed(ctx.kepr, issue.id, unixNow());143					break;144				}145				const save = SAVE_PREFIX.test(prefix)146					? saveByPrefix(openMirror(ctx.mirrorDir, issue.repo), prefix)147					: null;148				if (!save) return refuse(`No save ${prefix} in ${issue.repo}.`);149				setClosed(ctx.kepr, issue.id, unixNow(), save.id);150				break;151			}152			case "reopen":153				setClosed(ctx.kepr, issue.id, null);154				break;155			case "hide_entry":156			case "unhide_entry": {157				const entry = Number(fields.entry);158				const hidden = fields.action === "hide_entry";159				if (!Number.isSafeInteger(entry) || !setEntryHidden(ctx.kepr, issue.id, entry, hidden)) {160					return c.notFound();161				}162				break;163			}164			case "hide":165			case "unhide":166				setHidden(ctx.kepr, issue.id, fields.action === "hide");167				break;168			case "block":169				if (blockReporter(ctx.kepr, issue.id, unixNow()) === null) {170					return refuse("This issue has no address to block.");171				}172				break;173			case "type":174				if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.");175				setType(ctx.kepr, issue.id, fields.type as IssueType);176				break;177			default:178				return refuse("Unknown action.");179		}180		return c.redirect(`/admin/issues/${issue.repo}/${issue.number}`, 303);181	});182}183184function issuePage(c: Context, ctx: AdminContext, issue: OperatorIssue, error: string): string {185	const action = `/admin/issues/${issue.repo}/${issue.number}`;186	const button = (name: string, label: string) =>187		html`<form method="post" action="${action}">188${formField(c)}<input type="hidden" name="action" value="${name}">189<button class="issues-submit" type="submit">${label}</button></form>`;190	const post = (operator: boolean, at: number, text: string, entry?: Entry) =>191		html`<article class="issue-post${operator ? " issue-post--operator" : ""}">192<p class="issue-post-head mono">${193			operator194				? html`<span class="issue-badge">operator</span>`195				: html`<span class="issue-author">${issue.reporter_name || "anonymous"}</span>`196		}<span class="history-when">${formatDate(at)}</span>${197			entry?.hidden ? html`<span class="issues-error">hidden</span>` : ""198		}</p>199<div class="issue-text">${text}</div>200${201	entry &&202	html`<form method="post" action="${action}">${formField(c)}203<input type="hidden" name="action" value="${entry.hidden ? "unhide_entry" : "hide_entry"}">204<input type="hidden" name="entry" value="${String(entry.id)}">205<button class="issues-cancel mono" type="submit">${entry.hidden ? "unhide" : "hide (spam)"}</button>206</form>`207}208</article>`;209	const typeOption = (type: string) =>210		html`<option value="${type}"${issue.type === type ? html` selected` : ""}>${type}</option>`;211	const body = html`${notice(error)}212<p class="issue-meta mono">${issue.repo} #${issue.number} · ${issue.status}${213		issue.fixed_in && ` · fixed in ${issue.fixed_in}`214	}${issue.hidden ? " · hidden" : ""} · ${issue.version && `v${issue.version} · `}filed ${formatDate(issue.filed_at)}</p>215<p class="issue-meta mono">reporter: ${issue.reporter_name || "anonymous"}216· contact: ${issue.contact_email || "none given"}${217		reporterBlocked(ctx.kepr, issue.id)218			? html` · <span class="issues-error">reporter blocked</span>`219			: ""220	}</p>221${post(false, issue.filed_at, issue.body)}222${operatorEntries(ctx.kepr, issue.id).map((entry) =>223	post(entry.author === "operator", entry.created_at, entry.body, entry),224)}225<form class="issues-form" method="post" action="${action}">${formField(c)}226<input type="hidden" name="action" value="reply">227<label class="issues-label" for="reply">reply</label>228<textarea class="issues-textarea" id="reply" name="body" rows="5" required229maxlength="${MAX_CHARS}"></textarea>230<button class="issues-submit" type="submit">reply</button>231</form>232${233	issue.status === "open"234		? html`<form class="issues-form" method="post" action="${action}">${formField(c)}235<input type="hidden" name="action" value="close">236<label class="issues-label" for="fixed-in">fixed in save237<span class="muted">(optional)</span></label>238<input class="issues-input" id="fixed-in" name="fixed_in" maxlength="12">239<button class="issues-submit" type="submit">close</button>240</form>`241		: button("reopen", "reopen")242}243<form class="issues-form" method="post" action="${action}">${formField(c)}244<input type="hidden" name="action" value="type">245<label class="issues-label" for="type">type</label>246<select class="issues-input" id="type" name="type">${ISSUE_TYPES.map(typeOption)}</select>247<button class="issues-submit" type="submit">set type</button>248</form>249${issue.hidden ? button("unhide", "unhide") : button("hide", "hide (spam)")}250${!reporterBlocked(ctx.kepr, issue.id) && button("block", "block this reporter")}`;251	return adminPage(c, ctx, issue.title, body);252}