528bcf043cac

edit each build's notes and visibility on its repo's admin page, replacing the releases section (v3.0.143)

time
2026-09-30 13:47 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
admin repo settings
parent
086289d5af85
7 files package.json+1 −1src/admin/console.ts+0 −1src/admin/releases.ts+0 −81src/admin/repos.ts+70 −16src/server/app.ts+0 −2tests/console.test.ts+5 −4tests/repoworkspace.test.ts+4 −4
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.142",3+	"version": "3.0.143",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/console.ts+0 −1
@@ -23,7 +23,6 @@2323 	["/admin", "overview"],2424 	["/admin/repos", "repos"],2525 	["/admin/issues", "issues"],26-	["/admin/releases", "releases"],2726 	["/admin/notifications", "notifications"],2827 	["/admin/email", "email"],2928 	["/admin/bucket", "bucket"],

view file

src/admin/releases.ts+0 −81
@@ -1,81 +0,0 @@1-import type { Context, Hono } from "hono";2-import { MAX_CHARS } from "../issues/spam.ts";3-import { openMirror } from "../mirror/store.ts";4-import { html } from "../pages/html.ts";5-import { plural } from "../pages/layout.ts";6-import { type OperatorRelease, operatorReleases, setRelease } from "../releases/releases.ts";7-import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";8-import { allRepos, findRepo } from "./store.ts";9-10-/**11- * §24's Releases section: versions with download counts, displayed notes and hiding. Everything12- * lives in kepr.db; nothing is deleted, since the bucket key is read-only.13- */14-export function registerReleases(app: Hono, ctx: AdminContext): void {15-	app.get("/admin/releases", (c) => c.html(releasesPage(c, ctx, "")));16-17-	app.post("/admin/releases/:repo/:version", async (c) => {18-		const repo = findRepo(ctx.kepr, c.req.param("repo"));19-		const release =20-			repo &&21-			operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name).find(22-				(r) => r.version === c.req.param("version"),23-			);24-		if (!repo || !release) return c.notFound();25-		const fields = await textFields(c);26-		const notes = (fields.notes ?? "").replace(/\r\n/g, "\n");27-		if (notes.length > MAX_CHARS) {28-			const error = `${repo.name} ${release.version}: notes are over ${MAX_CHARS.toLocaleString("en-US")} characters.`;29-			return c.html(releasesPage(c, ctx, error), 400);30-		}31-		// Notes equal to the record's are no override, so a later record edit would show through.32-		const override = notes === release.recordNotes ? null : notes;33-		setRelease(ctx.kepr, repo.name, release.version, fields.hidden === "1", override);34-		return c.redirect(`/admin/releases#${repo.name}-${release.version}`, 303);35-	});36-}37-38-function releasesPage(c: Context, ctx: AdminContext, error: string): string {39-	const sections = allRepos(ctx.kepr).flatMap((repo) => {40-		const releases = operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name);41-		if (releases.length === 0) return [];42-		return html`<section class="repo-section">43-<h2 class="section-label mono">${repo.name}</h2>44-${releases.map((release) => releaseForm(c, repo.name, release))}45-</section>`;46-	});47-	const body = html`${notice(error)}48-${49-	sections.length > 050-		? sections51-		: html`<p class="muted mono save-empty">52-no releases in the bucket yet53-</p>`54-}`;55-	return adminPage(c, ctx, "releases", body);56-}57-58-function releaseForm(c: Context, repo: string, release: OperatorRelease) {59-	const total = release.downloads.reduce((sum, [, n]) => sum + n, 0);60-	const id = `${repo}-${release.version}`;61-	return html`<article class="issue-post" id="${id}">62-<p class="issue-post-head mono">63-${/* this row stays one line: splitting its spans changes the HTML that tests/console.test.ts pins */ ""}${html`<span class="issue-author">${release.version}</span>`}${html`<span>${release.hidden ? "hidden" : "shown"}</span>`}${html`<span>${plural(total, "download")}</span>`}${release.downloads.map(64-	([platform, n]) => html`<span class="muted">${platform} ${n}</span>`,65-)}66-</p>67-<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}">68-${formField(c)}69-<label class="issues-label" for="${id}-notes">notes70-${release.notes !== release.recordNotes ? html` <span class="muted">(edited)</span>` : ""}</label>71-<textarea class="issues-textarea" id="${id}-notes" name="notes" rows="3"72-maxlength="${MAX_CHARS}">${release.notes}</textarea>73-${74-	// One line on purpose: Biome folds a hole-by-hole split back together, and breaking the75-	// tag itself would put whitespace in the HTML that tests/console.test.ts pins.76-	""77-}<label class="issues-label">${html`<input type="checkbox"`}${html` name="hidden" value="1"`}${release.hidden ? html` checked` : ""}> hidden</label>78-<button class="issues-submit" type="submit">save ${release.version}</button>79-</form>80-</article>`;81-}

view file at the parent

src/admin/repos.ts+70 −16
@@ -1,11 +1,17 @@11 import type { Context, Hono } from "hono";22 import { syncAll } from "../bucket/sync.ts";3+import { MAX_CHARS } from "../issues/spam.ts";34 import { operatorIssues } from "../issues/store.ts";45 import { languageLines, repoStats } from "../mirror/queries.ts";56 import { openMirror, quarantinedPaths } from "../mirror/store.ts";67 import { html } from "../pages/html.ts";78 import { formatDate, plural } from "../pages/layout.ts";8-import { operatorReleases, visibleReleases } from "../releases/releases.ts";9+import {10+	type OperatorRelease,11+	operatorReleases,12+	setRelease,13+	visibleReleases,14+} from "../releases/releases.ts";915 import { type RepoText, repoText, setRepoText } from "../server/db.ts";1016 import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";1117 import { type AdminRepo, allRepos, findRepo, type RepoEdit, updateRepo } from "./store.ts";@@ -24,7 +30,7 @@2430 		.filter((name) => name !== "");2531 }2632 27-/** §24's Repos section: visibility (§20), card settings, sync status and "sync now". */33+/** §24's Repos section: visibility (§20), card settings, texts, builds, sync status and "sync now". */2834 export function registerRepos(app: Hono, ctx: AdminContext): void {2935 	app.get("/admin/repos", (c) => c.html(reposPage(c, ctx, "")));3036 @@ -47,7 +53,8 @@4753 			languages: languages.join(", "),4854 			issues_paused: fields.issues_paused === "1" ? 1 : 0,4955 		};50-		const refuse = (error: string) => c.html(reposPage(c, ctx, `${repo.name}: ${error}`), 400);56+		const refuse = (error: string) =>57+			c.html(reposPage(c, ctx, `${repo.name}: ${error}`, repo.name), 400);5158 		const texts = (["description", "details"] as const).map((field): [RepoText, string] => [5259 			field,5360 			(fields[field] ?? "").replace(/\r\n/g, "\n").trim(),@@ -74,11 +81,36 @@7481 		for (const [field, value] of texts) setRepoText(ctx.kepr, repo.name, field, value);7582 		return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303);7683 	});84+85+	// A build's displayed notes and hiding live in kepr.db; the record in the bucket is untouched.86+	app.post("/admin/releases/:repo/:version", async (c) => {87+		const repo = findRepo(ctx.kepr, c.req.param("repo"));88+		const release =89+			repo &&90+			operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name).find(91+				(r) => r.version === c.req.param("version"),92+			);93+		if (!repo || !release) return c.notFound();94+		const fields = await textFields(c);95+		const notes = (fields.notes ?? "").replace(/\r\n/g, "\n");96+		if (notes.length > MAX_CHARS) {97+			const limit = MAX_CHARS.toLocaleString("en-US");98+			const error = `${repo.name} ${release.version}: notes are over ${limit} characters.`;99+			return c.html(reposPage(c, ctx, error, repo.name), 400);100+		}101+		// Notes equal to the record's are no override, so a later record edit would show through.102+		const override = notes === release.recordNotes ? null : notes;103+		setRelease(ctx.kepr, repo.name, release.version, fields.hidden === "1", override);104+		return c.redirect(105+			`/admin/repos?repo=${repo.name}&saved=1#${repo.name}-${release.version}`,106+			303,107+		);108+	});77109 }78110 79-function reposPage(c: Context, ctx: AdminContext, error: string): string {111+function reposPage(c: Context, ctx: AdminContext, error: string, chosen = c.req.query("repo")) {80112 	const repos = allRepos(ctx.kepr);81-	const selected = repos.find((repo) => repo.name === c.req.query("repo")) ?? repos[0];113+	const selected = repos.find((repo) => repo.name === chosen) ?? repos[0];82114 	const body = html`${notice(error)}83115 <p class="admin-intro">Choose a repository to manage its visibility and public page.</p>84116 <form method="post" action="/admin/sync">@@ -99,7 +131,7 @@99131 	return adminPage(c, ctx, "repos", body);100132 }101133 102-/** The selected repo at a glance: its numbers, languages, description and releases (§24). */134+/** The selected repo at a glance: its numbers and languages (§24). */103135 function workspace(ctx: AdminContext, repo: AdminRepo) {104136 	const mirror = openMirror(ctx.mirrorDir, repo.name);105137 	const stats = repoStats(mirror);@@ -124,21 +156,13 @@124156 	([language, lines], i) =>125157 		html`${i > 0 ? " · " : " "}${language} <span class="muted">${plural(lines, "line")}</span>`,126158 )}</p>127-${128-	releases.length > 0 &&129-	html`<ul class="admin-releases mono">${releases.map(130-		(release) => html`<li><a href="/admin/releases#${repo.name}-${release.version}">131-${release.version}</a> <span class="muted">${release.hidden ? "hidden" : "shown"} · ${plural(132-			downloads(release),133-			"download",134-		)}</span></li>`,135-	)}</ul>`136-}`;159+`;137160 }138161 139162 function repoForm(c: Context, ctx: AdminContext, repo: AdminRepo) {140163 	const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name));141164 	const own = repoText(ctx.kepr, repo.name);165+	const releases = operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name);142166 	const input = (name: keyof RepoEdit, label: string) =>143167 		html`<label class="issues-label" for="${repo.name}-${name}">${label}</label>144168 <input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;@@ -173,6 +197,11 @@173197 <p class="issue-meta mono">Markdown. Leave either blank to use the README's.</p>174198 <button class="issues-submit" type="submit">save ${repo.name}</button>175199 </form>200+${201+	releases.length > 0 &&202+	html`<section class="repo-section"><h2 class="section-label mono">builds</h2>203+${releases.map((release) => releaseForm(c, repo.name, release))}</section>`204+}176205 </article>`;177206 }178207 @@ -180,4 +209,29 @@180209 	return html`<label class="issues-label" for="${repo}-${name}">${label}</label>181210 <textarea class="issues-textarea" id="${repo}-${name}" name="${name}" rows="${String(rows)}"182211 maxlength="${String(LIMITS[name])}">${value}</textarea>`;212+}213+214+function releaseForm(c: Context, repo: string, release: OperatorRelease) {215+	const total = release.downloads.reduce((sum, [, n]) => sum + n, 0);216+	const id = `${repo}-${release.version}`;217+	return html`<article class="issue-post" id="${id}">218+<p class="issue-post-head mono">219+${/* this row stays one line: splitting its spans changes the HTML that tests/console.test.ts pins */ ""}${html`<span class="issue-author">${release.version}</span>`}${html`<span>${release.hidden ? "hidden" : "shown"}</span>`}${html`<span>${plural(total, "download")}</span>`}${release.downloads.map(220+	([platform, n]) => html`<span class="muted">${platform} ${n}</span>`,221+)}222+</p>223+<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}">224+${formField(c)}225+<label class="issues-label" for="${id}-notes">notes226+${release.notes !== release.recordNotes ? html` <span class="muted">(edited)</span>` : ""}</label>227+<textarea class="issues-textarea" id="${id}-notes" name="notes" rows="3"228+maxlength="${MAX_CHARS}">${release.notes}</textarea>229+${230+	// One line on purpose: Biome folds a hole-by-hole split back together, and breaking the231+	// tag itself would put whitespace in the HTML that tests/console.test.ts pins.232+	""233+}<label class="issues-label">${html`<input type="checkbox"`}${html` name="hidden" value="1"`}${release.hidden ? html` checked` : ""}> hidden</label>234+<button class="issues-submit" type="submit">save ${release.version}</button>235+</form>236+</article>`;183237 }

view file

src/server/app.ts+0 −2
@@ -9,7 +9,6 @@99 import { registerEmail } from "../admin/email.ts";1010 import { registerIssues } from "../admin/issues.ts";1111 import { registerNotifications } from "../admin/notifications.ts";12-import { registerReleases } from "../admin/releases.ts";1312 import { registerRepos } from "../admin/repos.ts";1413 import { registerSite } from "../admin/site.ts";1514 import { registerRepoText } from "../admin/text.ts";@@ -125,7 +124,6 @@125124 	registerStatus(app, admin);126125 	registerRepos(app, admin);127126 	registerIssues(app, admin);128-	registerReleases(app, admin);129127 	registerSite(app, admin);130128 	registerBucket(app, admin);131129 	registerNotifications(app, admin);

view file

tests/console.test.ts+5 −4
@@ -282,7 +282,7 @@282282 	});283283 284284 	test("every version is listed with its download counts and notes", async () => {285-		const page = await (await operator.get("/admin/releases")).text();285+		const page = await (await operator.get("/admin/repos?repo=app")).text();286286 		expect(page).toContain('id="app-1.1.0"');287287 		expect(page).toContain('id="app-1.0.0"');288288 		expect(page.indexOf('id="app-1.1.0"')).toBeLessThan(page.indexOf('id="app-1.0.0"'));@@ -291,10 +291,11 @@291291 	});292292 293293 	test("edited notes replace the record's everywhere; the record's own text clears the edit", async () => {294-		expect((await edit("1.1.0", { notes: "Better <notes>." })).status).toBe(303);294+		const saved = await edit("1.1.0", { notes: "Better <notes>." });295+		expect(saved.headers.get("location")).toBe("/admin/repos?repo=app&saved=1#app-1.1.0");295296 		expect(JSON.parse(await text("/api/app/releases/latest")).notes).toBe("Better <notes>.");296297 		expect(await text("/app/releases")).toContain("Better &lt;notes&gt;.");297-		expect(await (await operator.get("/admin/releases")).text()).toContain("(edited)");298+		expect(await (await operator.get("/admin/repos?repo=app")).text()).toContain("(edited)");298299 		expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);299300 		const row = inst.kepr.query("SELECT notes FROM release_settings WHERE version = '1.1.0'").get();300301 		expect(row).toEqual({ notes: null });@@ -308,7 +309,7 @@308309 		expect(await text("/api/app/releases/history")).not.toContain("1.1.0");309310 		expect(await text("/app/releases")).not.toContain("1.1.0");310311 		expect((await inst.app.request("/app/releases/1.1.0/linux-x86_64")).status).toBe(404);311-		const page = await (await operator.get("/admin/releases")).text();312+		const page = await (await operator.get("/admin/repos?repo=app")).text();312313 		expect(page).toContain('id="app-1.1.0"');313314 		expect(page).toContain('value="1" checked> hidden');314315 		expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);

view file

tests/repoworkspace.test.ts+4 −4
@@ -80,14 +80,14 @@8080 		});8181 	});8282 83-	test("lists its releases with visibility and downloads, linking to the Releases section", async () => {83+	test("edits its builds in place, with visibility and downloads", async () => {8484 		inst.kepr.run(8585 			"INSERT INTO release_settings (repo, version, hidden) VALUES ('app', '1.0.0', 1)",8686 		);8787 		const page = await workspace();88-		expect(page).toContain('href="/admin/releases#app-1.1.0"');89-		expect(page).toMatch(/1\.1\.0[\s\S]*?shown · 2 downloads/);90-		expect(page).toMatch(/1\.0\.0[\s\S]*?hidden · 0 downloads/);88+		expect(page).toContain('action="/admin/releases/app/1.1.0"');89+		expect(page).toContain("<span>shown</span><span>2 downloads</span>");90+		expect(page).toContain("<span>hidden</span><span>0 downloads</span>");9191 		inst.kepr.run("DELETE FROM release_settings");9292 	});9393 

view file