987482f17e06

add issue triage tabs, filters, follow-up hiding and a per-repo pause on new issues (v3.0.134)

time
2026-09-29 22:31 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
issue triage
parent
364bcfc8c156
9 files package.json+1 −1public/css/kepr.css+6 −0src/admin/issues.ts+75 −13src/admin/repos.ts+4 −0src/admin/store.ts+3 −1src/issues/pages.ts+8 −2src/issues/store.ts+54 −11src/server/db.ts+22 −7tests/triage.test.ts+137 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.133",3+	"version": "3.0.134",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+6 −0
@@ -1559,6 +1559,12 @@15591559 .issues-cancel:hover {15601560 	color: var(--accent);15611561 }1562+button.issues-cancel {1563+	padding: 0;1564+	border: 0;1565+	background: none;1566+	cursor: pointer;1567+}15621568 .issues-error {15631569 	font-size: 0.75rem;15641570 	color: #c86a5a;

view file

src/admin/issues.ts+75 −13
@@ -2,24 +2,32 @@22 import { MAX_CHARS } from "../issues/spam.ts";33 import {44 	addEntry,5+	type Entry,56 	ISSUE_TYPES,67 	type IssueType,78 	type OperatorFilter,89 	type OperatorIssue,10+	operatorEntries,911 	operatorIssue,1012 	operatorIssues,1113 	setClosed,14+	setEntryHidden,1215 	setHidden,1316 	setType,14-	threadEntries,1517 } from "../issues/store.ts";1618 import { saveByPrefix } from "../mirror/queries.ts";1719 import { openMirror } from "../mirror/store.ts";1820 import { html } from "../pages/html.ts";1921 import { formatDate, relativeTime } from "../pages/layout.ts";2022 import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";23+import { allRepos } from "./store.ts";2124 22-const FILTERS: OperatorFilter[] = ["open", "closed", "hidden"];25+const FILTERS: [filter: OperatorFilter, label: string][] = [26+	["open", "open"],27+	["awaiting", "awaiting reply"],28+	["closed", "closed"],29+	["hidden", "hidden"],30+];2331 const SAVE_PREFIX = /^[0-9a-f]{6,12}$/;2432 const ISSUE_PATH = "/admin/issues/:repo/:n{[0-9]{1,9}}";2533 const unixNow = () => Math.floor(Date.now() / 1000);@@ -27,23 +35,58 @@2735 /** §24's Issues section: every repo's issues, and the operator's actions of §23. */2836 export function registerIssues(app: Hono, ctx: AdminContext): void {2937 	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);38+		const filter = FILTERS.find(([f]) => f === c.req.query("filter"))?.[0] ?? "open";39+		// An unknown repo or type narrows nothing, as the public list treats an unknown type.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);3560 		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"72+placeholder="search titles and text" value="${words}">73+<button class="issues-submit" type="submit">filter</button>74+</form>3675 <section class="repo-section">3776 ${3877 	issues.length > 03978 		? issues.map(4079 				(issue) =>4180 					html`<a class="issue-row" href="/admin/issues/${issue.repo}/${issue.number}">42-<span class="issue-title">${issue.title}</span>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>4386 <span class="issue-meta mono">${issue.repo} #${issue.number} · ${issue.type} ·4487 ${issue.status} · ${relativeTime(issue.filed_at)}</span></a>`,4588 			)46-		: html`<p class="muted mono save-empty">no ${filter} issues</p>`89+		: html`<p class="muted mono save-empty">no issues here</p>`4790 }4891 </section>`;4992 		return c.html(adminPage(c, ctx, "issues", body));@@ -84,6 +127,15 @@84127 			case "reopen":85128 				setClosed(ctx.kepr, issue.id, null);86129 				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+			}87139 			case "hide":88140 			case "unhide":89141 				setHidden(ctx.kepr, issue.id, fields.action === "hide");@@ -105,14 +157,24 @@105157 		html`<form method="post" action="${action}">106158 ${formField(c)}<input type="hidden" name="action" value="${name}">107159 <button class="issues-submit" type="submit">${label}</button></form>`;108-	const post = (operator: boolean, at: number, text: string) =>160+	const post = (operator: boolean, at: number, text: string, entry?: Entry) =>109161 		html`<article class="issue-post${operator ? " issue-post--operator" : ""}">110162 <p class="issue-post-head mono">${111163 			operator112164 				? html`<span class="issue-badge">operator</span>`113165 				: html`<span class="issue-author">${issue.reporter_name || "anonymous"}</span>`114-		}<span class="history-when">${formatDate(at)}</span></p>166+		}<span class="history-when">${formatDate(at)}</span>${167+			entry?.hidden ? html`<span class="issues-error">hidden</span>` : ""168+		}</p>115169 <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+}116178 </article>`;117179 	const typeOption = (type: string) =>118180 		html`<option value="${type}"${issue.type === type ? html` selected` : ""}>${type}</option>`;@@ -123,8 +185,8 @@123185 <p class="issue-meta mono">reporter: ${issue.reporter_name || "anonymous"}124186 · contact: ${issue.contact_email || "none given"}</p>125187 ${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),188+${operatorEntries(ctx.kepr, issue.id).map((entry) =>189+	post(entry.author === "operator", entry.created_at, entry.body, entry),128190 )}129191 <form class="issues-form" method="post" action="${action}">${formField(c)}130192 <input type="hidden" name="action" value="reply">

view file

src/admin/repos.ts+4 −0
@@ -49,6 +49,7 @@4949 			install3: text("install3"),5050 			// Joined with the separator `publicRepo` splits on, so a name never contains it.5151 			languages: languages.join(", "),52+			issues_paused: fields.issues_paused === "1" ? 1 : 0,5253 		};5354 		const refuse = (error: string) => c.html(reposPage(c, ctx, `${repo.name}: ${error}`), 400);5455 		if (Object.values(fields).some((value) => value.length > MAX_FIELD)) {@@ -160,6 +161,9 @@160161 ${formField(c)}161162 <label class="issues-label">162163 <input type="checkbox" name="public" value="1"${repo.public ? html` checked` : ""}> public</label>164+<label class="issues-label">165+<input type="checkbox" name="issues_paused" value="1"${repo.issues_paused ? html` checked` : ""}>166+pause new issues</label>163167 ${input("category", "category")}164168 ${input("homepage", "homepage")}165169 ${input("sort_order", "card order")}

view file

src/admin/store.ts+3 −1
@@ -12,6 +12,7 @@1212 	install3: string;1313 	/** Shown languages, joined by ", "; '' shows the detected one. */1414 	languages: string;15+	issues_paused: number;1516 	last_sync_at: number | null;1617 	last_sync_error: string | null;1718 };@@ -29,7 +30,7 @@2930 export function updateRepo(db: Database, name: string, edit: RepoEdit): void {3031 	db.query(3132 		`UPDATE repos SET public = ?, category = ?, homepage = ?, sort_order = ?, install1 = ?,32-		install2 = ?, install3 = ?, languages = ?33+		install2 = ?, install3 = ?, languages = ?, issues_paused = ?3334 		WHERE name = ?`,3435 	).run(3536 		edit.public,@@ -40,6 +41,7 @@4041 		edit.install2,4142 		edit.install3,4243 		edit.languages,44+		edit.issues_paused,4345 		name,4446 	);4547 }

view file

src/issues/pages.ts+8 −2
@@ -54,6 +54,7 @@5454 	app.post("/:repo/issues/new", async (c) => {5555 		const repo = openPublicRepo(ctx, c.req.param("repo"));5656 		if (!repo) return notFound(c, ctx);57+		if (repo.settings.issuesPaused) return formPage(c, ctx, repo, {}, "", 403);5758 		const fields = await readFields(c, FORM_FIELDS);5859 		const now = unixNow();5960 		const refuse = (message: string, status: 400 | 403 | 429) =>@@ -196,7 +197,7 @@196197 		body: html`<p class="breadcrumb mono"><a href="/${name}">${name}</a></p>197198 <h1 class="file-page-title">issues</h1>198199 <p class="issues-bar mono">${tab("open")}${tab("closed")}199-<a class="issues-new" href="/${name}/issues/new">report an issue →</a></p>200+${!repo.settings.issuesPaused && html`<a class="issues-new" href="/${name}/issues/new">report an issue →</a>`}</p>200201 <form class="issues-filter" method="get" action="/${name}/issues">201202 <input type="hidden" name="status" value="${status}">202203 <input class="issues-input" name="q" type="search" aria-label="search issue titles"@@ -239,7 +240,12 @@239240 	privatePage(c);240241 	const typeOption = (type: string) =>241242 		html`<option value="${type}"${values.type === type ? html` selected` : ""}>${type}</option>`;242-	const body = html`${crumbs(name)}243+	const body = repo.settings.issuesPaused244+		? html`${crumbs(name)}245+<h1 class="file-page-title">report an issue</h1>246+<p>${name} is not taking new issues right now.</p>247+<p><a href="/${name}/issues">see the existing issues →</a></p>`248+		: html`${crumbs(name)}243249 <h1 class="file-page-title">report an issue</h1>244250 ${error && html`<p class="issues-error mono" role="alert">${error}</p>`}245251 <form class="issues-form" method="post" action="/${name}/issues/new">

