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
↓ kepr-1a15eaa5.tar.gzatlas at this save
files changed6 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 −1package.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": {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+}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+}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);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 <on> 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 <soon>.");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+}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));