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" : ""}" href="/admin/issues?filter=${f}">${f}</a>`;33		const issues = operatorIssues(ctx.kepr, filter);34		const body = html`<p class="issues-bar mono">${FILTERS.map(tab)}</p>35<section class="repo-section">36${issues.length > 0 ? issues.map((issue) => html`<a class="issue-row" href="/admin/issues/${issue.repo}/${issue.number}"><span class="issue-title">${issue.title}</span><span class="issue-meta mono">${issue.repo} #${issue.number} · ${issue.type} · ${issue.status} · ${relativeTime(issue.filed_at)}</span></a>`) : html`<p class="muted mono save-empty">no ${filter} issues</p>`}37</section>`;38		return c.html(adminPage(c, ctx, "issues", body));39	});4041	app.get(ISSUE_PATH, (c) => {42		const issue = operatorIssue(ctx.kepr, c.req.param("repo"), Number(c.req.param("n")));43		return issue ? c.html(issuePage(c, ctx, issue, "")) : c.notFound();44	});4546	app.post(ISSUE_PATH, async (c) => {47		const issue = operatorIssue(ctx.kepr, c.req.param("repo"), Number(c.req.param("n")));48		if (!issue) return c.notFound();49		const fields = await textFields(c);50		const refuse = (error: string) => c.html(issuePage(c, ctx, issue, error), 400);51		switch (fields.action) {52			case "reply": {53				const body = (fields.body ?? "").trim();54				if (body === "" || body.length > MAX_CHARS) {55					return refuse(`A reply is 1 to ${MAX_CHARS.toLocaleString("en-US")} characters.`);56				}57				addEntry(ctx.kepr, issue.id, "operator", body, unixNow());58				break;59			}60			case "close": {61				const prefix = (fields.fixed_in ?? "").trim();62				if (prefix === "") {63					setClosed(ctx.kepr, issue.id, unixNow());64					break;65				}66				const save = SAVE_PREFIX.test(prefix)67					? saveByPrefix(openMirror(ctx.mirrorDir, issue.repo), prefix)68					: null;69				if (!save) return refuse(`No save ${prefix} in ${issue.repo}.`);70				setClosed(ctx.kepr, issue.id, unixNow(), save.id);71				break;72			}73			case "reopen":74				setClosed(ctx.kepr, issue.id, null);75				break;76			case "hide":77			case "unhide":78				setHidden(ctx.kepr, issue.id, fields.action === "hide");79				break;80			case "type":81				if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.");82				setType(ctx.kepr, issue.id, fields.type as IssueType);83				break;84			default:85				return refuse("Unknown action.");86		}87		return c.redirect(`/admin/issues/${issue.repo}/${issue.number}`, 303);88	});89}9091function issuePage(c: Context, ctx: AdminContext, issue: OperatorIssue, error: string): string {92	const action = `/admin/issues/${issue.repo}/${issue.number}`;93	const button = (name: string, label: string) =>94		html`<form method="post" action="${action}">${formField(c)}<input type="hidden" name="action" value="${name}"><button class="issues-submit" type="submit">${label}</button></form>`;95	const post = (operator: boolean, at: number, text: string) =>96		html`<article class="issue-post${operator ? " issue-post--operator" : ""}">97<p class="issue-post-head mono">${operator ? html`<span class="issue-badge">operator</span>` : html`<span class="issue-author">${issue.reporter_name || "anonymous"}</span>`}<span class="history-when">${formatDate(at)}</span></p>98<div class="issue-text">${text}</div>99</article>`;100	const typeOption = (type: string) =>101		html`<option value="${type}"${issue.type === type ? html` selected` : ""}>${type}</option>`;102	const body = html`${notice(error)}103<p class="issue-meta mono">${issue.repo} #${issue.number} · ${issue.status}${issue.fixed_in && ` · fixed in ${issue.fixed_in}`}${issue.hidden ? " · hidden" : ""} · ${issue.version && `v${issue.version} · `}filed ${formatDate(issue.filed_at)}</p>104<p class="issue-meta mono">reporter: ${issue.reporter_name || "anonymous"} · contact: ${issue.contact_email || "none given"}</p>105${post(false, issue.filed_at, issue.body)}106${threadEntries(ctx.kepr, issue.id).map((entry) => post(entry.author === "operator", entry.created_at, entry.body))}107<form class="issues-form" method="post" action="${action}">${formField(c)}108<input type="hidden" name="action" value="reply">109<label class="issues-label" for="reply">reply</label>110<textarea class="issues-textarea" id="reply" name="body" rows="5" required maxlength="${MAX_CHARS}"></textarea>111<button class="issues-submit" type="submit">reply</button>112</form>113${114	issue.status === "open"115		? html`<form class="issues-form" method="post" action="${action}">${formField(c)}116<input type="hidden" name="action" value="close">117<label class="issues-label" for="fixed-in">fixed in save <span class="muted">(optional)</span></label>118<input class="issues-input" id="fixed-in" name="fixed_in" maxlength="12">119<button class="issues-submit" type="submit">close</button>120</form>`121		: button("reopen", "reopen")122}123<form class="issues-form" method="post" action="${action}">${formField(c)}124<input type="hidden" name="action" value="type">125<label class="issues-label" for="type">type</label>126<select class="issues-input" id="type" name="type">${ISSUE_TYPES.map(typeOption)}</select>127<button class="issues-submit" type="submit">set type</button>128</form>129${issue.hidden ? button("unhide", "unhide") : button("hide", "hide (spam)")}`;130	return adminPage(c, ctx, issue.title, body);131}