issues.ts

11.4 KB · raw

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