5487c6f1524a

close, reopen, hide or unhide ticked issues together from the console list (v3.0.164)

time
2026-09-30 23:24 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
operator console
parent
1440b2ee584c
5 files package.json+1 −1public/css/kepr.css+9 −0src/admin/issues.ts+46 −8src/issues/store.ts+28 −0tests/triage.test.ts+33 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.163",3+	"version": "3.0.164",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+9 −0
@@ -2225,6 +2225,15 @@22252225 .admin-days li > span:last-child {22262226 	text-align: right;22272227 }2228+.admin-select-row {2229+	display: flex;2230+	gap: 12px;2231+	align-items: center;2232+}2233+.admin-select-row .issue-row {2234+	flex: 1;2235+	min-width: 0;2236+}22282237 .admin-table {22292238 	width: 100%;22302239 	border-collapse: collapse;

view file

src/admin/issues.ts+46 −8
@@ -4,6 +4,7 @@44 	addEntry,55 	blockedReporters,66 	blockReporter,7+	countIssues,78 	type Entry,89 	ISSUE_TYPES,910 	type IssueType,@@ -16,6 +17,8 @@1617 	setClosed,1718 	setEntryHidden,1819 	setHidden,20+	setManyClosed,21+	setManyHidden,1922 	setType,2023 	unblockReporter,2124 } from "../issues/store.ts";@@ -32,6 +35,7 @@3235 	["closed", "closed"],3336 	["hidden", "hidden"],3437 ];38+const BULK_ACTIONS = ["close", "reopen", "hide", "unhide"] as const;3539 const SAVE_PREFIX = /^[0-9a-f]{6,12}$/;3640 const ISSUE_PATH = "/admin/issues/:repo/:n{[0-9]{1,9}}";3741 const unixNow = () => Math.floor(Date.now() / 1000);@@ -89,32 +93,66 @@8993 placeholder="search titles and text" value="${words}">9094 <button class="issues-submit" type="submit">filter</button>9195 </form>92-<section class="repo-section">96+<form class="repo-section" method="post" action="/admin/issues">97+${formField(c)}<input type="hidden" name="filter" value="${filter}">9398 ${99+	issues.length > 0 &&100+	html`<p class="issues-filter">101+<select class="issues-input" name="action" aria-label="action for ticked issues">102+${BULK_ACTIONS.map((action) => html`<option value="${action}">${action}</option>`)}103+</select>104+<button class="issues-submit" type="submit">apply to ticked</button></p>`105+}106+${94107 	issues.length > 095108 		? issues.map(96109 				(issue) =>97-					html`<a class="issue-row" href="/admin/issues/${issue.repo}/${issue.number}">110+					html`<div class="admin-select-row">111+<input type="checkbox" name="issue" value="${String(issue.id)}"112+aria-label="tick ${issue.repo} #${String(issue.number)}">113+<a class="issue-row" href="/admin/issues/${issue.repo}/${issue.number}">98114 <span class="issue-title">${issue.title}${99115 						issue.awaiting && !issue.hidden && issue.status === "open"100116 							? html` <span class="issue-badge">awaiting reply</span>`101117 							: ""102118 					}</span>103119 <span class="issue-meta mono">${issue.repo} #${issue.number} · ${issue.type} ·104-${issue.status} · ${relativeTime(issue.filed_at)}</span></a>`,120+${issue.status} · ${relativeTime(issue.filed_at)}</span></a></div>`,105121 			)106122 		: html`<p class="muted mono save-empty">no issues here</p>`107123 }108-</section>`;124+</form>`;109125 		return c.html(adminPage(c, ctx, "issues", body));110126 	});111127 112128 	app.post("/admin/issues", async (c) => {113-		const fields = await textFields(c);114-		if (fields.action !== "unblock" || !unblockReporter(ctx.kepr, Number(fields.block))) {115-			return c.notFound();129+		const form = await c.req.parseBody({ all: true });130+		const action = form.action;131+		if (action === "unblock") {132+			return unblockReporter(ctx.kepr, Number(form.block))133+				? c.redirect("/admin/issues", 303)134+				: c.notFound();116135 		}117-		return c.redirect("/admin/issues", 303);136+		const ids = [form.issue ?? []].flat().map(Number);137+		const known =138+			ids.length > 0 &&139+			ids.every(Number.isSafeInteger) &&140+			countIssues(ctx.kepr, ids) === new Set(ids).size;141+		if (!known) return c.notFound();142+		switch (action) {143+			case "close":144+			case "reopen":145+				setManyClosed(ctx.kepr, ids, action === "close" ? unixNow() : null);146+				break;147+			case "hide":148+			case "unhide":149+				setManyHidden(ctx.kepr, ids, action === "hide");150+				break;151+			default:152+				return c.notFound();153+		}154+		const filter = FILTERS.find(([f]) => f === form.filter)?.[0] ?? "open";155+		return c.redirect(`/admin/issues?filter=${filter}`, 303);118156 	});119157 120158 	app.get(ISSUE_PATH, (c) => {

view file

src/issues/store.ts+28 −0
@@ -250,6 +250,34 @@250250 	db.query("UPDATE issues SET type = ? WHERE id = ?").run(type, id);251251 }252252 253+// A list of issue ids bound as one JSON parameter, so the statement stays fixed whatever its length.254+const IDS = "id IN (SELECT value FROM json_each(?))";255+256+/** How many of these ids are issues; the console refuses a bulk action naming one that isn't. */257+export function countIssues(db: Database, ids: number[]): number {258+	return (259+		db260+			.query<{ n: number }, [string]>(`SELECT COUNT(*) AS n FROM issues WHERE ${IDS}`)261+			.get(JSON.stringify(ids))?.n ?? 0262+	);263+}264+265+/**266+ * Closes or reopens many issues at once (§24). Only those not already so change, so a closed267+ * issue keeps its time and "fixed in" save.268+ */269+export function setManyClosed(db: Database, ids: number[], closedAt: number | null): void {270+	db.query(271+		`UPDATE issues272+		SET status = ?1, closed_at = ?2, fixed_in = ''273+		WHERE id IN (SELECT value FROM json_each(?3)) AND status != ?1`,274+	).run(closedAt === null ? "open" : "closed", closedAt, JSON.stringify(ids));275+}276+277+export function setManyHidden(db: Database, ids: number[], hidden: boolean): void {278+	db.query(`UPDATE issues SET hidden = ? WHERE ${IDS}`).run(hidden ? 1 : 0, JSON.stringify(ids));279+}280+253281 export type BlockedReporter = { id: number; repo: string; number: number; blocked_at: number };254282 255283 /**

view file

tests/triage.test.ts+33 −0
@@ -134,6 +134,39 @@134134 		expect(await text("/app/issues/new")).toContain('name="title"');135135 	});136136 137+	test("ticked issues are closed, reopened, hidden and unhidden together", async () => {138+		const ticked = [id("app", 1), id("app", 2)];139+		const bulk = (action: string, issues = ticked) => {140+			const form = new FormData();141+			form.set("action", action);142+			form.set("filter", "closed");143+			for (const issue of issues) form.append("issue", String(issue));144+			return operator.post("/admin/issues", form);145+		};146+		const status = () =>147+			inst.kepr148+				.query("SELECT status, hidden FROM issues WHERE id IN (?, ?) ORDER BY id")149+				.all(...ticked);150+		expect(await list()).toContain(`name="issue" value="${ticked[0]}"`);151+		const closed = await bulk("close");152+		expect(closed.status).toBe(303);153+		expect(closed.headers.get("location")).toBe("/admin/issues?filter=closed");154+		expect(status()).toEqual([155+			{ status: "closed", hidden: 0 },156+			{ status: "closed", hidden: 0 },157+		]);158+		await bulk("hide");159+		expect(status().map((row) => (row as { hidden: number }).hidden)).toEqual([1, 1]);160+		await bulk("unhide");161+		await bulk("reopen");162+		expect(status()).toEqual([163+			{ status: "open", hidden: 0 },164+			{ status: "open", hidden: 0 },165+		]);166+		expect((await bulk("close", [999_999])).status).toBe(404);167+		expect((await bulk("explode")).status).toBe(404);168+	});169+137170 	test("blocking a reporter lists the block under the tabs until it is lifted", async () => {138171 		expect(await list()).not.toContain("blocked reporters");139172 		const blocked = await operator.post("/admin/issues/app/2", { action: "block" });

view file