1a15eaa5791b

add the admin issues section: every repo's issues with open, closed and hidden filters, the reporter's contact detail, reply, close with an optional fixed-in save, reopen, hide, unhide and set type, tested end to end from a public filing to the operator's answer and close (v3.0.36)

time
2026-09-26 20:21 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
0750fb5f4f0a
6 files package.json+1 −1src/admin/issues.ts+131 −0src/issues/store.ts+41 −0src/server/app.ts+2 −0tests/console.test.ts+104 −11tests/issues.test.ts+1 −1
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.35",3+	"version": "3.0.36",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/issues.ts+131 −0
@@ -0,0 +1,131 @@1+import type { Context, Hono } from "hono";2+import { MAX_CHARS } from "../issues/spam.ts";3+import {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";16+import { saveByPrefix } from "../mirror/queries.ts";17+import { openMirror } from "../mirror/store.ts";18+import { html } from "../pages/html.ts";19+import { formatDate, relativeTime } from "../pages/layout.ts";20+import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";21+22+const FILTERS: OperatorFilter[] = ["open", "closed", "hidden"];23+const SAVE_PREFIX = /^[0-9a-f]{6,12}$/;24+const ISSUE_PATH = "/admin/issues/:repo/:n{[0-9]{1,9}}";25+const unixNow = () => Math.floor(Date.now() / 1000);26+27+/** §24's Issues section: every repo's issues, and the operator's actions of §23. */28+export 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+	});40+41+	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+	});45+46+	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+}90+91+function 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+}

view file

src/issues/store.ts+41 −0
@@ -146,3 +146,44 @@146146 			.get()?.n ?? 0147147 	);148148 }149+150+/** An issue as the operator sees it (§24): hidden or not, with the reporter's contact detail. */151+export type OperatorIssue = Issue & { repo: string; hidden: number; contact_email: string };152+export type OperatorFilter = "open" | "closed" | "hidden";153+154+const OPERATOR_COLUMNS = `${PUBLIC_COLUMNS}, repo, hidden, contact_email`;155+156+/** Every repo's issues, private repos' included: open or closed ones not hidden, or the hidden. */157+export function operatorIssues(db: Database, filter: OperatorFilter): OperatorIssue[] {158+	const where = filter === "hidden" ? "hidden = 1" : "hidden = 0 AND status = ?";159+	const query = db.query<OperatorIssue, string[]>(160+		`SELECT ${OPERATOR_COLUMNS} FROM issues WHERE ${where} ORDER BY filed_at DESC, id DESC`,161+	);162+	return filter === "hidden" ? query.all() : query.all(filter);163+}164+165+export function operatorIssue(db: Database, repo: string, number: number): OperatorIssue | null {166+	return db167+		.query<OperatorIssue, [string, number]>(168+			`SELECT ${OPERATOR_COLUMNS} FROM issues WHERE repo = ? AND number = ?`,169+		)170+		.get(repo, number);171+}172+173+/** Closes an issue, optionally "fixed in" a save (§23); reopening clears both. */174+export function setClosed(db: Database, id: number, closedAt: number | null, fixedIn = ""): void {175+	db.query("UPDATE issues SET status = ?, closed_at = ?, fixed_in = ? WHERE id = ?").run(176+		closedAt === null ? "open" : "closed",177+		closedAt,178+		fixedIn,179+		id,180+	);181+}182+183+export function setHidden(db: Database, id: number, hidden: boolean): void {184+	db.query("UPDATE issues SET hidden = ? WHERE id = ?").run(hidden ? 1 : 0, id);185+}186+187+export function setType(db: Database, id: number, type: IssueType): void {188+	db.query("UPDATE issues SET type = ? WHERE id = ?").run(type, id);189+}

view file

src/server/app.ts+2 −0
@@ -4,6 +4,7 @@44 import { bodyLimit } from "hono/body-limit";55 import { secureHeaders } from "hono/secure-headers";66 import { registerStatus } from "../admin/console.ts";7+import { registerIssues } from "../admin/issues.ts";78 import { registerRepos } from "../admin/repos.ts";89 import { openBucket } from "../bucket/reader.ts";910 import { isSafePath } from "../bucket/repo.ts";@@ -74,6 +75,7 @@7475 	};7576 	registerStatus(app, admin);7677 	registerRepos(app, admin);78+	registerIssues(app, admin);7779 	registerHousekeeping(app, ctx);7880 	registerFrontPages(app, ctx);7981 	registerDocsPages(app, ctx);

view file

