e2328c2de1b6

show red open-issue counts beside amber handle links in repo headers (v3.1.19)

time
2026-10-08 15:47 UTC · 4 days ago
machine
Junes-MacBook-Pro.local
agent
gpt-6
session
repo header issue indicators
parent
542f2ac04155
4 files package.json+1 −1public/css/kepr.css+5 −5src/pages/repo.ts+10 −1tests/pages.test.ts+31 −1
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.1.18",3+	"version": "3.1.19",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+5 −5
@@ -306,13 +306,9 @@306306 .repo-contributor {307307 	margin: 4px 0 0;308308 	font-size: 0.8125rem;309-	color: var(--text-secondary);309+	color: var(--accent);310310 	animation: fadeUp 280ms 40ms ease both;311311 }312-.repo-contributor a:hover {313-	color: var(--accent);314-	text-decoration-color: var(--accent);315-}316312 .repo-desc,317313 .repo-homepage {318314 	margin: 0 0 10px;@@ -390,6 +386,10 @@390386 .repo-tab.active {391387 	color: var(--accent);392388 	border-bottom-color: var(--accent);389+}390+.repo-issue-count {391+	margin-left: 0.5em;392+	color: var(--red);393393 }394394 .repo-panel {395395 	padding: 10px 0 16px;

view file

src/pages/repo.ts+10 −1
@@ -1,6 +1,7 @@11 import type { Hono } from "hono";22 import { isSafePath } from "../bucket/repo.ts";33 import { describeFile } from "../describe/describe.ts";4+import { issueCounts } from "../issues/store.ts";45 import {56 	fileAt,67 	filesAt,@@ -133,6 +134,7 @@133134 	const steal = `koh steal ${ctx.domain}/${name}`;134135 	const stats = repoStats(repo.mirror);135136 	const handle = setting(ctx.kepr, "handle");137+	const openIssues = issueCounts(ctx.kepr, name).open;136138 	// §21.4: the version is the newest release; with none, the version the project declares, by137139 	// Koh's rule, at its newest save.138140 	const version =@@ -162,7 +164,14 @@162164 <div class="repo-tabbar">163165 <nav class="repo-tabs" aria-label="Repository panels">${PANELS.map(164166 		([label, path]) =>165-			html`<a class="repo-tab" href="/${name}/${path}" data-panel="${path}">${label}</a>`,167+			html`<a class="repo-tab" href="/${name}/${path}" data-panel="${path}">${label}${168+				path === "issues" &&169+				openIssues > 0 &&170+				html`<span class="repo-issue-count" aria-label="${plural(171+					openIssues,172+					"open issue",173+				)}">${openIssues}</span>`174+			}</a>`,166175 	)}</nav>167176 <div class="install-lines">168177 <button type="button" class="repo-steal-btn" data-copy="${steal}">${steal}</button>

view file

tests/pages.test.ts+31 −1
@@ -1,3 +1,4 @@1+import type { Database } from "bun:sqlite";12 import { beforeAll, describe, expect, test } from "bun:test";23 import { mkdir, mkdtemp, writeFile } from "node:fs/promises";34 import { tmpdir } from "node:os";@@ -14,6 +15,7 @@1415 1516 let fx: Fixture;1617 let app: Hono;18+let kepr: Database;1719 let mirrorDir: string;1820 let tailSaves: string[];1921 const get = (path: string) => app.request(path);@@ -58,7 +60,7 @@5860 	const dataDir = await mkdtemp(join(tmpdir(), "kepr-pages-"));5961 	await mkdir(join(dataDir, "assets"));6062 	await writeFile(join(dataDir, "assets", "plain.txt"), "asset\n");61-	const kepr = openKeprDb(dataDir);63+	kepr = openKeprDb(dataDir);6264 	mirrorDir = join(dataDir, "mirror");6365 	for (const bucketDir of [fx.bucketDir, secretRepo.bucketDir, large.bucketDir, tail.bucketDir]) {6466 		const bucket = openBucket({ kind: "folder", path: bucketDir });@@ -114,6 +116,34 @@114116 		expect(historyIds(page)).toEqual([head(), second(), first()]);115117 		expect(page).toContain('data-copy="koh steal kepr.example/demo"');116118 		expect(page).toContain('href="/demo/releases" data-panel="releases">builds</a>');119+	});120+121+	test("the issues tab counts only this repo's visible open issues and hides zero", async () => {122+		kepr.run("BEGIN");123+		try {124+			kepr.run(`INSERT INTO issues (repo, number, title, body, type, filed_at, hidden, status) VALUES125+('demo', 1, 'one', 'body', 'bug', 1, 0, 'open'),126+('demo', 2, 'two', 'body', 'bug', 1, 0, 'open'),127+('demo', 3, 'three', 'body', 'bug', 1, 0, 'open'),128+('demo', 4, 'hidden', 'body', 'bug', 1, 1, 'open'),129+('demo', 5, 'closed', 'body', 'bug', 1, 0, 'closed'),130+('large', 1, 'another repo', 'body', 'bug', 1, 0, 'open')`);131+			expect(await body("/demo")).toContain(132+				'<span class="repo-issue-count" aria-label="3 open issues">3</span>',133+			);134+			kepr.run("UPDATE issues SET status = 'closed' WHERE repo = 'demo' AND number = 1");135+			expect(await body("/demo")).toContain(136+				'<span class="repo-issue-count" aria-label="2 open issues">2</span>',137+			);138+			kepr.run("UPDATE issues SET status = 'closed' WHERE repo = 'demo' AND number = 2");139+			expect(await body("/demo")).toContain(140+				'<span class="repo-issue-count" aria-label="1 open issue">1</span>',141+			);142+			kepr.run("UPDATE issues SET status = 'closed' WHERE repo = 'demo'");143+			expect(await body("/demo")).not.toContain('class="repo-issue-count"');144+		} finally {145+			kepr.run("ROLLBACK");146+		}117147 	});118148 119149 	test("a page past the last, or too large to bind, is page 1", async () => {

view file