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);262728export 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}