tests/console.test.ts+104 −11
@@ -60,17 +60,7 @@6060 		});6161 		inst = await instance(bucketDir);6262 		inst.kepr.run("INSERT INTO settings (key, value) VALUES ('handle', 'june')");63-		fileIssue(inst.kepr, {64-			repo: "app",65-			title: "It broke",66-			body: "b",67-			type: "bug",68-			version: "",69-			reporterName: "",70-			contactEmail: "",71-			ipHash: "x",72-			filedAt: 1,73-		});63+		fileIssueDirect(inst, "app");7464 		browser = new Browser(inst.app);7565 		await browser.signIn();7666 	});@@ -148,3 +138,106 @@148138 		);149139 	});150140 });141+142+describe("issues: filed, answered and closed end to end through real routes", () => {143+	let inst: Instance;144+	let operator: Browser;145+	let save: string;146+	const text = async (path: string) => (await inst.app.request(path)).text();147+	const act = (fields: Record<string, string>) => operator.post("/admin/issues/app/1", fields);148+149+	beforeAll(async () => {150+		setSystemTime(new Date(clock));151+		const repos = await makeRepos(["app", "hush"]);152+		save = repos.saves.app as string;153+		inst = await instance(repos.bucketDir);154+		operator = new Browser(inst.app);155+		await operator.signIn();156+		await operator.post("/admin/repos/app", { public: "1", sort_order: "0" });157+		// A reporter files through the public form, as a browser would.158+		const reporter = new Browser(inst.app, "198.51.100.40");159+		const form = await (await reporter.request("/app/issues/new")).text();160+		const stamp = /name="stamp" value="([^"]+)"/.exec(form)?.[1] ?? "";161+		tick(4);162+		const filed = await reporter.request("/app/issues/new", {163+			method: "POST",164+			body: new URLSearchParams({165+				stamp,166+				website: "",167+				title: "Crash <on> start",168+				body: "It crashed.",169+				type: "bug",170+				version: "",171+				name: "Ada",172+				email: "[email protected]",173+			}),174+		});175+		expect(filed.status).toBe(200);176+		fileIssueDirect(inst, "hush");177+	});178+	afterAll(() => setSystemTime());179+180+	test("the console lists issues across repos, private ones too, with the contact detail", async () => {181+		const list = await (await operator.get("/admin/issues")).text();182+		expect(list).toContain('href="/admin/issues/app/1"');183+		expect(list).toContain('href="/admin/issues/hush/1"');184+		expect(list).toContain("Crash &lt;on&gt; start");185+		const page = await (await operator.get("/admin/issues/app/1")).text();186+		expect(page).toContain("contact: [email protected]");187+		expect(await text("/app/issues/1")).not.toContain("[email protected]");188+	});189+190+	test("an operator reply shows on the public thread, marked", async () => {191+		expect((await act({ action: "reply", body: "Fixed <soon>." })).status).toBe(303);192+		expect((await act({ action: "reply", body: "  " })).status).toBe(400);193+		const thread = await text("/app/issues/1");194+		expect(thread).toContain('<span class="issue-badge">operator</span>');195+		expect(thread).toContain("Fixed &lt;soon&gt;.");196+	});197+198+	test("close with a fixed-in save prefix, then reopen", async () => {199+		expect((await act({ action: "close", fixed_in: "zzzzzz" })).status).toBe(400);200+		expect((await act({ action: "close", fixed_in: "0123456789ab" })).status).toBe(400);201+		expect((await act({ action: "close", fixed_in: save.slice(0, 6) })).status).toBe(303);202+		const thread = await text("/app/issues/1");203+		expect(thread).toContain("◈ closed");204+		expect(thread).toContain(`fixed in <a class="history-id" href="/app/saves/${save}">`);205+		expect(await text("/app/issues?status=closed")).toContain('href="/app/issues/1"');206+		expect(await (await operator.get("/admin/issues?filter=closed")).text()).toContain(207+			'href="/admin/issues/app/1"',208+		);209+		expect((await act({ action: "reopen" })).status).toBe(303);210+		expect(await text("/app/issues/1")).toContain("◉ open");211+		expect(await text("/app/issues/1")).not.toContain("fixed in");212+	});213+214+	test("set type, and hide for spam: gone publicly, kept in the console", async () => {215+		expect((await act({ action: "type", type: "nonsense" })).status).toBe(400);216+		expect((await act({ action: "type", type: "idea" })).status).toBe(303);217+		expect(await text("/app/issues")).toContain("#1 · idea");218+		expect((await act({ action: "hide" })).status).toBe(303);219+		expect((await inst.app.request("/app/issues/1")).status).toBe(404);220+		expect(await (await operator.get("/admin/issues")).text()).not.toContain("/admin/issues/app/1");221+		expect(await (await operator.get("/admin/issues?filter=hidden")).text()).toContain(222+			'href="/admin/issues/app/1"',223+		);224+		expect((await act({ action: "unhide" })).status).toBe(303);225+		expect((await inst.app.request("/app/issues/1")).status).toBe(200);226+		expect((await act({ action: "delete" })).status).toBe(400);227+		expect((await operator.post("/admin/issues/app/9", { action: "reopen" })).status).toBe(404);228+	});229+});230+231+function fileIssueDirect(inst: Instance, repo: string): void {232+	fileIssue(inst.kepr, {233+		repo,234+		title: `A ${repo} issue`,235+		body: "b",236+		type: "bug",237+		version: "",238+		reporterName: "",239+		contactEmail: "",240+		ipHash: "x",241+		filedAt: 1,242+	});243+}

view file

tests/issues.test.ts+1 −1
@@ -272,7 +272,7 @@272272 		expect((await via("127.0.0.1", "198.51.100.20, 198.51.100.21")).status).toBe(200);273273 	});274274 275-	test("an operator reply and a close show on the thread, marked (the console is Phase 7's)", async () => {275+	test("an operator reply and a close show on the thread, marked", async () => {276276 		const found = findIssue(kepr, "app", 1);277277 		if (!found) throw new Error("issue 1 missing");278278 		addEntry(kepr, found.id, "operator", "Fixed, thanks.", Math.floor(clock / 1000));

view file