issues.ts

13.3 KB · raw

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