view file

src/issues/store.ts+54 −11
@@ -23,6 +23,7 @@2323 	author: "reporter" | "operator";2424 	body: string;2525 	created_at: number;26+	hidden: number;2627 };2728 2829 export type NewIssue = {@@ -125,14 +126,37 @@125126 		.get(repo, number);126127 }127128 129+/** An issue's visible follow-ups and replies, oldest first. */128130 export function threadEntries(db: Database, issueId: number): Entry[] {131+	return operatorEntries(db, issueId).filter((entry) => !entry.hidden);132+}133+134+/** Every entry in an issue's thread, hidden ones included, for the console. */135+export function operatorEntries(db: Database, issueId: number): Entry[] {129136 	return db130137 		.query<Entry, [number]>(131-			"SELECT id, author, body, created_at FROM issue_entries WHERE issue_id = ? ORDER BY id",138+			`SELECT id, author, body, created_at, hidden139+FROM issue_entries140+WHERE issue_id = ?141+ORDER BY id`,132142 		)133143 		.all(issueId);134144 }135145 146+/** Hides or shows one entry of one issue; false when the issue has no such entry. */147+export function setEntryHidden(148+	db: Database,149+	issueId: number,150+	entryId: number,151+	hidden: boolean,152+): boolean {153+	return (154+		db155+			.query("UPDATE issue_entries SET hidden = ? WHERE id = ? AND issue_id = ?")156+			.run(hidden ? 1 : 0, entryId, issueId).changes === 1157+	);158+}159+136160 export function addEntry(137161 	db: Database,138162 	issueId: number,@@ -162,21 +186,40 @@162186 }163187 164188 /** An issue as the operator sees it (§24): hidden or not, with the reporter's contact detail. */165-export type OperatorIssue = Issue & { repo: string; hidden: number; contact_email: string };166-export type OperatorFilter = "open" | "closed" | "hidden";189+export type OperatorIssue = Issue & {190+	repo: string;191+	hidden: number;192+	contact_email: string;193+	/** 1 when the reporter spoke last: the filing, or their newest visible follow-up (§24). */194+	awaiting: number;195+};196+export type OperatorFilter = "open" | "awaiting" | "closed" | "hidden";167197 168-const OPERATOR_COLUMNS = `${PUBLIC_COLUMNS}, repo, hidden, contact_email`;198+const REPORTER_SPOKE_LAST = `COALESCE((199+	SELECT author FROM issue_entries e200+	WHERE e.issue_id = issues.id AND e.hidden = 0201+	ORDER BY e.id DESC202+	LIMIT 1203+), 'reporter') = 'reporter'`;204+const OPERATOR_COLUMNS = `${PUBLIC_COLUMNS}, repo, hidden, contact_email,205+${REPORTER_SPOKE_LAST} AS awaiting`;206+const OPERATOR_WHERE: Record<OperatorFilter, string> = {207+	open: "hidden = 0 AND status = 'open'",208+	awaiting: `hidden = 0 AND status = 'open' AND ${REPORTER_SPOKE_LAST}`,209+	closed: "hidden = 0 AND status = 'closed'",210+	hidden: "hidden = 1",211+};169212 170-/** Every repo's issues, private repos' included: open or closed ones not hidden, or the hidden. */213+/** Every repo's issues under one console tab, private repos' included, newest first. */171214 export function operatorIssues(db: Database, filter: OperatorFilter): OperatorIssue[] {172-	const where = filter === "hidden" ? "hidden = 1" : "hidden = 0 AND status = ?";173-	const query = db.query<OperatorIssue, string[]>(174-		`SELECT ${OPERATOR_COLUMNS}215+	return db216+		.query<OperatorIssue, []>(217+			`SELECT ${OPERATOR_COLUMNS}175218 FROM issues176-WHERE ${where}219+WHERE ${OPERATOR_WHERE[filter]}177220 ORDER BY filed_at DESC, id DESC`,178-	);179-	return filter === "hidden" ? query.all() : query.all(filter);221+		)222+		.all();180223 }181224 182225 export function operatorIssue(db: Database, repo: string, number: number): OperatorIssue | null {

view file

src/server/db.ts+22 −7
@@ -18,6 +18,7 @@1818 	install2        TEXT NOT NULL DEFAULT '',1919 	install3        TEXT NOT NULL DEFAULT '',2020 	languages       TEXT NOT NULL DEFAULT '',21+	issues_paused   INTEGER NOT NULL DEFAULT 0,2122 	last_sync_at    INTEGER,2223 	last_sync_error TEXT2324 );@@ -69,7 +70,8 @@6970 	issue_id   INTEGER NOT NULL REFERENCES issues (id),7071 	author     TEXT NOT NULL CHECK (author IN ('reporter', 'operator')),7172 	body       TEXT NOT NULL,72-	created_at INTEGER NOT NULL73+	created_at INTEGER NOT NULL,74+	hidden     INTEGER NOT NULL DEFAULT 07375 );7476 CREATE INDEX IF NOT EXISTS issue_entries_issue ON issue_entries (issue_id, id);7577 -- §24: admin sessions, revocable; only the SHA-256 of the cookie's token is kept.@@ -92,6 +94,13 @@9294 );9395 `;9496 97+// Columns added after the first kepr.db was made; SQLite has no ADD COLUMN IF NOT EXISTS.98+const ADDED_COLUMNS: [table: string, column: string, definition: string][] = [99+	["repos", "languages", "TEXT NOT NULL DEFAULT ''"],100+	["repos", "issues_paused", "INTEGER NOT NULL DEFAULT 0"],101+	["issue_entries", "hidden", "INTEGER NOT NULL DEFAULT 0"],102+];103+95104 /** `description` and `details` are the operator's Markdown, or '' for the README's. */96105 export type RepoSettings = {97106 	name: string;@@ -100,6 +109,8 @@100109 	installLines: string[];101110 	/** The operator's shown languages (§24), or none for the detected one. */102111 	languages: string[];112+	/** Filing is paused (§23); the list, threads and follow-ups carry on. */113+	issuesPaused: boolean;103114 	description: string;104115 	details: string;105116 };@@ -114,6 +125,7 @@114125 	install2: string;115126 	install3: string;116127 	languages: string;128+	issues_paused: number;117129 	description: string | null;118130 	details: string | null;119131 };@@ -124,10 +136,12 @@124136 	db.run("PRAGMA journal_mode = WAL");125137 	db.run("PRAGMA busy_timeout = 5000");126138 	db.run(SCHEMA);127-	// Added after the first kepr.db was made; SQLite has no ADD COLUMN IF NOT EXISTS.128-	const columns = db.query<{ name: string }, []>("PRAGMA table_info(repos)").all();129-	if (!columns.some((column) => column.name === "languages")) {130-		db.run("ALTER TABLE repos ADD COLUMN languages TEXT NOT NULL DEFAULT ''");139+	for (const [table, column, definition] of ADDED_COLUMNS) {140+		// Both names come from the list above, never from input.141+		const columns = db.query<{ name: string }, []>(`PRAGMA table_info(${table})`).all();142+		if (!columns.some((existing) => existing.name === column)) {143+			db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);144+		}131145 	}132146 	return db;133147 }@@ -165,8 +179,8 @@165179 export function publicRepo(db: Database, name: string): RepoSettings | null {166180 	const row = db167181 		.query<RepoRow, [string]>(168-			`SELECT name, category, homepage, install1, install2, install3, languages, description,169-			details182+			`SELECT name, category, homepage, install1, install2, install3, languages, issues_paused,183+			description, details170184 			FROM repos LEFT JOIN repo_text ON repo_text.repo = repos.name171185 			WHERE name = ? AND public = 1`,172186 		)@@ -179,6 +193,7 @@179193 		homepage: row.homepage,180194 		installLines,181195 		languages: row.languages === "" ? [] : row.languages.split(", "),196+		issuesPaused: row.issues_paused === 1,182197 		description: row.description ?? "",183198 		details: row.details ?? "",184199 	};

view file

tests/triage.test.ts+137 −0
@@ -0,0 +1,137 @@1+// Covers §23–§24's triage: tab counts, awaiting reply, repo/type/word filters, hiding one2+// follow-up, and pausing new issues per repo.3+4+import { beforeAll, describe, expect, test } from "bun:test";5+import { addEntry, fileIssue, setClosed } from "../src/issues/store.ts";6+import { Browser, type Instance, instance } from "./browser.ts";7+import { makeRepos } from "./fixture.ts";8+9+describe("issue triage (§23, §24)", () => {10+	let inst: Instance;11+	let operator: Browser;12+	let followUp: number;13+	let token: string;14+	const list = async (query = "") => (await operator.get(`/admin/issues${query}`)).text();15+	const text = async (path: string) => (await inst.app.request(path)).text();16+	const file = (repo: string, title: string, body: string, type: "bug" | "idea" | "question") =>17+		fileIssue(inst.kepr, {18+			repo,19+			title,20+			body,21+			type,22+			version: "",23+			reporterName: "",24+			contactEmail: "",25+			ipHash: "x",26+			filedAt: 1,27+		});28+	const id = (repo: string, n: number) =>29+		(30+			inst.kepr31+				.query<{ id: number }, [string, number]>(32+					"SELECT id FROM issues WHERE repo = ? AND number = ?",33+				)34+				.get(repo, n) as { id: number }35+		).id;36+37+	beforeAll(async () => {38+		const repos = await makeRepos(["app", "tools"]);39+		inst = await instance(repos.bucketDir);40+		operator = new Browser(inst.app);41+		await operator.signIn();42+		for (const repo of ["app", "tools"]) {43+			await operator.post(`/admin/repos/${repo}`, { public: "1", sort_order: "0" });44+		}45+		file("app", "Crash on start", "A segfault in main", "bug");46+		file("app", "Dark mode", "Please", "idea");47+		token = file("app", "How to install", "Which command?", "question").token;48+		file("tools", "Tools broken", "Nothing works", "bug");49+		addEntry(inst.kepr, id("app", 2), "operator", "Planned.", 2);50+		addEntry(inst.kepr, id("app", 3), "operator", "Use the script.", 2);51+		followUp = addEntry(inst.kepr, id("app", 3), "reporter", "Buy cheap pills here", 3);52+		setClosed(inst.kepr, id("tools", 1), 4);53+	});54+55+	test("every tab carries its count", async () => {56+		const page = await list();57+		expect(page).toMatch(/>\s*open <span class="muted">3<\/span>/);58+		expect(page).toMatch(/>\s*awaiting reply <span class="muted">2<\/span>/);59+		expect(page).toMatch(/>\s*closed <span class="muted">1<\/span>/);60+		expect(page).toMatch(/>\s*hidden <span class="muted">0<\/span>/);61+	});62+63+	test("awaiting reply is the open issues where the reporter spoke last", async () => {64+		const page = await list("?filter=awaiting");65+		expect(page).toContain('href="/admin/issues/app/1"');66+		expect(page).toContain('href="/admin/issues/app/3"');67+		expect(page).not.toContain('href="/admin/issues/app/2"');68+		const rows = (await list()).split('class="issue-row"');69+		const row = (n: number) => rows.find((r) => r.includes(`/admin/issues/app/${n}"`)) ?? "";70+		expect(row(1)).toContain("awaiting reply");71+		expect(row(2)).not.toContain("awaiting reply");72+	});73+74+	test("narrows by repo, type and words in the title or text, keeping the form filled", async () => {75+		const closedTools = await list("?filter=closed&repo=tools");76+		expect(closedTools).toContain('href="/admin/issues/tools/1"');77+		expect(closedTools).toContain('<option value="tools" selected>');78+		expect(await list("?repo=tools")).not.toContain('href="/admin/issues/app/1"');79+		const ideas = await list("?type=idea");80+		expect(ideas).toContain('href="/admin/issues/app/2"');81+		expect(ideas).not.toContain('href="/admin/issues/app/1"');82+		const words = await list("?q=SEGFAULT");83+		expect(words).toContain('href="/admin/issues/app/1"');84+		expect(words).not.toContain('href="/admin/issues/app/2"');85+		expect(words).toContain('value="SEGFAULT"');86+		expect(words).toMatch(/>\s*open <span class="muted">1<\/span>/);87+		// An unknown repo or type narrows nothing rather than failing.88+		expect(await list("?repo=nowhere&type=nonsense")).toContain('href="/admin/issues/app/2"');89+	});90+91+	test("hiding one follow-up removes it publicly, keeps it marked in the console", async () => {92+		const act = (fields: Record<string, string>) => operator.post("/admin/issues/app/3", fields);93+		expect((await act({ action: "hide_entry", entry: String(followUp) })).status).toBe(303);94+		expect(await text("/app/issues/3")).not.toContain("cheap pills");95+		expect(await text("/app/issues/3")).toContain("Use the script.");96+		const console = await (await operator.get("/admin/issues/app/3")).text();97+		expect(console).toContain("cheap pills");98+		expect(console).toMatch(/hidden[\s\S]*?cheap pills/);99+		// The hidden follow-up no longer counts as the reporter speaking last.100+		expect(await list("?filter=awaiting")).not.toContain('href="/admin/issues/app/3"');101+		expect((await act({ action: "unhide_entry", entry: String(followUp) })).status).toBe(303);102+		expect(await text("/app/issues/3")).toContain("cheap pills");103+		const other = operator.post("/admin/issues/app/1", {104+			action: "hide_entry",105+			entry: String(followUp),106+		});107+		expect((await other).status).toBe(404);108+		expect((await act({ action: "hide_entry", entry: "x" })).status).toBe(404);109+	});110+111+	test("pausing new issues stops filing but keeps the list and follow-ups", async () => {112+		const pause = await operator.post("/admin/repos/app", {113+			public: "1",114+			sort_order: "0",115+			issues_paused: "1",116+		});117+		expect(pause.status).toBe(303);118+		expect(await (await operator.get("/admin/repos?repo=app")).text()).toMatch(119+			/name="issues_paused" value="1" checked/,120+		);121+		const form = await inst.app.request("/app/issues/new");122+		expect(form.status).toBe(200);123+		const formText = await form.text();124+		expect(formText).toContain("app is not taking new issues right now.");125+		expect(formText).not.toContain('name="title"');126+		const filed = await inst.app.request("/app/issues/new", {127+			method: "POST",128+			body: new URLSearchParams({ title: "t", body: "b", type: "bug" }),129+		});130+		expect(filed.status).toBe(403);131+		expect(await text("/app/issues")).not.toContain("report an issue →");132+		expect(await text("/app/issues")).toContain('href="/app/issues/1"');133+		expect(await text(`/app/issues/3?token=${token}`)).toContain('name="body"');134+		await operator.post("/admin/repos/app", { public: "1", sort_order: "0" });135+		expect(await text("/app/issues/new")).toContain('name="title"');136+	});137+});

view file