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