issues.ts

6.2 KB · raw

1import type { Context, Hono } from "hono";2import { MAX_CHARS } from "../issues/spam.ts";3import {4	addEntry,5	ISSUE_TYPES,6	type IssueType,7	type OperatorFilter,8	type OperatorIssue,9	operatorIssue,10	operatorIssues,11	setClosed,12	setHidden,13	setType,14	threadEntries,15} from "../issues/store.ts";16import { saveByPrefix } from "../mirror/queries.ts";17import { openMirror } from "../mirror/store.ts";18import { html } from "../pages/html.ts";19import { formatDate, relativeTime } from "../pages/layout.ts";20import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";2122const FILTERS: OperatorFilter[] = ["open", "closed", "hidden"];23const SAVE_PREFIX = /^[0-9a-f]{6,12}$/;24const ISSUE_PATH = "/admin/issues/:repo/:n{[0-9]{1,9}}";25const unixNow = () => Math.floor(Date.now() / 1000);2627/** §24's Issues section: every repo's issues, and the operator's actions of §23. */28export function registerIssues(app: Hono, ctx: AdminContext): void {29	app.get("/admin/issues", (c) => {30		const filter = FILTERS.find((f) => f === c.req.query("filter")) ?? "open";31		const tab = (f: OperatorFilter) =>32			html`<a class="issues-tab${f === filter ? " active" : ""}"33				href="/admin/issues?filter=${f}">${f}</a>`;34		const issues = operatorIssues(ctx.kepr, filter);35		const body = html`<p class="issues-bar mono">${FILTERS.map(tab)}</p>36<section class="repo-section">37${38	issues.length > 039		? issues.map(40				(issue) =>41					html`<a class="issue-row" href="/admin/issues/${issue.repo}/${issue.number}">42<span class="issue-title">${issue.title}</span>43<span class="issue-meta mono">${issue.repo} #${issue.number} · ${issue.type} ·44${issue.status} · ${relativeTime(issue.filed_at)}</span></a>`,45			)46		: html`<p class="muted mono save-empty">no ${filter} issues</p>`47}48</section>`;49		return c.html(adminPage(c, ctx, "issues", body));50	});5152	app.get(ISSUE_PATH, (c) => {53		const issue = operatorIssue(ctx.kepr, c.req.param("repo"), Number(c.req.param("n")));54		return issue ? c.html(issuePage(c, ctx, issue, "")) : c.notFound();55	});5657	app.post(ISSUE_PATH, async (c) => {58		const issue = operatorIssue(ctx.kepr, c.req.param("repo"), Number(c.req.param("n")));59		if (!issue) return c.notFound();60		const fields = await textFields(c);61		const refuse = (error: string) => c.html(issuePage(c, ctx, issue, error), 400);62		switch (fields.action) {63			case "reply": {64				const body = (fields.body ?? "").trim();65				if (body === "" || body.length > MAX_CHARS) {66					return refuse(`A reply is 1 to ${MAX_CHARS.toLocaleString("en-US")} characters.`);67				}68				addEntry(ctx.kepr, issue.id, "operator", body, unixNow());69				break;70			}71			case "close": {72				const prefix = (fields.fixed_in ?? "").trim();73				if (prefix === "") {74					setClosed(ctx.kepr, issue.id, unixNow());75					break;76				}77				const save = SAVE_PREFIX.test(prefix)78					? saveByPrefix(openMirror(ctx.mirrorDir, issue.repo), prefix)79					: null;80				if (!save) return refuse(`No save ${prefix} in ${issue.repo}.`);81				setClosed(ctx.kepr, issue.id, unixNow(), save.id);82				break;83			}84			case "reopen":85				setClosed(ctx.kepr, issue.id, null);86				break;87			case "hide":88			case "unhide":89				setHidden(ctx.kepr, issue.id, fields.action === "hide");90				break;91			case "type":92				if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.");93				setType(ctx.kepr, issue.id, fields.type as IssueType);94				break;95			default:96				return refuse("Unknown action.");97		}98		return c.redirect(`/admin/issues/${issue.repo}/${issue.number}`, 303);99	});100}101102function issuePage(c: Context, ctx: AdminContext, issue: OperatorIssue, error: string): string {103	const action = `/admin/issues/${issue.repo}/${issue.number}`;104	const button = (name: string, label: string) =>105		html`<form method="post" action="${action}">106${formField(c)}<input type="hidden" name="action" value="${name}">107<button class="issues-submit" type="submit">${label}</button></form>`;108	const post = (operator: boolean, at: number, text: string) =>109		html`<article class="issue-post${operator ? " issue-post--operator" : ""}">110<p class="issue-post-head mono">${111			operator112				? html`<span class="issue-badge">operator</span>`113				: html`<span class="issue-author">${issue.reporter_name || "anonymous"}</span>`114		}<span class="history-when">${formatDate(at)}</span></p>115<div class="issue-text">${text}</div>116</article>`;117	const typeOption = (type: string) =>118		html`<option value="${type}"${issue.type === type ? html` selected` : ""}>${type}</option>`;119	const body = html`${notice(error)}120<p class="issue-meta mono">${issue.repo} #${issue.number} · ${issue.status}${121		issue.fixed_in && ` · fixed in ${issue.fixed_in}`122	}${issue.hidden ? " · hidden" : ""} · ${issue.version && `v${issue.version} · `}filed ${formatDate(issue.filed_at)}</p>123<p class="issue-meta mono">reporter: ${issue.reporter_name || "anonymous"}124· contact: ${issue.contact_email || "none given"}</p>125${post(false, issue.filed_at, issue.body)}126${threadEntries(ctx.kepr, issue.id).map((entry) =>127	post(entry.author === "operator", entry.created_at, entry.body),128)}129<form class="issues-form" method="post" action="${action}">${formField(c)}130<input type="hidden" name="action" value="reply">131<label class="issues-label" for="reply">reply</label>132<textarea class="issues-textarea" id="reply" name="body" rows="5" required133maxlength="${MAX_CHARS}"></textarea>134<button class="issues-submit" type="submit">reply</button>135</form>136${137	issue.status === "open"138		? html`<form class="issues-form" method="post" action="${action}">${formField(c)}139<input type="hidden" name="action" value="close">140<label class="issues-label" for="fixed-in">fixed in save141<span class="muted">(optional)</span></label>142<input class="issues-input" id="fixed-in" name="fixed_in" maxlength="12">143<button class="issues-submit" type="submit">close</button>144</form>`145		: button("reopen", "reopen")146}147<form class="issues-form" method="post" action="${action}">${formField(c)}148<input type="hidden" name="action" value="type">149<label class="issues-label" for="type">type</label>150<select class="issues-input" id="type" name="type">${ISSUE_TYPES.map(typeOption)}</select>151<button class="issues-submit" type="submit">set type</button>152</form>153${issue.hidden ? button("unhide", "unhide") : button("hide", "hide (spam)")}`;154	return adminPage(c, ctx, issue.title, body);155}