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
↓ kepr-987482f1.tar.gzatlas at this save
files changed9 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 −0package.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": {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;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">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")}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 }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">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 {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 };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+});