issues.ts

8.8 KB · raw

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