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
↓ kepr-5487c6f1.tar.gzatlas at this save
files changed5 files
package.json+1 −1public/css/kepr.css+9 −0src/admin/issues.ts+46 −8src/issues/store.ts+28 −0tests/triage.test.ts+33 −0package.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": {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;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) => {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 /**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" });