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
↓ kepr-528bcf04.tar.gzatlas at this save
files changed7 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 −4package.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": {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"],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-}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 }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);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 <notes>.");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);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