import type { Context, Hono } from "hono";
import { MAX_CHARS } from "../issues/spam.ts";
import {
addEntry,
type Entry,
ISSUE_TYPES,
type IssueType,
type OperatorFilter,
type OperatorIssue,
operatorEntries,
operatorIssue,
operatorIssues,
setClosed,
setEntryHidden,
setHidden,
setType,
} from "../issues/store.ts";
import { saveByPrefix } from "../mirror/queries.ts";
import { openMirror } from "../mirror/store.ts";
import { html } from "../pages/html.ts";
import { formatDate, relativeTime } from "../pages/layout.ts";
import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";
import { allRepos } from "./store.ts";
const FILTERS: [filter: OperatorFilter, label: string][] = [
["open", "open"],
["awaiting", "awaiting reply"],
["closed", "closed"],
["hidden", "hidden"],
];
const SAVE_PREFIX = /^[0-9a-f]{6,12}$/;
const ISSUE_PATH = "/admin/issues/:repo/:n{[0-9]{1,9}}";
const unixNow = () => Math.floor(Date.now() / 1000);
/** §24's Issues section: every repo's issues, and the operator's actions of §23. */
export function registerIssues(app: Hono, ctx: AdminContext): void {
app.get("/admin/issues", (c) => {
const filter = FILTERS.find(([f]) => f === c.req.query("filter"))?.[0] ?? "open";
// An unknown repo or type narrows nothing, as the public list treats an unknown type.
const repos = allRepos(ctx.kepr).map((repo) => repo.name);
const repo = repos.find((name) => name === c.req.query("repo")) ?? "";
const type = ISSUE_TYPES.find((t) => t === c.req.query("type")) ?? "";
const words = (c.req.query("q") ?? "").trim();
const narrowed = (f: OperatorFilter) =>
operatorIssues(ctx.kepr, f).filter(
(issue) =>
(!repo || issue.repo === repo) &&
(!type || issue.type === type) &&
`${issue.title}\n${issue.body}`.toLowerCase().includes(words.toLowerCase()),
);
const tab = ([f, label]: (typeof FILTERS)[number]) => {
const query = new URLSearchParams({ filter: f, repo, type, q: words });
return html`
${label} ${narrowed(f).length}`;
};
const option = (value: string, selected: string) =>
html``;
const issues = narrowed(filter);
const body = html`
${FILTERS.map(tab)}
`;
return c.html(adminPage(c, ctx, "issues", body));
});
app.get(ISSUE_PATH, (c) => {
const issue = operatorIssue(ctx.kepr, c.req.param("repo"), Number(c.req.param("n")));
return issue ? c.html(issuePage(c, ctx, issue, "")) : c.notFound();
});
app.post(ISSUE_PATH, async (c) => {
const issue = operatorIssue(ctx.kepr, c.req.param("repo"), Number(c.req.param("n")));
if (!issue) return c.notFound();
const fields = await textFields(c);
const refuse = (error: string) => c.html(issuePage(c, ctx, issue, error), 400);
switch (fields.action) {
case "reply": {
const body = (fields.body ?? "").trim();
if (body === "" || body.length > MAX_CHARS) {
return refuse(`A reply is 1 to ${MAX_CHARS.toLocaleString("en-US")} characters.`);
}
addEntry(ctx.kepr, issue.id, "operator", body, unixNow());
break;
}
case "close": {
const prefix = (fields.fixed_in ?? "").trim();
if (prefix === "") {
setClosed(ctx.kepr, issue.id, unixNow());
break;
}
const save = SAVE_PREFIX.test(prefix)
? saveByPrefix(openMirror(ctx.mirrorDir, issue.repo), prefix)
: null;
if (!save) return refuse(`No save ${prefix} in ${issue.repo}.`);
setClosed(ctx.kepr, issue.id, unixNow(), save.id);
break;
}
case "reopen":
setClosed(ctx.kepr, issue.id, null);
break;
case "hide_entry":
case "unhide_entry": {
const entry = Number(fields.entry);
const hidden = fields.action === "hide_entry";
if (!Number.isSafeInteger(entry) || !setEntryHidden(ctx.kepr, issue.id, entry, hidden)) {
return c.notFound();
}
break;
}
case "hide":
case "unhide":
setHidden(ctx.kepr, issue.id, fields.action === "hide");
break;
case "type":
if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.");
setType(ctx.kepr, issue.id, fields.type as IssueType);
break;
default:
return refuse("Unknown action.");
}
return c.redirect(`/admin/issues/${issue.repo}/${issue.number}`, 303);
});
}
function issuePage(c: Context, ctx: AdminContext, issue: OperatorIssue, error: string): string {
const action = `/admin/issues/${issue.repo}/${issue.number}`;
const button = (name: string, label: string) =>
html``;
const post = (operator: boolean, at: number, text: string, entry?: Entry) =>
html`
${
operator
? html`operator`
: html`${issue.reporter_name || "anonymous"}`
}${formatDate(at)}${
entry?.hidden ? html`hidden` : ""
}
${text}
${
entry &&
html``
}
`;
const typeOption = (type: string) =>
html``;
const body = html`${notice(error)}
${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)}
reporter: ${issue.reporter_name || "anonymous"}
· contact: ${issue.contact_email || "none given"}
${post(false, issue.filed_at, issue.body)}
${operatorEntries(ctx.kepr, issue.id).map((entry) =>
post(entry.author === "operator", entry.created_at, entry.body, entry),
)}
${
issue.status === "open"
? html``
: button("reopen", "reopen")
}
${issue.hidden ? button("unhide", "unhide") : button("hide", "hide (spam)")}`;
return adminPage(c, ctx, issue.title, body);
}